@uzuhq/code-sdk 0.7.6 → 0.7.7

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.
Files changed (52) hide show
  1. package/dist/dev-globals.d.ts +12 -27
  2. package/dist/dev-globals.js +0 -15
  3. package/dist/dev-hooks-ClWM8HzI.d.ts +682 -0
  4. package/dist/index.d.ts +152 -66
  5. package/dist/index.js +1837 -416
  6. package/package.json +8 -5
  7. package/dist/action-types.test-d.d.ts +0 -11
  8. package/dist/action-types.test-d.js +0 -101
  9. package/dist/dev-hooks.d.ts +0 -241
  10. package/dist/dev-hooks.js +0 -132
  11. package/dist/dev-hooks.test.d.ts +0 -1
  12. package/dist/dev-hooks.test.js +0 -294
  13. package/dist/dev-prediction-traps.d.ts +0 -32
  14. package/dist/dev-prediction-traps.js +0 -0
  15. package/dist/dev-prediction-traps.test.d.ts +0 -1
  16. package/dist/dev-prediction-traps.test.js +0 -178
  17. package/dist/dev-state-patch.d.ts +0 -81
  18. package/dist/dev-state-patch.js +0 -295
  19. package/dist/dev-state-patch.test.d.ts +0 -1
  20. package/dist/dev-state-patch.test.js +0 -333
  21. package/dist/json-patch.d.ts +0 -7
  22. package/dist/json-patch.js +0 -78
  23. package/dist/random.d.ts +0 -11
  24. package/dist/random.js +0 -34
  25. package/dist/reconnectable-ws.d.ts +0 -60
  26. package/dist/reconnectable-ws.js +0 -229
  27. package/dist/room.d.ts +0 -23
  28. package/dist/room.js +0 -36
  29. package/dist/roster-params.test.d.ts +0 -1
  30. package/dist/roster-params.test.js +0 -86
  31. package/dist/run/local-server-action.d.ts +0 -16
  32. package/dist/run/local-server-action.js +0 -217
  33. package/dist/run/local-server-action.test.d.ts +0 -1
  34. package/dist/run/local-server-action.test.js +0 -242
  35. package/dist/run/optimistic-action-client.d.ts +0 -68
  36. package/dist/run/optimistic-action-client.js +0 -209
  37. package/dist/run/optimistic-action-client.test.d.ts +0 -1
  38. package/dist/run/optimistic-action-client.test.js +0 -430
  39. package/dist/run/server-action.d.ts +0 -16
  40. package/dist/run/server-action.js +0 -181
  41. package/dist/run/server-action.test.d.ts +0 -1
  42. package/dist/run/server-action.test.js +0 -105
  43. package/dist/server-clock.d.ts +0 -29
  44. package/dist/server-clock.js +0 -40
  45. package/dist/server-only.d.ts +0 -33
  46. package/dist/server-only.js +0 -21
  47. package/dist/sync/local.d.ts +0 -8
  48. package/dist/sync/local.js +0 -50
  49. package/dist/sync/online.d.ts +0 -5
  50. package/dist/sync/online.js +0 -165
  51. package/dist/types.d.ts +0 -345
  52. package/dist/types.js +0 -8
