@uzuhq/code-sdk 0.7.2 → 0.7.3

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.
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `run()` 経由の `inputs` でも同じ縛りが効くことの検証。
3
+ *
4
+ * `SendAction<A & SA>` は `SA` が `Record<string, …>` に落ちた瞬間
5
+ * `keyof` が `string` へ潰れて action 名の検査が丸ごと消える。 直接
6
+ * `SendAction<…>` を declare するだけの検査では踏めないので、 実際に
7
+ * `run()` を通す形で押さえる。
8
+ *
9
+ * 型検査だけが目的なので関数は呼ばない (`run` は window を触る)。
10
+ */
11
+ export declare function _runTypeChecks(): void;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * action 名と payload が型で縛られることの検証。
3
+ *
4
+ * 実行時の振る舞いは無いので、 tsc が通ること自体がテストになる。
5
+ * @ts-expect-error が「実際にエラーになる」ことも同時に確かめている
6
+ * (エラーが出なくなったら @ts-expect-error 自体が未使用エラーになる)。
7
+ *
8
+ * SDK は値を出さない。 logic は `satisfies` でキーを保ってから型注釈で組み立てる。
9
+ * 値を export すると scenario の logic.ts が SDK を実行時 import することになり、
10
+ * サーバー用 bundle (Workers) で解決できず落ちる。
11
+ */
12
+ import { run } from './index.js';
13
+ const actions = {
14
+ // payload に型を付けた handler
15
+ 'vote.submit': ({ state, payload }) => {
16
+ state.count += payload.optionId.length;
17
+ },
18
+ // 注釈のない handler は payload: any のまま (段階的移行)
19
+ noop: ({ state }) => {
20
+ state.count += 1;
21
+ },
22
+ };
23
+ const serverActions = {
24
+ 'server.only': ({ payload }) => {
25
+ payload.token.trim();
26
+ },
27
+ };
28
+ const _logic = {
29
+ setup: () => ({ count: 0 }),
30
+ actions,
31
+ serverActions,
32
+ update: () => { },
33
+ };
34
+ send('vote.submit', { optionId: 'a' });
35
+ send('noop', { anything: 1 });
36
+ send('noop'); // 注釈なし (any) は省略できる
37
+ send('server.only', { token: 't' });
38
+ // @ts-expect-error 存在しない action 名
39
+ send('vote.submi', { optionId: 'a' });
40
+ // @ts-expect-error payload の形違い
41
+ send('vote.submit', { wrong: 1 });
42
+ // @ts-expect-error payload が必須の action で省略はできない
43
+ send('vote.submit');
44
+ /**
45
+ * `run()` 経由の `inputs` でも同じ縛りが効くことの検証。
46
+ *
47
+ * `SendAction<A & SA>` は `SA` が `Record<string, …>` に落ちた瞬間
48
+ * `keyof` が `string` へ潰れて action 名の検査が丸ごと消える。 直接
49
+ * `SendAction<…>` を declare するだけの検査では踏めないので、 実際に
50
+ * `run()` を通す形で押さえる。
51
+ *
52
+ * 型検査だけが目的なので関数は呼ばない (`run` は window を触る)。
53
+ */
54
+ export function _runTypeChecks() {
55
+ // serverActions を持たない logic (SA が既定値に落ちる経路)
56
+ run({
57
+ logic: { setup: () => ({ count: 0 }), actions, update: () => { } },
58
+ onState: () => { },
59
+ playerCount: 1,
60
+ inputs: (sendAction) => {
61
+ sendAction('vote.submit', { optionId: 'a' });
62
+ sendAction('noop');
63
+ // @ts-expect-error 存在しない action 名
64
+ sendAction('vote.submi', { optionId: 'a' });
65
+ // @ts-expect-error payload の形違い
66
+ sendAction('vote.submit', { wrong: 1 });
67
+ },
68
+ });
69
+ // 型引数を書かない呼び出しは A が推論されるので検査が効く
70
+ run({
71
+ logic: { setup: () => ({ count: 0 }), actions, update: () => { } },
72
+ onState: () => { },
73
+ playerCount: 1,
74
+ inputs: (sendAction) => {
75
+ sendAction('vote.submit', { optionId: 'a' });
76
+ // @ts-expect-error 存在しない action 名
77
+ sendAction('vote.submi', { optionId: 'a' });
78
+ },
79
+ });
80
+ // 既存シナリオの `run<State>({…})`。 A に既定値が無いと TS2558 で落ちる。
81
+ // 検査は効かなくなるが、 型引数を足さずに済むこと自体が後方互換の条件。
82
+ run({
83
+ logic: { setup: () => ({ count: 0 }), actions, update: () => { } },
84
+ onState: () => { },
85
+ playerCount: 1,
86
+ inputs: (sendAction) => {
87
+ sendAction('vote.submit', { optionId: 'a' });
88
+ },
89
+ });
90
+ // serverActions を持つ logic (SA が推論される経路)
91
+ run({
92
+ logic: { setup: () => ({ count: 0 }), actions, serverActions, update: () => { } },
93
+ onState: () => { },
94
+ playerCount: 1,
95
+ inputs: (sendAction) => {
96
+ sendAction('server.only', { token: 't' });
97
+ // @ts-expect-error 存在しない action 名
98
+ sendAction('server.onl', { token: 't' });
99
+ },
100
+ });
101
+ }
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, ActionArgs, ActionContext, ActionHandler, ServerActionArgs, SetupArgs, SetupContext, UpdateArgs, UpdateContext, EventHandler, EventSubscription, Deadline, DeadlineArgs, DeadlineContext, 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, SetupArgs, SetupContext, UpdateArgs, UpdateContext, EventHandler, EventSubscription, Deadline, DeadlineArgs, DeadlineContext, ServerActionContext, ServerActionHandler, ServerOnlyAction, ServerOnlyActionContext, ServerOnlyActionHandlerFn, ActionMap, ServerActionMap, PayloadMap, SendAction, } 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 { serverNow } from './server-clock.js';
@@ -30,7 +30,7 @@ export { attachDevHooks, createDevHooks } from './dev-hooks.js';
30
30
  export type { UzuDevHooks, DevHooksCtx, RunHandle, SyncHandle } from './dev-hooks.js';
