@uzuhq/code-sdk 0.4.0 → 0.5.0

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/index.d.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  * scenario ディレクトリで `uzu dev` を実行するだけで良い。 子 iframe は既存 online mode
16
16
  * (`?server=ws://localhost:<port>` 経路) で dev-server に接続する。
17
17
  */
18
- export type { PlayScreenMessage, BridgeChannel, BridgeMessage, Seat, SeatKind, Emit, ServerEvent, SeededRandom, GameLogic, GameConfig, SyncConfig, PatchFn, SetFn, Operation, ConnectionState, ConnectionCallbacks, PlayerVoiceState, PlayersChangedMessage, ActionContext, ActionHandler, ServerOnlyAction, ServerOnlyActionContext, ServerOnlyActionHandlerFn, } from './types.js';
18
+ export type { PlayScreenMessage, BridgeChannel, BridgeMessage, Seat, SeatKind, Emit, ServerEvent, SeededRandom, GameLogic, GameConfig, SyncConfig, PatchFn, SetFn, Operation, ConnectionState, ConnectionCallbacks, PlayerVoiceState, PlayersChangedMessage, ActionContext, ActionHandler, ServerActionContext, ServerActionHandler, ServerOnlyAction, ServerOnlyActionContext, ServerOnlyActionHandlerFn, } from './types.js';
19
19
  export { SERVER_TIME, DEFAULT_ICON_URLS } from './types.js';
20
20
  export { serverOnly, isServerOnlyAction } from './server-only.js';
21
21
  export { Room } from './room.js';
@@ -6,9 +6,10 @@
6
6
  * ブラウザ内 LocalGameRoom — GameRoom DO の動作をサーバーレスで再現する。
7
7
  * server_url がない場合に vite dev だけで ServerAction シナリオをローカル実行する。
8
8
  *
9
- * online (`runOnlineServerAction`) と挙動を揃えるため、standard handler は同期で実行し
9
+ * online (`runOnlineServerAction`) と挙動を揃えるため、`logic.actions` は同期で実行し
10
10
  * `sendAction()` 直後に `onState()` を同期発火する (楽観的更新だけで完結する仕様)。
11
- * serverOnly handler のみ `await` で非同期実行し、完了後に `onState()` を呼ぶ。
11
+ * `logic.serverActions` `Promise<void>` を返しうるので `await` で実行し、完了後に
12
+ * `onState()` を呼ぶ。同名なら actions → serverActions の順で走る (サーバーと同じ)。
12
13
  */
13
14
  import type { GameConfig } from '../types.js';
14
15
  import type { RunHandle } from '../dev-hooks.js';
