@uzuhq/code-sdk 0.6.0 → 0.7.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, EventHandler, EventSubscription, ScheduleOptions, Scheduler, ServerActionContext, ServerActionHandler, 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, ActionArgs, ActionContext, ActionHandler, ServerActionArgs, UpdateArgs, UpdateContext, EventHandler, EventSubscription, ScheduleOptions, Scheduler, ServerActionContext, ServerActionHandler, ServerOnlyAction, ServerOnlyActionContext, ServerOnlyActionHandlerFn, } from './types.js';
19
19
  export { SERVER_TIME, DEFAULT_ICON_URLS, SCHEDULED_ACTOR } from './types.js';
20
20
  export { serverOnly, isServerOnlyAction } from './server-only.js';
21
21
  export { serverNow } from './server-clock.js';
@@ -76,7 +76,12 @@ export function runLocalServerAction(config) {
76
76
  const now = Date.now();
77
77
  if (plain && !legacyServerOnly) {
78
78
  try {
79
- plain(state, payload ?? {}, myId, plainEmit, { now, ...makeScheduleCtx() });
79
+ plain({
80
+ state,
81
+ payload: payload ?? {},
82
+ playerId: myId,
83
+ ctx: { now, emit: plainEmit, ...makeScheduleCtx() },
84
+ });
80
85
  }
81
86
  catch (err) {
82
87
  console.warn('[SDK LocalServerAction] Action error:', err);
@@ -89,11 +94,11 @@ export function runLocalServerAction(config) {
89
94
  return;
90
95
  void (async () => {
91
96
  try {
92
- await server(state, payload ?? {}, myId, serverEmit, {
93
- tick,
94
- random,
95
- now,
96
- ...makeScheduleCtx(),
97
+ await server({
98
+ state,
99
+ payload: payload ?? {},
100
+ playerId: myId,
101
+ ctx: { tick, random, now, emit: serverEmit, ...makeScheduleCtx() },
97
102
  });
98
103
  }
99
104
  catch (err) {
@@ -112,13 +117,16 @@ export function runLocalServerAction(config) {
112
117
  const tickEvents = [];
113
118
  const tickEmit = (name, data) => tickEvents.push({ name, data: data ?? {} });
114
119
  try {
115
- logic.update(state, {
116
- random,
117
- tick,
118
- now: Date.now(),
119
- ...makeScheduleCtx(),
120
- emit: tickEmit,
121
- playerInputs,
120
+ logic.update({
121
+ state,
122
+ ctx: {
123
+ random,
124
+ tick,
125
+ now: Date.now(),
126
+ ...makeScheduleCtx(),
127
+ emit: tickEmit,
128
+ playerInputs,
129
+ },
122
130
  });
123
131
  }
124
132
  catch (err) {
@@ -55,9 +55,9 @@ describe('runLocalServerAction', () => {
55
55
  it('actions だけなら同期で state と events が確定する', () => {
56
56
  const h = run(baseLogic({
57
57
  actions: {
58
- move: (state, _payload, _playerId, emit) => {
58
+ move: ({ state, ctx }) => {
59
59
  state.moves += 1;
60
- emit('moved');
60
+ ctx.emit('moved');
61
61
  },
62
62
  },
63
63
  }));
@@ -69,10 +69,10 @@ describe('runLocalServerAction', () => {
69
69
  it('serverActions だけなら await 後に反映される', async () => {
70
70
  const h = run(baseLogic({
71
71
  serverActions: {
72
- notifyExternal: async (state, _payload, _playerId, emit) => {
72
+ notifyExternal: async ({ state, ctx }) => {
73
73
  await tick();
74
74
  state.charged += 1;
75
- emit('charged');
75
+ ctx.emit('charged');
76
76
  },
77
77
  },
78
78
  }));
@@ -91,13 +91,13 @@ describe('runLocalServerAction', () => {
91
91
  const order = [];
92
92
  const h = run(baseLogic({
93
93
  actions: {
94
- move: (state) => {
94
+ move: ({ state }) => {
95
95
  order.push('actions');
96
96
  state.moves += 1;
97
97
  },
98
98
  },
99
99
  serverActions: {
100
- move: (state, _payload, _playerId, _emit, ctx) => {
100
+ move: ({ state, ctx }) => {
101
101
  order.push('serverActions');
102
102
  // actions の結果が見えている
103
103
  expect(state.moves).toBe(1);
@@ -124,16 +124,16 @@ describe('runLocalServerAction', () => {
124
124
  it('actions の events は serverActions の await を待たない', async () => {
125
125
  const h = run(baseLogic({
126
126
  actions: {
127
- move: (state, _payload, _playerId, emit) => {
127
+ move: ({ state, ctx }) => {
128
128
  state.moves += 1;
129
- emit('moved');
129
+ ctx.emit('moved');
130
130
  },
131
131
  },
132
132
  serverActions: {
133
- move: async (state, _payload, _playerId, emit) => {
133
+ move: async ({ state, ctx }) => {
134
134
  await tick();
135
135
  state.charged += 1;
136
- emit('charged');
136
+ ctx.emit('charged');
137
137
  },
138
138
  },
139
139
  }));
@@ -171,9 +171,9 @@ describe('runLocalServerAction', () => {
171
171
  const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
172
172
  const h = run(baseLogic({
173
173
  actions: {
174
- move: (state, _payload, _playerId, emit) => {
174
+ move: ({ state, ctx }) => {
175
175
  state.moves += 1;
176
- emit('moved');
176
+ ctx.emit('moved');
177
177
  },
178
178
  },
179
179
  serverActions: {
@@ -196,13 +196,14 @@ describe('runLocalServerAction', () => {
196
196
  it('actions に入った serverOnly() は server 側として実行される', async () => {
197
197
  const logic = baseLogic({
198
198
  actions: {
199
- // 新しい型では actions serverOnly() を入れられない (それが本 PR の狙い)。
200
- // ここは「R2 に残っている古い logic.js」を再現するための意図的な型違反。
201
- // @ts-expect-error 移行期の互換挙動を検証するため
202
- legacy: serverOnly(async (state, _payload, _playerId, emit, ctx) => {
199
+ // 検証対象は `__serverOnly` brand の判定であって、旧シグネチャの互換ではない。
200
+ // handler 自体は現行の呼び出し形で書いている (旧形式の logic.js は現行テンプレでは
201
+ // 動かない。公開済みゲームが無い前提で互換を切っている)。
202
+ // @ts-expect-error actions serverOnly() を入れるのは型違反だが、brand 判定を試す
203
+ legacy: serverOnly(async ({ state, ctx }) => {
203
204
  await tick();
204
205
  state.charged += ctx.tick + 1;
205
- emit('charged');
206
+ ctx.emit('charged');
206
207
  }),
207
208
  },
208
209
  });
@@ -98,9 +98,11 @@ export function createOptimisticActionClient(config) {
98
98
  // publish されてしまう。1 件ごとに直前の確定形から作り直し、成功したものだけ採用する。
99
99
  const base = structuredClone(displayState);
100
100
  try {
101
- runPredicted(action, () => handler(displayState, payload, playerId, noopEmit, {
102
- now,
103
- ...predictedScheduleCtx(now),
101
+ runPredicted(action, () => handler({
102
+ state: displayState,
103
+ payload,
104
+ playerId,
105
+ ctx: { now, emit: noopEmit, ...predictedScheduleCtx(now) },
104
106
  }));
105
107
  i++;
106
108
  }
@@ -153,9 +155,11 @@ export function createOptimisticActionClient(config) {
153
155
  // サーバー専用のはずの副作用がクライアントでも走る。brand を見て弾く。
154
156
  if (handler && !isServerOnlyAction(handler) && target !== null) {
155
157
  try {
156
- runPredicted(type, () => handler(target, payload ?? {}, playerId, predictEmit, {
157
- now,
158
- ...predictedScheduleCtx(now),
158
+ runPredicted(type, () => handler({
159
+ state: target,
160
+ payload: payload ?? {},
161
+ playerId,
162
+ ctx: { now, emit: predictEmit, ...predictedScheduleCtx(now) },
159
163
  }));
160
164
  pendingActions.push({ seq, action: type, payload: payload ?? {}, now });
161
165
  onState(target, playerId);
@@ -13,20 +13,23 @@ import { describe, expect, it, vi } from 'vitest';
13
13
  import { createOptimisticActionClient } from './optimistic-action-client.js';
14
14
  import { serverOnly } from '../server-only.js';
15
15
  import { resetServerTimeOffset } from '../server-clock.js';
16
- /** 旧 serverOnly() ブランド付き handler。actions に残っている古い logic を再現する。 */
17
- const legacyServerOnly = serverOnly((state) => {
16
+ /**
17
+ * `serverOnly()` brand が付いた handler。`actions` に入っていても先読みされないこと
18
+ * (brand 判定が効くこと) を確かめる。旧シグネチャの互換は検証対象ではない。
19
+ */
20
+ const legacyServerOnly = serverOnly(({ state }) => {
18
21
  state.charged += 1000;
19
22
  });
20
23
  const makeLogic = () => ({
21
24
  setup: () => ({ moves: 0, charged: 0, stampedAt: 0 }),
22
25
  actions: {
23
- move: (state, _payload, _playerId, emit, ctx) => {
26
+ move: ({ state, ctx }) => {
24
27
  state.moves += 1;
25
28
  state.stampedAt = ctx.now;
26
- emit('moved', {});
29
+ ctx.emit('moved', {});
27
30
  },
28
31
  // 先読みでも ctx.schedule を呼ぶ action。型にはあるので呼べてしまう。
29
- startTimer: (state, _payload, _playerId, _emit, ctx) => {
32
+ startTimer: ({ state, ctx }) => {
30
33
  state.stampedAt = ctx.schedule({
31
34
  key: 'timer',
32
35
  after: 60,
@@ -34,15 +37,15 @@ const makeLogic = () => ({
34
37
  });
35
38
  },
36
39
  // 予測の結果によって別の event を出す (予測ミスの再現用)
37
- guess: (state, payload, _playerId, emit) => {
40
+ guess: ({ state, payload, ctx }) => {
38
41
  state.moves += 1;
39
- emit(payload?.willEmit ?? 'accepted', payload?.data ?? {});
42
+ ctx.emit(payload?.willEmit ?? 'accepted', payload?.data ?? {});
40
43
  },
41
44
  boom: () => {
42
45
  throw new Error('always fails');
43
46
  },
44
47
  // 途中まで state を書き換えてから throw する (部分ミューテーションの検証用)
45
- partial: (state) => {
48
+ partial: ({ state }) => {
46
49
  state.moves += 1;
47
50
  throw new Error('fails after mutating');
48
51
  },
@@ -52,12 +55,12 @@ const makeLogic = () => ({
52
55
  },
53
56
  serverActions: {
54
57
  // move と同名 = 同じ action の「サーバーだけで走る続き」
55
- move: (state, _payload, _playerId, emit, ctx) => {
58
+ move: ({ state, ctx }) => {
56
59
  state.charged += ctx.tick;
57
- emit('charged', {});
60
+ ctx.emit('charged', {});
58
61
  },
59
62
  // actions に無い名前 = 先読みされない action (旧 serverOnly 相当)
60
- notifyExternal: (state) => {
63
+ notifyExternal: ({ state }) => {
61
64
  state.charged += 100;
62
65
  },
63
66
  },
package/dist/types.d.ts CHANGED
@@ -66,24 +66,8 @@ export type EventHandler = (data: Record<string, unknown>) => void;
66
66
  /**
67
67
  * events の購読宣言。
68
68
  *
69
- * `predict` は必須。 このイベントを「クライアント先読みの時点で実行してよいか」を
70
- * シナリオ側が明示する。
71
- *
72
- * | `predict` | 実行タイミング | 向いているもの |
73
- * |---|---|---|
74
- * | `true` | 先読み時に即実行。確定は待たない | 短く上書きできる SE、画面フラッシュ |
75
- * | `false` | サーバー確定後に 1 回だけ | 長い演出、ハプティクス、計測、外部通知 |
76
- *
77
- * IMPORTANT: 先読みは外れることがあり、実行してしまったものは取り消せない。
78
- * 「起きなかった出来事」で実行されて困るもの (analytics / 実績解除 / 長いカットイン)
79
- * は必ず `predict: false` にすること。誤送信は静かに、そして永久に残る。
80
- *
81
- * ```ts
82
- * events: {
83
- * 'dialogue.line': { predict: true, handler: (d) => playSound('page') },
84
- * 'game.finished': { predict: false, handler: () => playFanfare() },
85
- * }
86
- * ```
69
+ * 先読みは外れることがあり、実行してしまったものは取り消せない。取り消せない副作用
70
+ * (analytics / 実績解除 / 長い演出) は必ず `predict: false` にすること。
87
71
  */
88
72
  export interface EventSubscription {
89
73
  /** 先読み時点で実行してよいか。宣言必須。 */
@@ -111,46 +95,27 @@ export interface SeededRandom {
111
95
  */
112
96
  export interface ActionContext {
113
97
  /**
114
- * このアクションがサーバーで処理される時刻 (ms)
98
+ * サーバーで処理される時刻 (ms)。先読みではクロックオフセットからの推定値。
115
99
  *
116
- * サーバーでは dispatch 時の実時刻。クライアント先読みでは、サーバーとのクロック
117
- * オフセットから推定した値が入る。端末の時計がズレていても影響を受けない。
100
+ * 推定なのでサーバーとは数十 ms ずれる。時刻での分岐に使うと境界で判定が割れるので、
101
+ * 分岐は `update()` (サーバー専用) で行う。
118
102
  *
119
- * IMPORTANT: 推定なのでサーバーの値とは数十 ms ずれる。時刻の **記録** に使うこと。
120
- * `if (ctx.now > deadline)` のような **分岐** に使うと境界付近でクライアントと
121
- * サーバーの判定が割れ、先読みが構造ごと外れる。時刻での分岐は `update()` で行う
122
- * (サーバーでしか走らないので実時刻で正確)。
123
- *
124
- * 再適用 (reconciliation でのやり直し) では初回予測時の値を使い回す。取り直すと
125
- * やり直すたびに値がズレて表示がガタつくため。
103
+ * 同じ action を再適用しても値は変わらない (初回予測時の値を使い回す)。
126
104
  */
127
105
  now: number;
128
- /**
129
- * サーバー権威のタイマー予約。
130
- *
131
- * IMPORTANT: クライアント先読みでは**何も予約しない** (予約はサーバーだけが持つ)。
132
- * 戻り値の絶対時刻は先読みでも計算されるので、表示用の値は即座に出せる。
133
- */
106
+ emit: Emit;
107
+ /** 予約はサーバーだけが持つ。先読みでは戻り値を計算するだけで、予約も取り消しもしない。 */
134
108
  schedule(options: ScheduleOptions): number;
135
- /** 予約を取り消す。クライアント先読みでは何もしない。 */
136
109
  unschedule(key: string): void;
137
110
  }
138
111
  /** 予約されたタイマーの識別子付き宣言。 */
139
112
  export interface ScheduleOptions {
140
- /**
141
- * 予約の識別子。同じ key で予約し直すと**置き換わる**。
142
- *
143
- * key を必須にしているのは、古い予約を確実に消せるようにするため。
144
- * 例: GM がタイマーを 20 分 -> 30 秒に変更したとき、同じ key で予約し直せば
145
- * 古い 20 分の予約は消える。key が無いと両方が生き残り、19 分後に
146
- * 「もう終わったフェーズ」を進める事故になる。
147
- */
113
+ /** 予約の識別子。同じ key で予約し直すと置き換わる (古い予約が残ると二重に発火する)。 */
148
114
  key: string;
149
- /** 発火する絶対時刻 (ms)。`after` と排他。過去を指定すると即座に発火する。 */
115
+ /** 絶対時刻 (ms)。`after` と排他。過去なら即座に発火する。 */
150
116
  at?: number;
151
- /** 何秒後に発火するか。`at` と排他。 */
117
+ /** 何秒後か。`at` と排他。 */
152
118
  after?: number;
153
- /** 発火時に実行する action 名。 */
154
119
  action: string;
155
120
  payload?: Record<string, unknown>;
156
121
  }
@@ -163,22 +128,12 @@ export interface ScheduleOptions {
163
128
  */
164
129
  export interface Scheduler {
165
130
  /**
166
- * 予約する。戻り値は確定した絶対時刻 (ms)
167
- *
168
- * 表示用の `endsAt` と発火の予約が二重管理で drift しないよう、戻り値をそのまま
169
- * state に入れられるようにしている。
170
- *
171
- * ```ts
172
- * state.game.timerEndsAt = ctx.schedule({
173
- * key: 'phaseTimer', after: 1200, action: 'phase.timeout', payload: { phaseId },
174
- * });
175
- * ```
131
+ * 予約して確定した絶対時刻 (ms) を返す。表示用の endsAt にそのまま使える。
176
132
  *
177
- * IMPORTANT: 発火は at-least-once。同じ予約が 2 回実行されうるので、handler 側で
178
- * 「もう進んでいたら何もしない」ガードを書くこと。
133
+ * 発火は at-least-once。同じ予約が 2 回実行されうるので、handler 側で「自分が予約した
134
+ * ものか」を state と突き合わせて弾くこと (action 名や key では区別できない)。
179
135
  */
180
136
  schedule(options: ScheduleOptions): number;
181
- /** 予約を取り消す。存在しない key を渡しても何も起きない。 */
182
137
  unschedule(key: string): void;
183
138
  }
184
139
  /**
@@ -194,13 +149,33 @@ export interface ServerActionContext {
194
149
  random: SeededRandom;
195
150
  /** サーバーの実時刻 (ms)。同じ dispatch の `actions` に渡る `ctx.now` と同一値。 */
196
151
  now: number;
152
+ emit: Emit;
197
153
  schedule(options: ScheduleOptions): number;
198
154
  unschedule(key: string): void;
199
155
  }
200
156
  /** @deprecated `ServerActionContext` を使う。 */
201
157
  export type ServerOnlyActionContext = ServerActionContext;
202
- export type ActionHandler<S> = (state: S, payload: any, playerId: string, emit: Emit, ctx: ActionContext) => void;
203
- export type ServerActionHandler<S> = (state: S, payload: any, playerId: string, emit: Emit, ctx: ServerActionContext) => Promise<void> | void;
158
+ /**
159
+ * action handler の引数。
160
+ *
161
+ * オブジェクトなのは使うものだけ書けるようにするため。`ctx` を入れ子で残しているのは、
162
+ * 実行環境が与えるものをひとまとまりで helper へ渡せるようにするため。
163
+ */
164
+ export interface ActionArgs<S> {
165
+ state: S;
166
+ payload: any;
167
+ playerId: string;
168
+ ctx: ActionContext;
169
+ }
170
+ export type ActionHandler<S> = (args: ActionArgs<S>) => void;
171
+ /** `serverActions` handler の引数。`ctx` にサーバー限定の tick / random が入る。 */
172
+ export interface ServerActionArgs<S> {
173
+ state: S;
174
+ payload: any;
175
+ playerId: string;
176
+ ctx: ServerActionContext;
177
+ }
178
+ export type ServerActionHandler<S> = (args: ServerActionArgs<S>) => Promise<void> | void;
204
179
  /** @deprecated `ServerActionHandler` を使う。 */
205
180
  export type ServerOnlyActionHandlerFn<S> = ServerActionHandler<S>;
206
181
  /**
@@ -210,6 +185,21 @@ export type ServerOnlyActionHandlerFn<S> = ServerActionHandler<S>;
210
185
  export type ServerOnlyAction<S> = ServerActionHandler<S> & {
211
186
  readonly __serverOnly: true;
212
187
  };
188
+ /** `update()` の ctx。サーバーでしか走らないので tick / random / playerInputs を持つ。 */
189
+ export interface UpdateContext {
190
+ random: SeededRandom;
191
+ tick: number;
192
+ /** サーバーの実時刻 (ms)。update はサーバーでしか走らないので常に正確。 */
193
+ now: number;
194
+ schedule(options: ScheduleOptions): number;
195
+ unschedule(key: string): void;
196
+ emit: Emit;
197
+ playerInputs: Record<string, Record<string, any>>;
198
+ }
199
+ export interface UpdateArgs<S> {
200
+ state: S;
201
+ ctx: UpdateContext;
202
+ }
213
203
  export interface GameLogic<S> {
214
204
  /**
215
205
  * seats には kind !== 'player' の席 (spectator / admin) も含まれる。
@@ -232,16 +222,7 @@ export interface GameLogic<S> {
232
222
  * (旧 `serverOnly()` 相当) になる。
233
223
  */
234
224
  serverActions?: Record<string, ServerActionHandler<S>>;
235
- update(state: S, ctx: {
236
- random: SeededRandom;
237
- tick: number;
238
- /** サーバーの実時刻 (ms)。update はサーバーでしか走らないので常に正確。 */
239
- now: number;
240
- schedule(options: ScheduleOptions): number;
241
- unschedule(key: string): void;
242
- emit: Emit;
243
- playerInputs: Record<string, Record<string, any>>;
244
- }): void;
225
+ update(args: UpdateArgs<S>): void;
245
226
  tickRate?: number;
246
227
  }
247
228
  export interface GameConfig<S> extends ConnectionCallbacks {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uzuhq/code-sdk",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "UZU PlayScreen SDK - Flutter ↔ JS ゲーム通信ライブラリ",
5
5
  "type": "module",
6
6
  "exports": {