31
31
  export { getPredictionWarnings } from './dev-prediction-traps.js';
32
32
  export type { PredictionWarning } from './dev-prediction-traps.js';
33
- import type { BridgeMessage, GameConfig, SyncConfig, PlayerVoiceState } from './types.js';
33
+ import type { ActionMap, ServerActionMap, BridgeMessage, GameConfig, SyncConfig, PlayerVoiceState } from './types.js';
34
34
  import type { RoomLike } from './room.js';
35
35
  type GameMessageHandler = (payload: Record<string, unknown>) => void;
36
36
  type PlayersChangedHandler = (players: Record<string, PlayerVoiceState>) => void;
@@ -103,5 +103,14 @@ export declare function firstFrameReady(): void;
103
103
  export declare function gameReady(): void;
104
104
  /** Flutter からの playersChanged メッセージを受信するハンドラを登録する。 */
105
105
  export declare function onPlayersChanged(handler: PlayersChangedHandler): void;
106
- export declare function run<S>(config: GameConfig<S>): void;
106
+ /**
107
+ * `A` に既定値が要る。 TypeScript は型引数の部分推論ができないので、 既定が無いと
108
+ * 既存の `run<State>({…})` が「2 つ必要」で落ちる。 既定を置けば型引数を書かない
109
+ * 呼び出しは `A` が推論されて検査が効き、 `run<State>` は従来どおり通る。
110
+ *
111
+ * `SA` の既定が `ServerActionMap<S>` (= `Record<string, …>`) だと
112
+ * `keyof (A & SA)` が `string` へ潰れ、action 名の検査が丸ごと消える。
113
+ * `serverActions` を持たない logic でもキーが残るよう空 map を既定にする。
114
+ */
115
+ export declare function run<S, A extends ActionMap<S> = ActionMap<S>, SA extends ServerActionMap<S> = Record<never, never>>(config: GameConfig<S, A, SA>): void;
107
116
  export declare function sync<S>(config: SyncConfig<S>): void;
package/dist/index.js CHANGED
@@ -66,7 +66,10 @@ function attachDevHooksIfNotHosted() {
66
66
  attachDevHooks(ctx);
67
67
  }
68
68
  // ─── Public API ─────────────────────────────────────────────