@@ -24,39 +24,49 @@ export function runLocalServerAction(config) {
24
24
  let state = logic.setup(players, random);
25
25
  let tick = 0;
26
26
  const playerInputs = {};
27
- // Action 処理。standard handler は同期実行で `sendAction()` 直後の同期 onState
28
- // 保証する (online の楽観的更新と同じ挙動)。serverOnly handler は `Promise<void>`
29
- // を返しうるので `await` で実行し、完了後に events と onState を発火する。
27
+ // Action 処理。`actions` は同期実行で `sendAction()` 直後の同期 onState を保証する
28
+ // (online の楽観的更新と同じ挙動)。`serverActions` は `Promise<void>` を返しうるので
29
+ // `await` で実行し、完了後に events と onState を発火する。
30
30
  // どちらも 1 回しか実行しない (online の「楽観 → サーバー確定」の 2 段階は再現しない)。
31
31
  const dispatchAction = (type, payload) => {
32
- const handler = logic.actions[type];
33
- if (!handler)
32
+ const plain = logic.actions[type];
33
+ // 移行期: 旧 serverOnly() を actions に入れたままの logic も動かす。
34
+ const legacyServerOnly = isServerOnlyAction(plain) ? plain : null;
35
+ const server = logic.serverActions?.[type] ?? legacyServerOnly;
36
+ if (!plain && !server)
34
37
  return;
35
- const actionEvents = [];
36
- const actionEmit = (name, data) => actionEvents.push({ name, data: data ?? {} });
37
- if (isServerOnlyAction(handler)) {
38
- void (async () => {
39
- try {
40
- await handler(state, payload ?? {}, myId, actionEmit, { tick });
41
- }
42
- catch (err) {
43
- console.warn('[SDK LocalServerAction] Action error:', err);
44
- return;
45
- }
46
- dispatchEvents(actionEvents);
47
- onState(state, myId);
48
- })();
49
- return;
50
- }
51
- try {
52
- handler(state, payload ?? {}, myId, actionEmit, {});
38
+ // events emit 元ごとに分ける。online では actions 由来がクライアント先読みの
39
+ // 時点で発火するので、ここでも plain の実行直後に流す。共有すると serverActions
40
+ // `await fetch()` を持つ場合に actions 側の events までその分遅れてしまい、
41
+ // 「ローカルモードだけ音が遅れる」というモード差になる。
42
+ const plainEvents = [];
43
+ const serverEvents = [];
44
+ const plainEmit = (name, data) => plainEvents.push({ name, data: data ?? {} });
45
+ const serverEmit = (name, data) => serverEvents.push({ name, data: data ?? {} });
46
+ if (plain && !legacyServerOnly) {
47
+ try {
48
+ plain(state, payload ?? {}, myId, plainEmit, {});
49
+ }
50
+ catch (err) {
51
+ console.warn('[SDK LocalServerAction] Action error:', err);
52
+ return;
53
+ }
54
+ dispatchEvents(plainEvents);
55
+ onState(state, myId);
53
56
  }
54
- catch (err) {
55
- console.warn('[SDK LocalServerAction] Action error:', err);
57
+ if (!server)
56
58
  return;
57
- }
58
- dispatchEvents(actionEvents);
59
- onState(state, myId);
59
+ void (async () => {
60
+ try {
61
+ await server(state, payload ?? {}, myId, serverEmit, { tick, random });
62
+ }
63
+ catch (err) {
64
+ console.warn('[SDK LocalServerAction] Action error:', err);
65
+ return;
66
+ }
67
+ dispatchEvents(serverEvents);
68
+ onState(state, myId);
69
+ })();
60
70
  };
61
71
  inputs(dispatchAction);
62
72
  onState(state, myId);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,208 @@
1
+ /**
2
+ * local-server-action.ts (ソロモード) の unit test。
3
+ *
4
+ * ソロモードは「クライアント = サーバー」なので `actions` と `serverActions` の
5
+ * 両方を 1 回ずつ実行する。online の「楽観 → サーバー確定」の 2 段階は再現しないが、
6
+ * **events の発火タイミングは online に揃える** 必要がある。
7
+ *
8
+ * online では actions 由来の events はクライアント先読みの時点で即座に発火する。
9
+ * ソロでも同じく plain の実行直後に流さないと、`serverActions` が `await fetch()` を
10
+ * 持つシナリオで「ソロモードだけ音が遅れる」というモード差になる。
11
+ */
12
+ import { describe, expect, it, vi } from 'vitest';
13
+ import { runLocalServerAction } from './local-server-action.js';
14
+ import { serverOnly } from '../server-only.js';
15
+ /** 遅延させた serverActions を持たせるための待ち。 */
16
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
17
+ const run = (logic) => {
18
+ const fired = [];
19
+ const states = [];
20
+ let send = () => { };
21
+ const config = {
22
+ logic,
23
+ playerCount: 1,
24
+ onState: (s) => states.push(structuredClone(s)),
25
+ inputs: (sendAction) => {
26
+ send = sendAction;
27
+ },
28
+ events: {
29
+ moved: () => fired.push('moved'),
30
+ charged: () => fired.push('charged'),
31
+ },
32
+ };
33
+ runLocalServerAction(config);
34
+ return { send: (t, p) => send(t, p), fired, states };
35
+ };
36
+ const baseLogic = (overrides = {}) => ({
37
+ setup: () => ({ moves: 0, charged: 0, rolled: -1 }),
38
+ actions: {},
39
+ update: () => { },
40
+ ...overrides,
41
+ });
42
+ describe('runLocalServerAction', () => {
43
+ describe('actions と serverActions の実行', () => {
44
+ /** actions だけの action は同期実行され、その場で state と events が確定する。 */
45
+ it('actions だけなら同期で state と events が確定する', () => {
46
+ const h = run(baseLogic({
47
+ actions: {
48
+ move: (state, _payload, _playerId, emit) => {
49
+ state.moves += 1;
50
+ emit('moved');
51
+ },
52
+ },
53
+ }));
54
+ h.send('move');
55
+ expect(h.states[h.states.length - 1].moves).toBe(1);
56
+ expect(h.fired).toEqual(['moved']);
57
+ });
58
+ /** serverActions だけの action は、await 完了後に state と events が反映される。 */
59
+ it('serverActions だけなら await 後に反映される', async () => {
60
+ const h = run(baseLogic({
61
+ serverActions: {
62
+ notifyExternal: async (state, _payload, _playerId, emit) => {
63
+ await tick();
64
+ state.charged += 1;
65
+ emit('charged');
66
+ },
67
+ },
68
+ }));
69
+ h.send('notifyExternal');
70
+ expect(h.fired).toEqual([]); // まだ await 中
71
+ await tick();
72
+ await tick();
73
+ expect(h.states[h.states.length - 1].charged).toBe(1);
74
+ expect(h.fired).toEqual(['charged']);
75
+ });
76
+ /**
77
+ * 同名で両方定義されている場合、サーバーと同じく actions → serverActions の順で走る。
78
+ * serverActions は actions が書いた結果を見られる。
79
+ */
80
+ it('同名なら actions → serverActions の順に走る', async () => {
81
+ const order = [];
82
+ const h = run(baseLogic({
83
+ actions: {
84
+ move: (state) => {
85
+ order.push('actions');
86
+ state.moves += 1;
87
+ },
88
+ },
89
+ serverActions: {
90
+ move: (state, _payload, _playerId, _emit, ctx) => {
91
+ order.push('serverActions');
92
+ // actions の結果が見えている
93
+ expect(state.moves).toBe(1);
94
+ state.charged += ctx.tick + 1;
95
+ state.rolled = ctx.random.int(100);
96
+ },
97
+ },
98
+ }));
99
+ h.send('move');
100
+ await tick();
101
+ expect(order).toEqual(['actions', 'serverActions']);
102
+ const last = h.states[h.states.length - 1];
103
+ expect(last.moves).toBe(1);
104
+ expect(last.charged).toBe(1); // tick 0 + 1
105
+ // ctx.random が渡っている (setup 前の -1 から変わっている)
106
+ expect(last.rolled).toBeGreaterThanOrEqual(0);
107
+ });
108
+ });
109
+ describe('events の発火タイミング', () => {
110
+ /**
111
+ * online では actions 由来の events は先読み時に即発火する。ソロでも同じタイミングで
112
+ * 流し、serverActions の await を待たせない。ここが揃っていないとモード差になる。
113
+ */
114
+ it('actions の events は serverActions の await を待たない', async () => {
115
+ const h = run(baseLogic({
116
+ actions: {
117
+ move: (state, _payload, _playerId, emit) => {
118
+ state.moves += 1;
119
+ emit('moved');
120
+ },
121
+ },
122
+ serverActions: {
123
+ move: async (state, _payload, _playerId, emit) => {
124
+ await tick();
125
+ state.charged += 1;
126
+ emit('charged');
127
+ },
128
+ },
129
+ }));
130
+ h.send('move');
131
+ // serverActions がまだ await 中でも actions 側の events は出ている
132
+ expect(h.fired).toEqual(['moved']);
133
+ await tick();
134
+ await tick();
135
+ expect(h.fired).toEqual(['moved', 'charged']);
136
+ });
137
+ });
138
+ describe('エラー処理', () => {
139
+ /** actions が throw したら serverActions へ進まず、state 更新も通知しない。 */
140
+ it('actions が throw したら serverActions を実行しない', () => {
141
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
142
+ let serverRan = false;
143
+ const h = run(baseLogic({
144
+ actions: {
145
+ move: () => {
146
+ throw new Error('boom');
147
+ },
148
+ },
149
+ serverActions: {
150
+ move: () => {
151
+ serverRan = true;
152
+ },
153
+ },
154
+ }));
155
+ h.send('move');
156
+ expect(serverRan).toBe(false);
157
+ warn.mockRestore();
158
+ });
159
+ /** serverActions が throw しても、既に流れた actions 側の events は取り消さない。 */
160
+ it('serverActions が throw しても actions の events は残る', async () => {
161
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
162
+ const h = run(baseLogic({
163
+ actions: {
164
+ move: (state, _payload, _playerId, emit) => {
165
+ state.moves += 1;
166
+ emit('moved');
167
+ },
168
+ },
169
+ serverActions: {
170
+ move: () => {
171
+ throw new Error('boom');
172
+ },
173
+ },
174
+ }));
175
+ h.send('move');
176
+ await tick();
177
+ expect(h.fired).toEqual(['moved']);
178
+ warn.mockRestore();
179
+ });
180
+ });
181
+ describe('旧 serverOnly() の互換', () => {
182
+ /**
183
+ * R2 の古い logic.js は serverOnly() の brand を付けたまま actions に入っている。
184
+ * ソロモードでも brand を見て「先読みしない handler」として扱う。
185
+ */
186
+ it('actions に入った serverOnly() は server 側として実行される', async () => {
187
+ const logic = baseLogic({
188
+ actions: {
189
+ // 新しい型では actions に serverOnly() を入れられない (それが本 PR の狙い)。
190
+ // ここは「R2 に残っている古い logic.js」を再現するための意図的な型違反。
191
+ // @ts-expect-error 移行期の互換挙動を検証するため
192
+ legacy: serverOnly(async (state, _payload, _playerId, emit, ctx) => {
193
+ await tick();
194
+ state.charged += ctx.tick + 1;
195
+ emit('charged');
196
+ }),
197
+ },
198
+ });
199
+ const h = run(logic);
200
+ h.send('legacy');
201
+ expect(h.fired).toEqual([]); // 先読みされていない
202
+ await tick();
203
+ await tick();
204
+ expect(h.states[h.states.length - 1].charged).toBe(1);
205
+ expect(h.fired).toEqual(['charged']);
206
+ });
207
+ });
208
+ });
@@ -8,10 +8,10 @@
8
8
  * トランスポート (WebSocket / BroadcastChannel) と上位ロジック (楽観更新 / pending
9
9
  * キュー / ack ベースの確定 / rollback) を分離し、3 モードで挙動が揃うことを実装で保証する。
10
10
  *
11
- * - `send(type, payload)`: standard handler は同期で先行実行し pending キューへ。
12
- * serverOnly handler は skip して transport にだけ送る。
13
- * - `applyState(state, { ack, from, events })`: フル state を受信した時に呼ぶ。
14
- * - `applyDelta(patches, { ack, from, events })`: JSON Patch を受信した時に呼ぶ
11
+ * - `send(type, payload)`: `logic.actions` handler を同期で先行実行し pending キューへ。
12
+ * `logic.serverActions` は先読みせず transport にだけ送る。
13
+ * - `applyState(state, { ack, from, events, serverEvents })`: フル state を受信した時に呼ぶ。
14
+ * - `applyDelta(patches, { ack, from, events, serverEvents })`: JSON Patch を受信した時に呼ぶ
15
15
  * (適用失敗時は false を返すので transport 側でフル state を再要求する)。
16
16
  * - `rollback(seq)`: `__action_error` 受信時に該当 action を pending から除去して再適用。
17
17
  * - `reset(state)`: 再接続後の state 復元用 (pending を全クリア)。
@@ -27,8 +27,13 @@ export interface ConfirmOptions {
27
27
  ack?: number;
28
28
  /** ack の送信元 player id。自分の pending と一致した時だけ events を skip する */
29
29
  from?: string;
30
- /** サーバー (or 仮想サーバー) dispatch した events */
30
+ /** `logic.actions` 由来の events。自分の ack なら先読み時に発火済みなので skip する */
31
31
  events?: EventEntry[];
32
+ /**
33
+ * `logic.serverActions` 由来の events。先読みでは走らないので、自分の ack でも必ず配信する。
34
+ * これを `events` と混ぜると、同名 action の server 側 events が重複排除で消える。
35
+ */
36
+ serverEvents?: EventEntry[];
32
37
  }
33
38
  export interface OptimisticActionClientConfig<S> {
34
39
  logic: GameLogic<S>;
@@ -33,17 +33,21 @@ export function createOptimisticActionClient(config) {
33
33
  while (i < pendingActions.length) {
34
34
  const { action, payload } = pendingActions[i];
35
35
  const handler = logic.actions[action];
36
- // serverOnly handler send 時点で pending に積まれないので、ここに来るのは
37
- // 素の handler だけ。型を絞るためにも明示的に弾く。
36
+ // 移行期の互換: 旧 serverOnly() actions に入れたままの logic では、その handler は
37
+ // サーバー専用なので先読みの再適用対象から外す (send 時にも pending へ積んでいない)。
38
38
  if (!handler || isServerOnlyAction(handler)) {
39
39
  pendingActions.splice(i, 1);
40
40
  continue;
41
41
  }
42
+ // handler が途中まで state を変更してから throw すると、その部分変更が残ったまま
43
+ // publish されてしまう。1 件ごとに直前の確定形から作り直し、成功したものだけ採用する。
44
+ const base = structuredClone(displayState);
42
45
  try {
43
46
  runPredicted(action, () => handler(displayState, payload, playerId, noopEmit, {}));
44
47
  i++;
45
48
  }
46
49
  catch {
50
+ displayState = base;
47
51
  pendingActions.splice(i, 1);
48
52
  }
49
53
  }
@@ -53,11 +57,13 @@ export function createOptimisticActionClient(config) {
53
57
  * 自分が出した pending action が ack されたかを判定し、events 重複排除と
54
58
  * pending キューの掃除を行う。
55
59
  */
56
- const handleAck = (ack, from, evts) => {
60
+ const handleAck = (ack, from, evts, serverEvts) => {
57
61
  const isMyAck = from === playerId && ack !== undefined && pendingActions.some((p) => p.seq === ack);
58
62
  if (!isMyAck) {
59
63
  dispatchEvents(evts);
60
64
  }
65
+ // serverActions 由来は先読みで走っていないので、自分の ack でも必ず配信する。
66
+ dispatchEvents(serverEvts);
61
67
  if (from === playerId && ack !== undefined) {
62
68
  while (pendingActions.length > 0 && pendingActions[0].seq <= ack) {
63
69
  pendingActions.shift();
@@ -68,11 +74,13 @@ export function createOptimisticActionClient(config) {
68
74
  send(type, payload) {
69
75
  actionSeq++;
70
76
  const seq = actionSeq;
71
- // serverOnly handler は先行実行を skip。pending にも積まないので、ack 受信時の
72
- // isMyAck 判定で false となり、サーバー発の events が普通に emit される。
77
+ // 先読みするのは logic.actions だけ。serverActions transport にだけ送り、
78
+ // 結果は ack (state + serverEvents) で受け取る。
73
79
  const handler = logic.actions[type];
74
80
  // callback 内では displayState の narrowing が効かないので const に退避する。
75
81
  const target = displayState;
82
+ // 旧 serverOnly() が actions に残っている logic では、その handler を先読みすると
83
+ // サーバー専用のはずの副作用がクライアントでも走る。brand を見て弾く。
76
84
  if (handler && !isServerOnlyAction(handler) && target !== null) {
77
85
  try {
78
86
  runPredicted(type, () => handler(target, payload ?? {}, playerId, emit, {}));
@@ -86,7 +94,7 @@ export function createOptimisticActionClient(config) {
86
94
  sendAction({ action: type, payload: payload ?? {}, seq });
87
95
  },
88
96
  applyState(state, options = {}) {
89
- handleAck(options.ack, options.from, options.events ?? []);
97
+ handleAck(options.ack, options.from, options.events ?? [], options.serverEvents ?? []);
90
98
  confirmedState = state;
91
99
  reapplyPendingActions();
92
100
  },
@@ -102,7 +110,7 @@ export function createOptimisticActionClient(config) {
102
110
  if (!ok)
103
111
  return false;
104
112
  confirmedState = cloned;
105
- handleAck(options.ack, options.from, options.events ?? []);
113
+ handleAck(options.ack, options.from, options.events ?? [], options.serverEvents ?? []);
106
114
  reapplyPendingActions();
107
115
  return true;
108
116
  },
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,232 @@
1
+ /**
2
+ * optimistic-action-client.ts の unit test。
3
+ *
4
+ * `logic.actions` (クライアント先読み + サーバーの 2 回実行) と `logic.serverActions`
5
+ * (サーバーのみ 1 回実行) を分離した設計が、events の重複排除と pending キューの
6
+ * 巻き戻しの両方で正しく閉じることを検証する。
7
+ *
8
+ * 特に重要なのは、同名 action で両方が定義されたケース。先読みで走るのは actions 側
9
+ * だけなので、serverActions が emit した events まで「自分の ack だから発火済み」として
10
+ * 捨ててしまうと、サーバー側のイベントが永久に届かなくなる。
11
+ */
12
+ import { describe, expect, it, vi } from 'vitest';
13
+ import { createOptimisticActionClient } from './optimistic-action-client.js';
14
+ import { serverOnly } from '../server-only.js';
15
+ /** 旧 serverOnly() ブランド付き handler。actions に残っている古い logic を再現する。 */
16
+ const legacyServerOnly = serverOnly((state) => {
17
+ state.charged += 1000;
18
+ });
19
+ const makeLogic = () => ({
20
+ setup: () => ({ moves: 0, charged: 0 }),
21
+ actions: {
22
+ move: (state) => {
23
+ state.moves += 1;
24
+ },
25
+ boom: () => {
26
+ throw new Error('always fails');
27
+ },
28
+ // 途中まで state を書き換えてから throw する (部分ミューテーションの検証用)
29
+ partial: (state) => {
30
+ state.moves += 1;
31
+ throw new Error('fails after mutating');
32
+ },
33
+ // 旧 serverOnly() が actions に残っている logic の再現
34
+ // @ts-expect-error 移行期の互換挙動を検証するため意図的に型違反させる
35
+ legacy: legacyServerOnly,
36
+ },
37
+ serverActions: {
38
+ // move と同名 = 同じ action の「サーバーだけで走る続き」
39
+ move: (state, _payload, _playerId, _emit, ctx) => {
40
+ state.charged += ctx.tick;
41
+ },
42
+ // actions に無い名前 = 先読みされない action (旧 serverOnly 相当)
43
+ notifyExternal: (state) => {
44
+ state.charged += 100;
45
+ },
46
+ },
47
+ update: () => { },
48
+ });
49
+ /** 直近の onState 通知。tsconfig の lib が Array#at 未対応なので添字で取る。 */
50
+ const latest = (states) => states[states.length - 1];
51
+ const setup = () => {
52
+ const sent = [];
53
+ const fired = [];
54
+ const states = [];
55
+ const client = createOptimisticActionClient({
56
+ logic: makeLogic(),
57
+ playerId: 'me',
58
+ onState: (s) => states.push(structuredClone(s)),
59
+ events: {
60
+ moved: () => fired.push('moved'),
61
+ charged: () => fired.push('charged'),
62
+ },
63
+ sendAction: ({ action, seq }) => sent.push({ action, seq }),
64
+ });
65
+ client.reset({ moves: 0, charged: 0 });
66
+ return { client, sent, fired, states };
67
+ };
68
+ describe('createOptimisticActionClient', () => {
69
+ describe('先読みの対象', () => {
70
+ /** actions にある handler だけがクライアントで先行実行される。 */
71
+ it('actions の handler は送信時に先行実行される', () => {
72
+ const { client, sent, states } = setup();
73
+ client.send('move');
74
+ expect(latest(states)).toEqual({ moves: 1, charged: 0 });
75
+ expect(sent).toEqual([{ action: 'move', seq: 1 }]);
76
+ });
77
+ /**
78
+ * serverActions にしか無い action は先読みされない。state を触らずサーバーへ送るだけ。
79
+ * 旧 serverOnly() と同じ挙動。
80
+ */
81
+ it('serverActions にしか無い action は先行実行されない', () => {
82
+ const { client, sent, states } = setup();
83
+ client.send('notifyExternal');
84
+ expect(latest(states)).toEqual({ moves: 0, charged: 0 });
85
+ expect(sent).toEqual([{ action: 'notifyExternal', seq: 1 }]);
86
+ });
87
+ /**
88
+ * 同名で両方定義されている場合、先読みされるのは actions 側だけ。
89
+ * serverActions 側の結果 (charged) はサーバーの ack が来るまで反映されない。
90
+ */
91
+ it('同名で両方ある場合、先読みは actions 側だけ', () => {
92
+ const { client, states } = setup();
93
+ client.send('move');
94
+ expect(latest(states)).toEqual({ moves: 1, charged: 0 });
95
+ });
96
+ });
97
+ describe('events の重複排除', () => {
98
+ /**
99
+ * 自分が出した action の ack では、actions 由来の events は先読み時に発火済みなので
100
+ * skip する。二重発火すると効果音が 2 回鳴るなどの不具合になる。
101
+ */
102
+ it('自分の ack では actions 由来の events を発火しない', () => {
103
+ const { client, fired } = setup();
104
+ client.send('move');
105
+ client.applyState({ moves: 1, charged: 0 }, { ack: 1, from: 'me', events: [{ name: 'moved', data: {} }] });
106
+ expect(fired).toEqual([]);
107
+ });
108
+ /**
109
+ * serverActions 由来の events は先読みで走っていないので、自分の ack でも必ず発火する。
110
+ * ここを events と同じ配列で送ると重複排除に巻き込まれて永久に届かなくなる。
111
+ */
112
+ it('自分の ack でも serverEvents は発火する', () => {
113
+ const { client, fired } = setup();
114
+ client.send('move');
115
+ client.applyState({ moves: 1, charged: 5 }, {
116
+ ack: 1,
117
+ from: 'me',
118
+ events: [{ name: 'moved', data: {} }],
119
+ serverEvents: [{ name: 'charged', data: {} }],
120
+ });
121
+ expect(fired).toEqual(['charged']);
122
+ });
123
+ /** 他プレイヤーの action なら先読みしていないので、両方とも発火する。 */
124
+ it('他プレイヤーの ack では両方発火する', () => {
125
+ const { client, fired } = setup();
126
+ client.applyState({ moves: 1, charged: 5 }, {
127
+ ack: 1,
128
+ from: 'other',
129
+ events: [{ name: 'moved', data: {} }],
130
+ serverEvents: [{ name: 'charged', data: {} }],
131
+ });
132
+ expect(fired).toEqual(['moved', 'charged']);
133
+ });
134
+ });
135
+ describe('pending キューと巻き戻し', () => {
136
+ /**
137
+ * pending に積まれるのは actions 分だけ。サーバー確定 state を受けたら、
138
+ * 未 ack の actions だけが再適用される (serverActions 分は state に含まれて来る)。
139
+ */
140
+ it('確定 state の上に未 ack の actions だけを再適用する', () => {
141
+ const { client, states } = setup();
142
+ client.send('move'); // seq 1
143
+ client.send('move'); // seq 2
144
+ // seq 1 だけ確定。charged はサーバー側で加算済みの値が入っている
145
+ client.applyState({ moves: 1, charged: 7 }, { ack: 1, from: 'me' });
146
+ // 確定 (moves:1) + 未 ack の seq 2 を再適用 = moves:2、charged はサーバー値のまま
147
+ expect(latest(states)).toEqual({ moves: 2, charged: 7 });
148
+ });
149
+ /** __action_error で該当 seq を除去し、残りを再適用する。 */
150
+ it('rollback は該当 action だけを取り消す', () => {
151
+ const { client, states } = setup();
152
+ client.send('move'); // seq 1
153
+ client.send('move'); // seq 2
154
+ expect(latest(states)).toEqual({ moves: 2, charged: 0 });
155
+ client.rollback(1);
156
+ // seq 1 が消えて seq 2 だけ再適用される
157
+ expect(latest(states)).toEqual({ moves: 1, charged: 0 });
158
+ });
159
+ /** 先行実行で throw した action は pending に積まれず、送信だけ行われる。 */
160
+ it('先行実行が throw した action は pending に積まれない', () => {
161
+ const { client, sent, states } = setup();
162
+ client.send('boom');
163
+ expect(sent).toEqual([{ action: 'boom', seq: 1 }]);
164
+ // pending が空なので、確定 state がそのまま表示される
165
+ client.applyState({ moves: 9, charged: 9 }, {});
166
+ expect(latest(states)).toEqual({ moves: 9, charged: 9 });
167
+ });
168
+ });
169
+ describe('applyDelta', () => {
170
+ /** delta 経路でも serverEvents の扱いは applyState と揃っている。 */
171
+ it('自分の ack でも serverEvents を発火する', () => {
172
+ const { client, fired } = setup();
173
+ client.send('move');
174
+ const ok = client.applyDelta([{ op: 'replace', path: '/charged', value: 3 }], {
175
+ ack: 1,
176
+ from: 'me',
177
+ events: [{ name: 'moved', data: {} }],
178
+ serverEvents: [{ name: 'charged', data: {} }],
179
+ });
180
+ expect(ok).toBe(true);
181
+ expect(fired).toEqual(['charged']);
182
+ });
183
+ /** patch が当たらないときは events を発火せず false を返す (transport がフル state を再要求する)。 */
184
+ it('patch 適用に失敗したら events を発火せず false を返す', () => {
185
+ const { client, fired } = setup();
186
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
187
+ const ok = client.applyDelta([{ op: 'replace', path: '/missing/deep', value: 1 }], {
188
+ events: [{ name: 'moved', data: {} }],
189
+ serverEvents: [{ name: 'charged', data: {} }],
190
+ });
191
+ expect(ok).toBe(false);
192
+ expect(fired).toEqual([]);
193
+ warn.mockRestore();
194
+ });
195
+ });
196
+ describe('レビュー指摘の回帰防止', () => {
197
+ /**
198
+ * 旧 serverOnly() が actions に残っている logic では、その handler をクライアントで
199
+ * 先読みしてはいけない。サーバー専用の副作用 (fetch 等) が client でも走ってしまう。
200
+ */
201
+ it('actions に残った serverOnly() は先読みしない', () => {
202
+ const { client, sent, states } = setup();
203
+ client.send('legacy');
204
+ // state は動かず、送信だけ行われる
205
+ expect(latest(states)).toEqual({ moves: 0, charged: 0 });
206
+ expect(sent).toEqual([{ action: 'legacy', seq: 1 }]);
207
+ });
208
+ /** 再適用でも同じ。pending に積まれていても brand 付きなら実行しない。 */
209
+ it('再適用でも serverOnly() を実行しない', () => {
210
+ const { client, states } = setup();
211
+ client.send('move'); // seq 1 (pending へ)
212
+ client.send('legacy'); // seq 2 (pending へは積まれない)
213
+ client.applyState({ moves: 0, charged: 0 }, {});
214
+ // 確定 state に move だけが再適用され、legacy の +1000 は入らない
215
+ expect(latest(states)).toEqual({ moves: 1, charged: 0 });
216
+ });
217
+ /**
218
+ * 再適用中に handler が途中まで state を変更してから throw した場合、その部分変更を
219
+ * 残したまま publish してはいけない。
220
+ */
221
+ it('再適用で throw した handler の部分変更を残さない', () => {
222
+ const { client, states } = setup();
223
+ client.send('move'); // seq 1: moves +1
224
+ client.send('partial'); // seq 2: moves +1 してから throw (pending には積まれない)
225
+ // 送信時点で partial は throw するので pending に入らず、moves は 1 のまま
226
+ expect(latest(states).moves).toBe(1);
227
+ // 確定 state を受けて再適用しても、partial の部分変更は混ざらない
228
+ client.applyState({ moves: 0, charged: 0 }, {});
229
+ expect(latest(states)).toEqual({ moves: 1, charged: 0 });
230
+ });
231
+ });
232
+ });
@@ -123,9 +123,10 @@ export function runOnlineServerAction(config, gameEndpoint, roomId, seatId, seat
123
123
  const ack = parsed.ack;
124
124
  const from = parsed.from;
125
125
  const evts = parsed.events ?? [];
126
+ const serverEvts = parsed.serverEvents ?? [];
126
127
  serverSeq = parsed.seq ?? serverSeq + 1;
127
128
  requestStatePending = false;
128
- client.applyState(parsed.state, { ack, from, events: evts });
129
+ client.applyState(parsed.state, { ack, from, events: evts, serverEvents: serverEvts });
129
130
  return;
130
131
  }
131
132
  // ─── Action 結果 (差分パッチ) ────────────────────────
@@ -133,6 +134,7 @@ export function runOnlineServerAction(config, gameEndpoint, roomId, seatId, seat
133
134
  const ack = parsed.ack;
134
135
  const from = parsed.from;
135
136
  const evts = parsed.events ?? [];
137
+ const serverEvts = parsed.serverEvents ?? [];
136
138
  const newSeq = parsed.seq ?? serverSeq + 1;
137
139
  if (newSeq !== serverSeq + 1) {
138
140
  requestFullState();
@@ -142,6 +144,7 @@ export function runOnlineServerAction(config, gameEndpoint, roomId, seatId, seat
142
144
  ack,
143
145
  from,
144
146
  events: evts,
147
+ serverEvents: serverEvts,
145
148
  });
146
149
  if (!ok) {
147
150
  requestFullState();
@@ -3,24 +3,31 @@
3
3
  * - ServerAction仕様: docs/docs/uzu_code/connection-method/arch3-authority.md
4
4
  * - 開発パターン: docs/docs/uzu_code/sdk-guide/patterns.md
5
5
  *
6
- * serverOnly() wrap した handler はクライアント側の楽観的更新(先行実行)を
7
- * スキップし、サーバー側でだけ実行される。fetch などの副作用付き処理を安全に
8
- * 書ける。
6
+ * `serverOnly()` `logic.serverActions` に置き換わった互換 API。
7
+ *
8
+ * 新しく書くコードでは `serverActions` フィールドへ直接置く。`actions` の型からは
9
+ * ユニオンが外れたため、`serverOnly()` で wrap した handler を `actions` に入れることは
10
+ * できない (型エラーになる)。
11
+ *
12
+ * `isServerOnlyAction()` はサーバー側テンプレが残す必要がある。R2 に保存済みの古い
13
+ * logic.js は `serverOnly()` の brand を持ったまま `actions` に入っており、新しい
14
+ * テンプレと組み合わさるため、brand を見ないと先読み対象として扱ってしまう。
9
15
  */
10
- import type { ActionHandler, ServerOnlyAction, ServerOnlyActionHandlerFn } from './types.js';
16
+ import type { ActionHandler, ServerActionHandler, ServerOnlyAction } from './types.js';
11
17
  /**
12
- * server-only action handler を作る。
18
+ * @deprecated `logic.serverActions` に直接書く。
13
19
  *
14
- * 例:
15
20
  * ```ts
16
- * actions: {
17
- * notifyExternal: serverOnly(async (state, payload, playerId) => {
18
- * await fetch('https://example.com/notify', { ... });
19
- * state.notifiedAt = Date.now();
20
- * }),
21
- * }
21
+ * // before
22
+ * actions: { notifyExternal: serverOnly(async (state) => { ... }) }
23
+ * // after
24
+ * serverActions: { notifyExternal: async (state) => { ... } }
22
25
  * ```
23
26
  */
24
- export declare function serverOnly<S>(handler: ServerOnlyActionHandlerFn<S>): ServerOnlyAction<S>;
25
- /** handler が serverOnly() で wrap されているか判定する。 */
26
- export declare function isServerOnlyAction<S>(handler: ActionHandler<S> | ServerOnlyAction<S> | undefined): handler is ServerOnlyAction<S>;
27
+ export declare function serverOnly<S>(handler: ServerActionHandler<S>): ServerOnlyAction<S>;
28
+ /**
29
+ * handler `serverOnly()` wrap されているか判定する。
30
+ *
31
+ * 移行期の互換用。`serverActions` へ移行済みの logic では常に false になる。
32
+ */
33
+ export declare function isServerOnlyAction<S>(handler: ActionHandler<S> | ServerActionHandler<S> | ServerOnlyAction<S> | undefined): handler is ServerOnlyAction<S>;
@@ -1,20 +1,21 @@
1
1
  /**
2
- * server-only action handler を作る。
2
+ * @deprecated `logic.serverActions` に直接書く。
3
3
  *
4
- * 例:
5
4
  * ```ts
6
- * actions: {
7
- * notifyExternal: serverOnly(async (state, payload, playerId) => {
8
- * await fetch('https://example.com/notify', { ... });
9
- * state.notifiedAt = Date.now();
10
- * }),
11
- * }
5
+ * // before
6
+ * actions: { notifyExternal: serverOnly(async (state) => { ... }) }
7
+ * // after
8
+ * serverActions: { notifyExternal: async (state) => { ... } }
12
9
  * ```
13
10
  */
14
11
  export function serverOnly(handler) {
15
12
  return Object.assign(handler, { __serverOnly: true });
16
13
  }
17
- /** handler が serverOnly() で wrap されているか判定する。 */
14
+ /**
15
+ * handler が `serverOnly()` で wrap されているか判定する。
16
+ *
17
+ * 移行期の互換用。`serverActions` へ移行済みの logic では常に false になる。
18
+ */
18
19
  export function isServerOnlyAction(handler) {
19
20
  return (typeof handler === 'function' && '__serverOnly' in handler && handler.__serverOnly === true);
20
21
  }
package/dist/types.d.ts CHANGED
@@ -73,17 +73,25 @@ export interface SeededRandom {
73
73
  * 素の handler はサーバーとクライアント先読みの両方で走る。クライアントが自力で
74
74
  * 再現できない値 (tick / 実時刻 / 乱数) をここで配ると、サーバー・ソロ・dev では
75
75
  * 本物が入るのにオンラインの先読みだけ値がズレる、という一番気付きにくい形で壊れる。
76
- * そういう値が要る action は serverOnly() にして先読みの対象から外す。
76
+ * そういう値が要る処理は `serverActions` 側に書く。
77
77
  */
78
78
  export type ActionContext = Record<never, never>;
79
- /** serverOnly() handler の実行文脈。サーバーでしか走らないので tick を渡せる。 */
80
- export interface ServerOnlyActionContext {
79
+ /** `serverActions` handler の実行文脈。サーバーでしか走らないので tick と乱数を渡せる。 */
80
+ export interface ServerActionContext {
81
81
  tick: number;
82
+ random: SeededRandom;
82
83
  }
84
+ /** @deprecated `ServerActionContext` を使う。 */
85
+ export type ServerOnlyActionContext = ServerActionContext;
83
86
  export type ActionHandler<S> = (state: S, payload: any, playerId: string, emit: Emit, ctx: ActionContext) => void;
84
- export type ServerOnlyActionHandlerFn<S> = (state: S, payload: any, playerId: string, emit: Emit, ctx: ServerOnlyActionContext) => Promise<void> | void;
85
- /** serverOnly() で wrap された handler。`__serverOnly` brand で識別する。 */
86
- export type ServerOnlyAction<S> = ServerOnlyActionHandlerFn<S> & {
87
+ export type ServerActionHandler<S> = (state: S, payload: any, playerId: string, emit: Emit, ctx: ServerActionContext) => Promise<void> | void;
88
+ /** @deprecated `ServerActionHandler` を使う。 */
89
+ export type ServerOnlyActionHandlerFn<S> = ServerActionHandler<S>;
90
+ /**
91
+ * @deprecated `serverActions` フィールドに直接書く。
92
+ * `serverOnly()` で wrap された handler。`__serverOnly` brand で識別する。
93
+ */
94
+ export type ServerOnlyAction<S> = ServerActionHandler<S> & {
87
95
  readonly __serverOnly: true;
88
96
  };
89
97
  export interface GameLogic<S> {
@@ -92,7 +100,22 @@ export interface GameLogic<S> {
92
100
  * ゲームの配役は kind === 'player' (または kind 省略) だけを対象にすること。
93
101
  */
94
102
  setup(seats: Seat[], random: SeededRandom): S;
95
- actions: Record<string, ActionHandler<S> | ServerOnlyAction<S>>;
103
+ /**
104
+ * クライアント先読みとサーバーの両方で走る handler。決定的でなければならない。
105
+ *
106
+ * `serverActions` に同名のキーを置くと、同じ action の「サーバーだけで走る続き」に
107
+ * なる。1 つの action を「即座に反映していい部分」と「サーバーが決める部分」へ
108
+ * 分けられる (例: 駒の移動は先読み、持ち時間の減算はサーバー)。
109
+ */
110
+ actions: Record<string, ActionHandler<S>>;
111
+ /**
112
+ * サーバーでのみ走る handler。実時刻 / 乱数 / fetch など、クライアント先読みで
113
+ * 再現できない処理をここに書く。async 可。
114
+ *
115
+ * `actions` と同名でも別名でもよい。別名だけに置けば「先読みしない action」
116
+ * (旧 `serverOnly()` 相当) になる。
117
+ */
118
+ serverActions?: Record<string, ServerActionHandler<S>>;
96
119
  update(state: S, ctx: {
97
120
  random: SeededRandom;
98
121
  tick: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uzuhq/code-sdk",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "UZU PlayScreen SDK - Flutter ↔ JS ゲーム通信ライブラリ",
5
5
  "type": "module",
6
6
  "exports": {