@@ -1,217 +0,0 @@
1
- import { DEFAULT_ICON_URLS } from '../types.js';
2
- import { SeededRandomImpl } from '../random.js';
3
- import { isServerOnlyAction } from '../server-only.js';
4
- import { applyJsonMergePatch, applyJsonPatch } from '../dev-state-patch.js';
5
- export function runLocalServerAction(config) {
6
- const { logic, inputs, events } = config;
7
- // ソロモードは自分ひとりで観測者が存在しないので席種別は常に player。
8
- const onState = (next, id) => config.onState(next, id, 'player');
9
- const tickRate = logic.tickRate ?? 0; // DO と同じデフォルト(0=tickなし)
10
- const seed = Math.floor(Math.random() * 0xffffffff);
11
- const random = new SeededRandomImpl(seed);
12
- const players = Array.from({ length: config.playerCount }, (_, i) => ({
13
- id: `local_${i}`,
14
- nickname: `Player ${i + 1}`,
15
- iconUrl: DEFAULT_ICON_URLS[i % DEFAULT_ICON_URLS.length],
16
- }));
17
- const myId = players[0].id;
18
- // イベント収集→一括配信(DO と同じパターン)
19
- // ソロモードでは alarm の代わりに setTimeout で締切に起きる。締切は state から
20
- // 導出するので、state を変えたら syncWakeup() を通すだけでよい。
21
- let wakeupTimer = null;
22
- let wakeupAt = null;
23
- const nextDeadline = () => {
24
- if (!logic.deadlines)
25
- return null;
26
- let earliest = null;
27
- for (const [key, deadline] of Object.entries(logic.deadlines)) {
28
- let at;
29
- try {
30
- at = deadline.at({ state });
31
- }
32
- catch (err) {
33
- console.error(`[Deadline] ❌ ${key}.at() で例外`, err);
34
- continue;
35
- }
36
- if (typeof at !== 'number' || !Number.isFinite(at))
37
- continue;
38
- if (earliest === null || at < earliest)
39
- earliest = at;
40
- }
41
- return earliest;
42
- };
43
- const fireDue = () => {
44
- wakeupTimer = null;
45
- wakeupAt = null;
46
- if (!logic.deadlines)
47
- return;
48
- const now = Date.now();
49
- const evts = [];
50
- const firedKeys = [];
51
- for (const [key, deadline] of Object.entries(logic.deadlines)) {
52
- // handler が emit してから throw したときに捨てられるよう、締切ごとに溜める。
53
- // state を戻したのに音や演出だけ流れると、起きていない出来事が見えてしまう。
54
- const pending = [];
55
- let snapshot = null;
56
- try {
57
- const at = deadline.at({ state });
58
- if (typeof at !== 'number' || !Number.isFinite(at) || at > now)
59
- continue;
60
- // 期限が来たものだけ複製する。毎周撮ると締切の数だけ state の複製が走る。
61
- snapshot = structuredClone(state);
62
- deadline.handler({
63
- state,
64
- ctx: { now, random, emit: (name, data) => pending.push({ name, data: data ?? {} }) },
65
- });
66
- evts.push(...pending);
67
- firedKeys.push(key);
68
- }
69
- catch (err) {
70
- // サーバー (DO) 側と同じく、失敗した締切ぶんだけ巻き戻す。
71
- if (snapshot !== null)
72
- state = snapshot;
73
- console.error(`[Deadline] ❌ ${key} で例外`, err);
74
- }
75
- }
76
- syncWakeup();
77
- if (firedKeys.length === 0)
78
- return;
79
- console.log(`[Deadline] ⏰ ${firedKeys.length} 件発火: ${firedKeys.join(', ')}`);
80
- dispatchEvents(evts);
81
- onState(state, myId);
82
- };
83
- const syncWakeup = () => {
84
- const next = nextDeadline();
85
- if (next === wakeupAt)
86
- return;
87
- if (wakeupTimer)
88
- clearTimeout(wakeupTimer);
89
- wakeupTimer = null;
90
- wakeupAt = next;
91
- if (next === null)
92
- return;
93
- wakeupTimer = setTimeout(fireDue, Math.max(0, next - Date.now()));
94
- };
95
- // ソロモードはこのクライアント自身がサーバーなので、先読みという概念が無い。
96
- // predict の値によらず、確定として 1 回だけ実行する。
97
- const dispatchEvents = (evts) => {
98
- for (const e of evts) {
99
- events?.[e.name]?.handler(e.data);
100
- }
101
- };
102
- // 移行期: publish 済みの logic.js は `setup({ seats })` で destructure したまま
103
- // 固まっている。`players` へ寄せただけだと `seats === undefined` を受け取って
104
- // throw するので、同じ配列を旧名でも渡す。`SetupArgs` に `seats` を宣言しないのは、
105
- // 新規シナリオに旧名を選ばせないため。全 revision の再 publish 後に落とす。
106
- const setupArgs = { players, seats: players, ctx: { random, now: Date.now() } };
107
- // setRawState で全置換できるよう let。closures は名前参照なので最新束縛を読む。
108
- let state = logic.setup(setupArgs);
109
- let tick = 0;
110
- const playerInputs = {};
111
- // Action 処理。`actions` は同期実行で `sendAction()` 直後の同期 onState を保証する
112
- // (online の楽観的更新と同じ挙動)。`serverActions` は `Promise<void>` を返しうるので
113
- // `await` で実行し、完了後に events と onState を発火する。
114
- // どちらも 1 回しか実行しない (online の「楽観 → サーバー確定」の 2 段階は再現しない)。
115
- const dispatchAction = (type, payload) => {
116
- const plain = logic.actions[type];
117
- // 移行期: 旧 serverOnly() を actions に入れたままの logic も動かす。
118
- const legacyServerOnly = isServerOnlyAction(plain) ? plain : null;
119
- const server = logic.serverActions?.[type] ?? legacyServerOnly;
120
- if (!plain && !server)
121
- return;
122
- // events は emit 元ごとに分ける。online では actions 由来がクライアント先読みの
123
- // 時点で発火するので、ここでも plain の実行直後に流す。共有すると serverActions が
124
- // `await fetch()` を持つ場合に actions 側の events までその分遅れてしまい、
125
- // 「ローカルモードだけ音が遅れる」というモード差になる。
126
- const plainEvents = [];
127
- const serverEvents = [];
128
- const plainEmit = (name, data) => plainEvents.push({ name, data: data ?? {} });
129
- const serverEmit = (name, data) => serverEvents.push({ name, data: data ?? {} });
130
- // ソロモードはこのクライアント自身がサーバーなので、実時刻がそのまま正となる。
131
- const now = Date.now();
132
- if (plain && !legacyServerOnly) {
133
- try {
134
- plain({
135
- state,
136
- payload: payload ?? {},
137
- playerId: myId,
138
- ctx: { now, emit: plainEmit },
139
- });
140
- }
141
- catch (err) {
142
- console.warn('[SDK LocalServerAction] Action error:', err);
143
- return;
144
- }
145
- dispatchEvents(plainEvents);
146
- syncWakeup();
147
- onState(state, myId);
148
- }
149
- if (!server)
150
- return;
151
- void (async () => {
152
- try {
153
- await server({
154
- state,
155
- payload: payload ?? {},
156
- playerId: myId,
157
- ctx: { tick, random, now, emit: serverEmit },
158
- });
159
- }
160
- catch (err) {
161
- console.warn('[SDK LocalServerAction] Action error:', err);
162
- return;
163
- }
164
- dispatchEvents(serverEvents);
165
- syncWakeup();
166
- onState(state, myId);
167
- })();
168
- };
169
- inputs(dispatchAction);
170
- syncWakeup();
171
- onState(state, myId);
172
- // Tick ループ(tickRate > 0 の場合のみ、DO と同じ)
173
- if (tickRate > 0) {
174
- setInterval(() => {
175
- const tickEvents = [];
176
- const tickEmit = (name, data) => tickEvents.push({ name, data: data ?? {} });
177
- try {
178
- logic.update({
179
- state,
180
- ctx: {
181
- random,
182
- tick,
183
- now: Date.now(),
184
- emit: tickEmit,
185
- playerInputs,
186
- },
187
- });
188
- }
189
- catch (err) {
190
- console.error(`[SDK LocalServerAction] tick error at tick=${tick}:`, err);
191
- tick++;
192
- return;
193
- }
194
- tick++;
195
- dispatchEvents(tickEvents);
196
- syncWakeup();
197
- onState(state, myId);
198
- }, 1000 / tickRate);
199
- }
200
- return {
201
- getRawState: () => state,
202
- setRawState: async (next) => {
203
- state = next;
204
- syncWakeup();
205
- onState(state, myId);
206
- },
207
- mergeRawState: async (patch) => {
208
- applyJsonMergePatch(state, patch);
209
- onState(state, myId);
210
- },
211
- patchRawState: async (ops) => {
212
- applyJsonPatch(state, ops);
213
- onState(state, myId);
214
- },
215
- sendAction: dispatchAction,
216
- };
217
- }
@@ -1 +0,0 @@
1
- export {};
@@ -1,242 +0,0 @@
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
- const seatKinds = [];
21
- let send = () => { };
22
- const config = {
23
- logic,
24
- playerCount: 1,
25
- onState: (s, _myPlayerId, mySeatKind) => {
26
- states.push(structuredClone(s));
27
- seatKinds.push(mySeatKind);
28
- },
29
- inputs: (sendAction) => {
30
- send = sendAction;
31
- },
32
- events: {
33
- moved: {
34
- predict: true,
35
- handler: () => {
36
- fired.push('moved');
37
- },
38
- },
39
- charged: {
40
- predict: false,
41
- handler: () => {
42
- fired.push('charged');
43
- },
44
- },
45
- },
46
- };
47
- runLocalServerAction(config);
48
- return { send: (t, p) => send(t, p), fired, states, seatKinds };
49
- };
50
- const baseLogic = (overrides = {}) => ({
51
- setup: () => ({ moves: 0, charged: 0, rolled: -1 }),
52
- actions: {},
53
- update: () => { },
54
- ...overrides,
55
- });
56
- describe('runLocalServerAction', () => {
57
- describe('actions と serverActions の実行', () => {
58
- /** actions だけの action は同期実行され、その場で state と events が確定する。 */
59
- it('actions だけなら同期で state と events が確定する', () => {
60
- const h = run(baseLogic({
61
- actions: {
62
- move: ({ state, ctx }) => {
63
- state.moves += 1;
64
- ctx.emit('moved');
65
- },
66
- },
67
- }));
68
- h.send('move');
69
- expect(h.states[h.states.length - 1].moves).toBe(1);
70
- expect(h.fired).toEqual(['moved']);
71
- });
72
- /** serverActions だけの action は、await 完了後に state と events が反映される。 */
73
- it('serverActions だけなら await 後に反映される', async () => {
74
- const h = run(baseLogic({
75
- serverActions: {
76
- notifyExternal: async ({ state, ctx }) => {
77
- await tick();
78
- state.charged += 1;
79
- ctx.emit('charged');
80
- },
81
- },
82
- }));
83
- h.send('notifyExternal');
84
- expect(h.fired).toEqual([]); // まだ await 中
85
- await tick();
86
- await tick();
87
- expect(h.states[h.states.length - 1].charged).toBe(1);
88
- expect(h.fired).toEqual(['charged']);
89
- });
90
- /**
91
- * 同名で両方定義されている場合、サーバーと同じく actions → serverActions の順で走る。
92
- * serverActions は actions が書いた結果を見られる。
93
- */
94
- it('同名なら actions → serverActions の順に走る', async () => {
95
- const order = [];
96
- const h = run(baseLogic({
97
- actions: {
98
- move: ({ state }) => {
99
- order.push('actions');
100
- state.moves += 1;
101
- },
102
- },
103
- serverActions: {
104
- move: ({ state, ctx }) => {
105
- order.push('serverActions');
106
- // actions の結果が見えている
107
- expect(state.moves).toBe(1);
108
- state.charged += ctx.tick + 1;
109
- state.rolled = ctx.random.int(100);
110
- },
111
- },
112
- }));
113
- h.send('move');
114
- await tick();
115
- expect(order).toEqual(['actions', 'serverActions']);
116
- const last = h.states[h.states.length - 1];
117
- expect(last.moves).toBe(1);
118
- expect(last.charged).toBe(1); // tick 0 + 1
119
- // ctx.random が渡っている (setup 前の -1 から変わっている)
120
- expect(last.rolled).toBeGreaterThanOrEqual(0);
121
- });
122
- });
123
- describe('events の発火タイミング', () => {
124
- /**
125
- * online では actions 由来の events は先読み時に即発火する。ソロでも同じタイミングで
126
- * 流し、serverActions の await を待たせない。ここが揃っていないとモード差になる。
127
- */
128
- it('actions の events は serverActions の await を待たない', async () => {
129
- const h = run(baseLogic({
130
- actions: {
131
- move: ({ state, ctx }) => {
132
- state.moves += 1;
133
- ctx.emit('moved');
134
- },
135
- },
136
- serverActions: {
137
- move: async ({ state, ctx }) => {
138
- await tick();
139
- state.charged += 1;
140
- ctx.emit('charged');
141
- },
142
- },
143
- }));
144
- h.send('move');
145
- // serverActions がまだ await 中でも actions 側の events は出ている
146
- expect(h.fired).toEqual(['moved']);
147
- await tick();
148
- await tick();
149
- expect(h.fired).toEqual(['moved', 'charged']);
150
- });
151
- });
152
- describe('エラー処理', () => {
153
- /** actions が throw したら serverActions へ進まず、state 更新も通知しない。 */
154
- it('actions が throw したら serverActions を実行しない', () => {
155
- const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
156
- let serverRan = false;
157
- const h = run(baseLogic({
158
- actions: {
159
- move: () => {
160
- throw new Error('boom');
161
- },
162
- },
163
- serverActions: {
164
- move: () => {
165
- serverRan = true;
166
- },
167
- },
168
- }));
169
- h.send('move');
170
- expect(serverRan).toBe(false);
171
- warn.mockRestore();
172
- });
173
- /** serverActions が throw しても、既に流れた actions 側の events は取り消さない。 */
174
- it('serverActions が throw しても actions の events は残る', async () => {
175
- const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
176
- const h = run(baseLogic({
177
- actions: {
178
- move: ({ state, ctx }) => {
179
- state.moves += 1;
180
- ctx.emit('moved');
181
- },
182
- },
183
- serverActions: {
184
- move: () => {
185
- throw new Error('boom');
186
- },
187
- },
188
- }));
189
- h.send('move');
190
- await tick();
191
- expect(h.fired).toEqual(['moved']);
192
- warn.mockRestore();
193
- });
194
- });
195
- describe('旧 serverOnly() の互換', () => {
196
- /**
197
- * R2 の古い logic.js は serverOnly() の brand を付けたまま actions に入っている。
198
- * ソロモードでも brand を見て「先読みしない handler」として扱う。
199
- */
200
- it('actions に入った serverOnly() は server 側として実行される', async () => {
201
- const logic = baseLogic({
202
- actions: {
203
- // 検証対象は `__serverOnly` brand の判定であって、旧シグネチャの互換ではない。
204
- // handler 自体は現行の呼び出し形で書いている (旧形式の logic.js は現行テンプレでは
205
- // 動かない。公開済みゲームが無い前提で互換を切っている)。
206
- // @ts-expect-error actions に serverOnly() を入れるのは型違反だが、brand 判定を試す
207
- legacy: serverOnly(async ({ state, ctx }) => {
208
- await tick();
209
- state.charged += ctx.tick + 1;
210
- ctx.emit('charged');
211
- }),
212
- },
213
- });
214
- const h = run(logic);
215
- h.send('legacy');
216
- expect(h.fired).toEqual([]); // 先読みされていない
217
- await tick();
218
- await tick();
219
- expect(h.states[h.states.length - 1].charged).toBe(1);
220
- expect(h.fired).toEqual(['charged']);
221
- });
222
- });
223
- /**
224
- * `onState` の第 3 引数は自分の席種別。roster に自分が居ないとき (観測者) に
225
- * GM ビューと観戦ビューを出し分けるためのもので、他プレイヤーの席種別は渡らない。
226
- */
227
- describe('自分の席種別', () => {
228
- /** ソロモードは自分ひとりで観測者が存在しないので、常に 'player' が渡る。 */
229
- it('ソロモードでは常に player が渡る', () => {
230
- const h = run(baseLogic({
231
- actions: {
232
- move: ({ state }) => {
233
- state.moves += 1;
234
- },
235
- },
236
- }));
237
- h.send('move');
238
- expect(h.seatKinds.length).toBeGreaterThan(1);
239
- expect(new Set(h.seatKinds)).toEqual(new Set(['player']));
240
- });
241
- });
242
- });
@@ -1,68 +0,0 @@
1
- /**
2
- * @docs
3
- * - ServerAction仕様: docs/docs/uzu_code/connection-method/arch3-authority.md
4
- * - 開発パターン: docs/docs/uzu_code/sdk-guide/patterns.md
5
- *
6
- * ServerAction の楽観的更新クライアント — online / emulator 共通ロジック。
7
- *
8
- * トランスポート (WebSocket / BroadcastChannel) と上位ロジック (楽観更新 / pending
9
- * キュー / ack ベースの確定 / rollback) を分離し、3 モードで挙動が揃うことを実装で保証する。
10
- *
11
- * - `send(type, payload)`: `logic.actions` の handler を同期で先行実行し pending キューへ。
12
- * `logic.serverActions` は先読みせず transport にだけ送る。
13
- * - `applyState(state, { ack, from, events })`: フル state を受信した時に呼ぶ。
14
- * - `applyDelta(patches, { ack, from, events })`: JSON Patch を受信した時に呼ぶ
15
- * (適用失敗時は false を返すので transport 側でフル state を再要求する)。
16
- * - `rollback(seq)`: `__action_error` 受信時に該当 action を pending から除去して再適用。
17
- * - `reset(state)`: 再接続後の state 復元用 (pending を全クリア)。
18
- */
19
- import type { GameLogic, EventSubscription } from '../types.js';
20
- import type { Operation } from '../json-patch.js';
21
- export interface EventEntry {
22
- name: string;
23
- data: Record<string, unknown>;
24
- }
25
- export interface ConfirmOptions {
26
- /** transport から受け取った action ack (確定された pending action の seq) */
27
- ack?: number;
28
- /** ack の送信元 player id */
29
- from?: string;
30
- /**
31
- * サーバーが確定させた events。`actions` 由来と `serverActions` 由来を区別しない
32
- * 1 本のリスト。
33
- *
34
- * 先読みで既に配信済みのものはクライアント側で差し引く (pending の `fired` 記録と
35
- * 突き合わせる)。サーバーが袋を分ける必要はない。
36
- */
37
- events?: EventEntry[];
38
- }
39
- export interface OptimisticActionClientConfig<S> {
40
- logic: GameLogic<S>;
41
- playerId: string;
42
- onState: (state: S, playerId: string) => void;
43
- events?: Record<string, EventSubscription>;
44
- /** action を transport に流すコールバック */
45
- sendAction: (msg: {
46
- action: string;
47
- payload: any;
48
- seq: number;
49
- }) => void;
50
- }
51
- export interface OptimisticActionClient<S> {
52
- /** input から呼ばれる action dispatch (楽観更新 + transport 送信) */
53
- send(type: string, payload?: any): void;
54
- /**
55
- * サーバーが打刻した時刻の観測値を渡してクロックオフセットを更新する。
56
- * transport が受信した全メッセージで呼んでよい (serverTime を持たないものは無視される)。
57
- */
58
- observeServerTime(serverTime: number | undefined): void;
59
- /** 仮想サーバー / DO からフル state を受信した時に呼ぶ */
60
- applyState(state: S, options?: ConfirmOptions): void;
61
- /** DO から JSON Patch delta を受信した時に呼ぶ。適用失敗時は false (transport で再要求) */
62
- applyDelta(patches: Operation[], options?: ConfirmOptions): boolean;
63
- /** `__action_error` 受信時に該当 action をロールバック */
64
- rollback(seq: number): void;
65
- /** 再接続時の state 復元 (pending を全クリア) */
66
- reset(state: S): void;
67
- }
68
- export declare function createOptimisticActionClient<S>(config: OptimisticActionClientConfig<S>): OptimisticActionClient<S>;