69
- export const isHosted = !!window.FlutterHost || window.parent !== window;
69
+ // Node (serverActionLogic / vitest) からこの module import しても落ちないよう
70
+ // guard する。 トップレベルで window を読むと、 型やヘルパーを取りたいだけの
71
+ // import でその場で ReferenceError になる。
72
+ export const isHosted = typeof window !== 'undefined' && (!!window.FlutterHost || window.parent !== window);
70
73
  export function getRoom() {
71
74
  return _room;
72
75
  }
@@ -227,6 +230,15 @@ export function gameReady() {
227
230
  export function onPlayersChanged(handler) {
228
231
  _playersChangedHandlers.push(handler);
229
232
  }
233
+ /**
234
+ * `A` に既定値が要る。 TypeScript は型引数の部分推論ができないので、 既定が無いと
235
+ * 既存の `run<State>({…})` が「2 つ必要」で落ちる。 既定を置けば型引数を書かない
236
+ * 呼び出しは `A` が推論されて検査が効き、 `run<State>` は従来どおり通る。
237
+ *
238
+ * `SA` の既定が `ServerActionMap<S>` (= `Record<string, …>`) だと
239
+ * `keyof (A & SA)` が `string` へ潰れ、action 名の検査が丸ごと消える。
240
+ * `serverActions` を持たない logic でもキーが残るよう空 map を既定にする。
241
+ */
230
242
  export function run(config) {
231
243
  _usesRun = true;
232
244
  const params = new URLSearchParams(window.location.search);
@@ -125,6 +125,12 @@ export function createOptimisticActionClient(config) {
125
125
  // 先読みするのは logic.actions だけ。serverActions は transport にだけ送り、
126
126
  // 結果は ack (state + serverEvents) で受け取る。
127
127
  const handler = logic.actions[type];
128
+ // どちらにも無い action は届く先が無い。 黙って捨てると UI 上は無反応なのに
129
+ // 原因が分からないので、 ここで気付けるようにする。 serverActions にだけある
130
+ // action は actions[type] が undefined でも正常なので、 両方を見る。
131
+ if (!handler && !logic.serverActions?.[type]) {
132
+ console.error(`[uzu] unknown action: "${type}". logic.actions / logic.serverActions のどちらにも登録されていません。`);
133
+ }
128
134
  // callback 内では displayState の narrowing が効かないので const に退避する。
129
135
  const target = displayState;
130
136
  // サーバーがこのアクションを処理する時刻の推定。再適用でも同じ値を使うので、
package/dist/types.d.ts CHANGED
@@ -164,21 +164,26 @@ export type ServerOnlyActionContext = ServerActionContext;
164
164
  * オブジェクトなのは使うものだけ書けるようにするため。`ctx` を入れ子で残しているのは、
165
165
  * 実行環境が与えるものをひとまとまりで helper へ渡せるようにするため。
166
166
  */
167
- export interface ActionArgs<S> {
167
+ export interface ActionArgs<S, P = any> {
168
168
  state: S;
169
- payload: any;
169
+ payload: P;
170
170
  playerId: string;
171
171
  ctx: ActionContext;
172
172
  }
173
- export type ActionHandler<S> = (args: ActionArgs<S>) => void;
173
+ /**
174
+ * `P` は既定が `any` なので、 注釈のない handler はそのまま動く。 payload の形を
175
+ * 書いた handler だけが検査され、 送信側もその型で縛られる。 全部に注釈しないと
176
+ * 恩恵が無い、 という移行にならないようにするための既定値。
177
+ */
178
+ export type ActionHandler<S, P = any> = (args: ActionArgs<S, P>) => void;
174
179
  /** `serverActions` handler の引数。`ctx` にサーバー限定の tick / random が入る。 */
175
- export interface ServerActionArgs<S> {
180
+ export interface ServerActionArgs<S, P = any> {
176
181
  state: S;
177
- payload: any;
182
+ payload: P;
178
183
  playerId: string;
179
184
  ctx: ServerActionContext;
180
185
  }
181
- export type ServerActionHandler<S> = (args: ServerActionArgs<S>) => Promise<void> | void;
186
+ export type ServerActionHandler<S, P = any> = (args: ServerActionArgs<S, P>) => Promise<void> | void;
182
187
  /** @deprecated `ServerActionHandler` を使う。 */
183
188
  export type ServerOnlyActionHandlerFn<S> = ServerActionHandler<S>;
184
189
  /**
@@ -220,7 +225,31 @@ export interface SetupArgs {
220
225
  seats: Seat[];
221
226
  ctx: SetupContext;
222
227
  }
223
- export interface GameLogic<S> {
228
+ /**
229
+ * `actions` の形だけを見る緩い制約。
230
+ *
231
+ * `Record<string, ActionHandler<S>>` (payload: any) を制約に使うと、 推論時に
232
+ * handler 型がそちらへ広げられ payload の型が取り出せなくなる。 payload を
233
+ * `any` のままにしてキーと引数の形だけ縛る。
234
+ */
235
+ export type ActionMap<S> = Record<string, (args: ActionArgs<S, any>) => void>;
236
+ /** `serverActions` 用。 ActionMap と同じ理由で payload は any のままにする。 */
237
+ export type ServerActionMap<S> = Record<string, (args: ServerActionArgs<S, any>) => Promise<void> | void>;
238
+ /** action 名から payload 型を引く表。 注釈のない handler は `any` になる。 */
239
+ export type PayloadMap<A> = {
240
+ [K in keyof A]: A[K] extends (args: infer G) => any ? G extends {
241
+ payload: infer P;
242
+ } ? P : never : never;
243
+ };
244
+ /**
245
+ * `inputs` が受け取る送信関数。 action 名と payload の両方が型で縛られる。
246
+ *
247
+ * payload を一律 optional にすると、 形が必須の action でも省略が通ってしまう。
248
+ * payload 型が undefined を含むとき (= 注釈なしの any や明示的に optional) だけ
249
+ * 省略できるようにする。
250
+ */
251
+ export type SendAction<A> = <K extends keyof A & string>(...args: undefined extends PayloadMap<A>[K] ? [type: K, payload?: PayloadMap<A>[K]] : [type: K, payload: PayloadMap<A>[K]]) => void;
252
+ export interface GameLogic<S, A extends ActionMap<S> = ActionMap<S>, SA extends ServerActionMap<S> = ServerActionMap<S>> {
224
253
  setup(args: SetupArgs): S;
225
254
  /**
226
255
  * クライアント先読みとサーバーの両方で走る handler。決定的でなければならない。
@@ -229,7 +258,7 @@ export interface GameLogic<S> {
229
258
  * なる。1 つの action を「即座に反映していい部分」と「サーバーが決める部分」へ
230
259
  * 分けられる (例: 駒の移動は先読み、持ち時間の減算はサーバー)。
231
260
  */
232
- actions: Record<string, ActionHandler<S>>;
261
+ actions: A;
233
262
  /**
234
263
  * サーバーでのみ走る handler。実時刻 / 乱数 / fetch など、クライアント先読みで
235
264
  * 再現できない処理をここに書く。async 可。
@@ -237,7 +266,7 @@ export interface GameLogic<S> {
237
266
  * `actions` と同名でも別名でもよい。別名だけに置けば「先読みしない action」
238
267
  * (旧 `serverOnly()` 相当) になる。
239
268
  */
240
- serverActions?: Record<string, ServerActionHandler<S>>;
269
+ serverActions?: SA;
241
270
  update(args: UpdateArgs<S>): void;
242
271
  /**
243
272
  * state 由来の締切。サーバーが `at` の時刻に自分で起きて `handler` を呼ぶ。
@@ -248,10 +277,10 @@ export interface GameLogic<S> {
248
277
  deadlines?: Record<string, Deadline<S>>;
249
278
  tickRate?: number;
250
279
  }
251
- export interface GameConfig<S> extends ConnectionCallbacks {
252
- logic: GameLogic<S>;
280
+ export interface GameConfig<S, A extends ActionMap<S> = ActionMap<S>, SA extends ServerActionMap<S> = ServerActionMap<S>> extends ConnectionCallbacks {
281
+ logic: GameLogic<S, A, SA>;
253
282
  onState: (state: S, myPlayerId: string) => void;
254
- inputs: (sendAction: (type: string, payload?: any) => void) => void;
283
+ inputs: (sendAction: SendAction<A & SA>) => void;
255
284
  /**
256
285
  * `emit(name, data)` の購読。 キーごとに `predict` の宣言が必須。
257
286
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uzuhq/code-sdk",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "description": "UZU PlayScreen SDK - Flutter ↔ JS ゲーム通信ライブラリ",
5
5
  "type": "module",
6
6
  "exports": {