screeps-connectivity 0.2.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.
Files changed (75) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/eslint.config.js +54 -0
  3. package/package.json +45 -0
  4. package/src/ScreepsClient.ts +172 -0
  5. package/src/badge/colors.ts +83 -0
  6. package/src/badge/generateSvg.ts +70 -0
  7. package/src/badge/index.ts +1 -0
  8. package/src/badge/paths.ts +385 -0
  9. package/src/cache/Cache.ts +71 -0
  10. package/src/cache/Map2Storage.ts +112 -0
  11. package/src/file-storage.ts +2 -0
  12. package/src/http/HttpClient.ts +160 -0
  13. package/src/http/auth/AuthStrategy.ts +5 -0
  14. package/src/http/auth/GuestAuth.ts +13 -0
  15. package/src/http/auth/PasswordAuth.ts +17 -0
  16. package/src/http/auth/SteamTicketAuth.ts +17 -0
  17. package/src/http/auth/TokenAuth.ts +14 -0
  18. package/src/http/decompress.ts +37 -0
  19. package/src/http/endpoints/auth.ts +23 -0
  20. package/src/http/endpoints/experimental.ts +13 -0
  21. package/src/http/endpoints/game.ts +103 -0
  22. package/src/http/endpoints/leaderboard.ts +16 -0
  23. package/src/http/endpoints/power-creeps.ts +24 -0
  24. package/src/http/endpoints/register.ts +19 -0
  25. package/src/http/endpoints/user-messages.ts +20 -0
  26. package/src/http/endpoints/user.ts +95 -0
  27. package/src/http/fetchServerVersion.ts +151 -0
  28. package/src/index.ts +55 -0
  29. package/src/logger.ts +25 -0
  30. package/src/socket/MessageParser.ts +44 -0
  31. package/src/socket/SocketClient.ts +203 -0
  32. package/src/storage/FileStorage.ts +44 -0
  33. package/src/storage/IndexedDBStorage.ts +77 -0
  34. package/src/storage/NullStorage.ts +8 -0
  35. package/src/storage/StorageAdapter.ts +6 -0
  36. package/src/stores/MapStatsStore.ts +115 -0
  37. package/src/stores/MapStore.ts +254 -0
  38. package/src/stores/NavigationStore.ts +61 -0
  39. package/src/stores/RoomStore.ts +264 -0
  40. package/src/stores/ServerStore.ts +128 -0
  41. package/src/stores/TypedStore.ts +31 -0
  42. package/src/stores/UserStore.ts +189 -0
  43. package/src/subscription/index.ts +18 -0
  44. package/src/types/api.ts +252 -0
  45. package/src/types/events.ts +72 -0
  46. package/src/types/game.ts +160 -0
  47. package/tests/.gitkeep +0 -0
  48. package/tests/ScreepsClient.test.ts +229 -0
  49. package/tests/badge/generateSvg.test.ts +174 -0
  50. package/tests/cache/Cache.test.ts +99 -0
  51. package/tests/cache/Map2Storage.test.ts +130 -0
  52. package/tests/http/HttpClient.test.ts +188 -0
  53. package/tests/http/decompress.test.ts +52 -0
  54. package/tests/http/endpoints/auth.test.ts +126 -0
  55. package/tests/http/endpoints/game.test.ts +210 -0
  56. package/tests/http/endpoints/power-creeps.test.ts +81 -0
  57. package/tests/http/endpoints/user-messages.test.ts +68 -0
  58. package/tests/http/endpoints/user.test.ts +139 -0
  59. package/tests/socket/MessageParser.test.ts +55 -0
  60. package/tests/socket/SocketClient.test.ts +144 -0
  61. package/tests/storage/FileStorage.test.ts +64 -0
  62. package/tests/storage/IndexedDBStorage.test.ts +36 -0
  63. package/tests/storage/NullStorage.test.ts +24 -0
  64. package/tests/stores/MapStatsStore.test.ts +234 -0
  65. package/tests/stores/MapStore.test.ts +537 -0
  66. package/tests/stores/NavigationStore.test.ts +166 -0
  67. package/tests/stores/RoomStore.test.ts +130 -0
  68. package/tests/stores/ServerStore.test.ts +48 -0
  69. package/tests/stores/TypedStore.test.ts +54 -0
  70. package/tests/stores/UserStore.test.ts +136 -0
  71. package/tests/subscription/SubscriptionGroup.test.ts +34 -0
  72. package/tests/types/game.test.ts +42 -0
  73. package/tsconfig.json +17 -0
  74. package/tsup.config.ts +9 -0
  75. package/vitest.config.ts +7 -0
@@ -0,0 +1,144 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { SocketClient } from '../../src/socket/SocketClient.js'
3
+
4
+ class MockWS {
5
+ static instances: MockWS[] = []
6
+ onopen: (() => void) | null = null
7
+ onclose: (() => void) | null = null
8
+ onerror: ((e: unknown) => void) | null = null
9
+ onmessage: ((e: MessageEvent) => void) | null = null
10
+ sent: string[] = []
11
+
12
+ constructor(public url: string) {
13
+ MockWS.instances.push(this)
14
+ }
15
+
16
+ send(data: string) { this.sent.push(data) }
17
+ close() { this.onclose?.() }
18
+
19
+ simulateOpen() { this.onopen?.() }
20
+ simulateMessage(data: string) { this.onmessage?.({ data } as MessageEvent) }
21
+ simulateClose() { this.onclose?.() }
22
+ }
23
+
24
+ beforeEach(() => { MockWS.instances = [] })
25
+
26
+ function makeClient() {
27
+ return new SocketClient({ url: 'http://test.local', WebSocket: MockWS as unknown as typeof WebSocket })
28
+ }
29
+
30
+ async function connectClient(client: SocketClient, token = 'tok') {
31
+ const connectPromise = client.connect(token)
32
+ const ws = MockWS.instances[0]
33
+ ws.simulateOpen()
34
+ ws.simulateMessage('auth ok newtoken')
35
+ await connectPromise
36
+ return ws
37
+ }
38
+
39
+ describe('SocketClient', () => {
40
+ it('connects to the correct WebSocket URL', async () => {
41
+ const client = makeClient()
42
+ const promise = client.connect('tok')
43
+ const ws = MockWS.instances[0]
44
+ expect(ws.url).toBe('ws://test.local/socket/websocket')
45
+ ws.simulateOpen()
46
+ ws.simulateMessage('auth ok tok')
47
+ await promise
48
+ })
49
+
50
+ it('sends auth token on open', async () => {
51
+ const client = makeClient()
52
+ const ws = await connectClient(client)
53
+ expect(ws.sent).toContain('auth tok')
54
+ })
55
+
56
+ it('resolves connect() after auth ok', async () => {
57
+ const client = makeClient()
58
+ await expect(connectClient(client)).resolves.toBeDefined()
59
+ })
60
+
61
+ it('subscribe sends subscribe message when authed', async () => {
62
+ const client = makeClient()
63
+ const ws = await connectClient(client)
64
+ ws.sent.length = 0
65
+ client.subscribe('room:shard0/W7N7')
66
+ expect(ws.sent).toContain('subscribe room:shard0/W7N7')
67
+ })
68
+
69
+ it('subscribe refcounts — subscribe message sent only once for multiple subs', async () => {
70
+ const client = makeClient()
71
+ const ws = await connectClient(client)
72
+ ws.sent.length = 0
73
+ client.subscribe('room:shard0/W7N7')
74
+ client.subscribe('room:shard0/W7N7')
75
+ const subscribeMsgs = ws.sent.filter(s => s.startsWith('subscribe'))
76
+ expect(subscribeMsgs).toHaveLength(1)
77
+ })
78
+
79
+ it('unsubscribe sent when last subscriber disposes', async () => {
80
+ const client = makeClient()
81
+ const ws = await connectClient(client)
82
+ const sub1 = client.subscribe('room:shard0/W7N7')
83
+ const sub2 = client.subscribe('room:shard0/W7N7')
84
+ ws.sent.length = 0
85
+ sub1.dispose()
86
+ expect(ws.sent.filter(s => s.startsWith('unsubscribe'))).toHaveLength(0)
87
+ sub2.dispose()
88
+ expect(ws.sent).toContain('unsubscribe room:shard0/W7N7')
89
+ })
90
+
91
+ it('on() delivers channel messages to listener', async () => {
92
+ const client = makeClient()
93
+ const ws = await connectClient(client)
94
+ const handler = vi.fn()
95
+ client.on('user:uid/cpu', handler)
96
+ ws.simulateMessage(JSON.stringify(['user:uid/cpu', { cpu: 25 }]))
97
+ await new Promise(r => setTimeout(r, 0))
98
+ expect(handler).toHaveBeenCalledWith({ cpu: 25 })
99
+ })
100
+
101
+ it('on() subscription dispose removes listener', async () => {
102
+ const client = makeClient()
103
+ const ws = await connectClient(client)
104
+ const handler = vi.fn()
105
+ const sub = client.on('user:uid/cpu', handler)
106
+ sub.dispose()
107
+ ws.simulateMessage(JSON.stringify(['user:uid/cpu', { cpu: 25 }]))
108
+ await new Promise(r => setTimeout(r, 0))
109
+ expect(handler).not.toHaveBeenCalled()
110
+ })
111
+
112
+ it('isConnected reflects state', async () => {
113
+ const client = makeClient()
114
+ expect(client.isConnected).toBe(false)
115
+ await connectClient(client)
116
+ expect(client.isConnected).toBe(true)
117
+ client.disconnect()
118
+ expect(client.isConnected).toBe(false)
119
+ // After intentional disconnect, no reconnect should be attempted
120
+ await new Promise(r => setTimeout(r, 0))
121
+ expect(MockWS.instances).toHaveLength(1)
122
+ })
123
+
124
+ it('disconnect() prevents reconnect even when not currently reconnecting', async () => {
125
+ const client = makeClient()
126
+ const _ws = await connectClient(client)
127
+ client.disconnect()
128
+ // After disconnect, scheduleReconnect should be a no-op
129
+ // We verify by checking no new WS instance is created after a tick
130
+ await new Promise(r => setTimeout(r, 0))
131
+ expect(MockWS.instances).toHaveLength(1) // only the original one
132
+ })
133
+
134
+ it('disconnected event has willReconnect: false when disconnect() is called', async () => {
135
+ const client = makeClient()
136
+ const _ws = await connectClient(client)
137
+ let willReconnect: boolean | undefined
138
+ client.on('disconnected', (data) => {
139
+ willReconnect = (data as { willReconnect: boolean }).willReconnect
140
+ })
141
+ client.disconnect()
142
+ expect(willReconnect).toBe(false)
143
+ })
144
+ })
@@ -0,0 +1,64 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { mkdtemp, rm } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { FileStorage } from '../../src/storage/FileStorage.js'
6
+
7
+ let tmpDir: string
8
+
9
+ beforeEach(async () => {
10
+ tmpDir = await mkdtemp(join(tmpdir(), 'screeps-test-'))
11
+ })
12
+
13
+ afterEach(async () => {
14
+ await rm(tmpDir, { recursive: true, force: true })
15
+ })
16
+
17
+ describe('FileStorage', () => {
18
+ it('returns null for missing key', async () => {
19
+ const s = new FileStorage(tmpDir, 'ns')
20
+ expect(await s.get('missing')).toBeNull()
21
+ })
22
+
23
+ it('stores and retrieves binary data', async () => {
24
+ const s = new FileStorage(tmpDir, 'ns')
25
+ const data = new Uint8Array([10, 20, 30, 40])
26
+ await s.set('key', data)
27
+ const result = await s.get('key')
28
+ expect(result).toEqual(data)
29
+ })
30
+
31
+ it('delete removes the entry', async () => {
32
+ const s = new FileStorage(tmpDir, 'ns')
33
+ await s.set('key', new Uint8Array([1]))
34
+ await s.delete('key')
35
+ expect(await s.get('key')).toBeNull()
36
+ })
37
+
38
+ it('delete on missing key does not throw', async () => {
39
+ const s = new FileStorage(tmpDir, 'ns')
40
+ await expect(s.delete('missing')).resolves.toBeUndefined()
41
+ })
42
+
43
+ it('clear removes all entries for this namespace', async () => {
44
+ const s = new FileStorage(tmpDir, 'ns')
45
+ await s.set('a', new Uint8Array([1]))
46
+ await s.set('b', new Uint8Array([2]))
47
+ await s.clear()
48
+ expect(await s.get('a')).toBeNull()
49
+ expect(await s.get('b')).toBeNull()
50
+ })
51
+
52
+ it('namespaces are isolated', async () => {
53
+ const s1 = new FileStorage(tmpDir, 'ns1')
54
+ const s2 = new FileStorage(tmpDir, 'ns2')
55
+ await s1.set('key', new Uint8Array([1]))
56
+ expect(await s2.get('key')).toBeNull()
57
+ })
58
+
59
+ it('sanitizes URL-style namespace for directory name', async () => {
60
+ const s = new FileStorage(tmpDir, 'https://screeps.com')
61
+ await s.set('key', new Uint8Array([99]))
62
+ expect(await s.get('key')).toEqual(new Uint8Array([99]))
63
+ })
64
+ })
@@ -0,0 +1,36 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import 'fake-indexeddb/auto'
3
+ import { IndexedDBStorage } from '../../src/storage/IndexedDBStorage.js'
4
+
5
+ describe('IndexedDBStorage', () => {
6
+ let storage: IndexedDBStorage
7
+
8
+ beforeEach(() => {
9
+ storage = new IndexedDBStorage(`test-ns-${Math.random()}`)
10
+ })
11
+
12
+ it('returns null for missing key', async () => {
13
+ expect(await storage.get('missing')).toBeNull()
14
+ })
15
+
16
+ it('stores and retrieves binary data', async () => {
17
+ const data = new Uint8Array([1, 2, 3, 4])
18
+ await storage.set('mykey', data)
19
+ const result = await storage.get('mykey')
20
+ expect(result).toEqual(data)
21
+ })
22
+
23
+ it('delete removes the entry', async () => {
24
+ await storage.set('key', new Uint8Array([7]))
25
+ await storage.delete('key')
26
+ expect(await storage.get('key')).toBeNull()
27
+ })
28
+
29
+ it('clear removes all entries', async () => {
30
+ await storage.set('a', new Uint8Array([1]))
31
+ await storage.set('b', new Uint8Array([2]))
32
+ await storage.clear()
33
+ expect(await storage.get('a')).toBeNull()
34
+ expect(await storage.get('b')).toBeNull()
35
+ })
36
+ })
@@ -0,0 +1,24 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { NullStorage } from '../../src/storage/NullStorage.js'
3
+
4
+ describe('NullStorage', () => {
5
+ it('get always returns null', async () => {
6
+ const s = new NullStorage()
7
+ expect(await s.get('key')).toBeNull()
8
+ })
9
+
10
+ it('set is a no-op and does not throw', async () => {
11
+ const s = new NullStorage()
12
+ await expect(s.set('key', new Uint8Array([1, 2]))).resolves.toBeUndefined()
13
+ })
14
+
15
+ it('delete is a no-op and does not throw', async () => {
16
+ const s = new NullStorage()
17
+ await expect(s.delete('key')).resolves.toBeUndefined()
18
+ })
19
+
20
+ it('clear is a no-op and does not throw', async () => {
21
+ const s = new NullStorage()
22
+ await expect(s.clear()).resolves.toBeUndefined()
23
+ })
24
+ })
@@ -0,0 +1,234 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { MapStatsStore } from '../../src/stores/MapStatsStore.js'
3
+ import { HttpClient } from '../../src/http/HttpClient.js'
4
+ import { TokenAuth } from '../../src/http/auth/TokenAuth.js'
5
+
6
+ function mockResponse(body: unknown, opts: ResponseInit = {}): Response {
7
+ return new Response(JSON.stringify(body), {
8
+ status: 200,
9
+ headers: { 'content-type': 'application/json', ...opts.headers },
10
+ ...opts,
11
+ })
12
+ }
13
+
14
+ describe('MapStatsStore', () => {
15
+ let fetchMock: ReturnType<typeof vi.fn>
16
+ let store: MapStatsStore
17
+
18
+ beforeEach(() => {
19
+ fetchMock = vi.fn()
20
+ vi.stubGlobal('fetch', fetchMock)
21
+ const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 't' }) })
22
+ store = new MapStatsStore(http, 10)
23
+ })
24
+
25
+ afterEach(() => {
26
+ vi.unstubAllGlobals()
27
+ })
28
+
29
+ it('does nothing when rooms is empty', () => {
30
+ store.request([], 'owner0')
31
+ expect(fetchMock).not.toHaveBeenCalled()
32
+ })
33
+
34
+ it('emits per-room events after fetch', async () => {
35
+ fetchMock.mockResolvedValue(mockResponse({
36
+ ok: 1,
37
+ stats: {
38
+ W1N1: { own: { user: 'u1', level: 3 } },
39
+ },
40
+ users: { u1: { _id: 'u1', username: 'Alice', badge: { type: 1, color1: '#fff', color2: '#000', color3: '#f00', flip: false } } },
41
+ }))
42
+
43
+ const events: Array<{ room: string; stat: unknown }> = []
44
+ store.on('mapStats:room', (e) => events.push(e))
45
+
46
+ store.request(['W1N1'], 'owner0')
47
+
48
+ await new Promise(r => setTimeout(r, 50))
49
+ expect(events).toHaveLength(1)
50
+ expect(events[0].room).toBe('W1N1')
51
+ const stat = events[0].stat as { own: { user: string; level: number }; username: string }
52
+ expect(stat.own).toEqual({ user: 'u1', level: 3 })
53
+ expect(stat.username).toBe('Alice')
54
+ })
55
+
56
+ it('propagates badge data for room owners', async () => {
57
+ fetchMock.mockResolvedValue(mockResponse({
58
+ ok: 1,
59
+ stats: {
60
+ W1N1: { own: { user: 'u1', level: 3 } },
61
+ },
62
+ users: {
63
+ u1: {
64
+ _id: 'u1',
65
+ username: 'Alice',
66
+ badge: { type: 24, color1: '#000077', color2: '#5555dd', color3: '#9999ff', param: 0, flip: false },
67
+ },
68
+ },
69
+ }))
70
+
71
+ const events: Array<{ room: string; stat: unknown }> = []
72
+ store.on('mapStats:room', (e) => events.push(e))
73
+
74
+ store.request(['W1N1'], 'owner0')
75
+
76
+ await new Promise(r => setTimeout(r, 50))
77
+ expect(events).toHaveLength(1)
78
+ const stat = events[0].stat as { badge: { type: number; color1: string } }
79
+ expect(stat.badge).toBeDefined()
80
+ expect(stat.badge.type).toBe(24)
81
+ expect(stat.badge.color1).toBe('#000077')
82
+ })
83
+
84
+ it('does not include badge for unowned rooms', async () => {
85
+ fetchMock.mockResolvedValue(mockResponse({
86
+ ok: 1,
87
+ stats: {
88
+ W1N1: { status: 'normal' },
89
+ },
90
+ users: {},
91
+ }))
92
+
93
+ const events: Array<{ room: string; stat: unknown }> = []
94
+ store.on('mapStats:room', (e) => events.push(e))
95
+
96
+ store.request(['W1N1'], 'owner0')
97
+
98
+ await new Promise(r => setTimeout(r, 50))
99
+ expect(events).toHaveLength(1)
100
+ const stat = events[0].stat as { badge?: unknown }
101
+ expect(stat.badge).toBeUndefined()
102
+ })
103
+
104
+ it('batches multiple request() calls into one HTTP request', async () => {
105
+ fetchMock.mockResolvedValue(mockResponse({
106
+ ok: 1,
107
+ stats: {
108
+ W1N1: { own: { user: 'u1', level: 1 } },
109
+ W1N2: { own: { user: 'u2', level: 2 } },
110
+ },
111
+ users: {},
112
+ }))
113
+
114
+ store.request(['W1N1'], 'owner0')
115
+ store.request(['W1N2'], 'owner0')
116
+
117
+ await new Promise(r => setTimeout(r, 50))
118
+ expect(fetchMock).toHaveBeenCalledOnce()
119
+
120
+ const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
121
+ const body = JSON.parse(init.body as string)
122
+ expect(body.rooms).toContain('W1N1')
123
+ expect(body.rooms).toContain('W1N2')
124
+ })
125
+
126
+ it('does not batch calls with different statName or shard', async () => {
127
+ fetchMock
128
+ .mockResolvedValueOnce(mockResponse({ ok: 1, stats: {}, users: {} }))
129
+ .mockResolvedValueOnce(mockResponse({ ok: 1, stats: {}, users: {} }))
130
+ .mockResolvedValueOnce(mockResponse({ ok: 1, stats: {}, users: {} }))
131
+
132
+ store.request(['W1N1'], 'owner0', 'shard0')
133
+ store.request(['W1N1'], 'owner0', 'shard1')
134
+ store.request(['W1N1'], 'status', 'shard0')
135
+
136
+ await new Promise(r => setTimeout(r, 50))
137
+ expect(fetchMock).toHaveBeenCalledTimes(3)
138
+ })
139
+
140
+ it('emits empty entry for rooms that do not exist on server', async () => {
141
+ fetchMock.mockResolvedValue(mockResponse({
142
+ ok: 1,
143
+ stats: {},
144
+ users: {},
145
+ }))
146
+
147
+ const events: Array<{ room: string; stat: unknown }> = []
148
+ store.on('mapStats:room', (e) => events.push(e))
149
+
150
+ store.request(['W1N1'], 'owner0')
151
+
152
+ await new Promise(r => setTimeout(r, 50))
153
+ expect(events).toHaveLength(1)
154
+ expect(events[0].room).toBe('W1N1')
155
+ expect(events[0].stat).not.toHaveProperty('own')
156
+ })
157
+
158
+ it('extracts mineral type and density from response', async () => {
159
+ fetchMock.mockResolvedValue(mockResponse({
160
+ ok: 1,
161
+ stats: {
162
+ W1N1: {
163
+ own: { user: 'u1', level: 1 },
164
+ minerals0: { type: 'H', density: 4 },
165
+ },
166
+ },
167
+ users: {},
168
+ }))
169
+
170
+ const events: Array<{ room: string; stat: unknown }> = []
171
+ store.on('mapStats:room', (e) => events.push(e))
172
+
173
+ store.request(['W1N1'], 'owner0')
174
+
175
+ await new Promise(r => setTimeout(r, 50))
176
+ expect(events).toHaveLength(1)
177
+ const stat = events[0].stat as { mineral: string; density: number }
178
+ expect(stat.mineral).toBe('H')
179
+ expect(stat.density).toBe(4)
180
+ })
181
+
182
+ it('extracts safeMode from response', async () => {
183
+ fetchMock.mockResolvedValue(mockResponse({
184
+ ok: 1,
185
+ stats: {
186
+ W9N8: {
187
+ status: 'normal',
188
+ novice: null,
189
+ respawnArea: null,
190
+ openTime: null,
191
+ own: { user: 'u1', level: 2 },
192
+ safeMode: true,
193
+ minerals0: { type: 'O', density: 2 },
194
+ },
195
+ },
196
+ users: { u1: { _id: 'u1', username: 'Alice' } },
197
+ }))
198
+
199
+ const events: Array<{ room: string; stat: unknown }> = []
200
+ store.on('mapStats:room', (e) => events.push(e))
201
+
202
+ store.request(['W9N8'], 'owner0')
203
+
204
+ await new Promise(r => setTimeout(r, 50))
205
+ expect(events).toHaveLength(1)
206
+ const stat = events[0].stat as { safeMode: boolean; own: { user: string; level: number }; username: string }
207
+ expect(stat.safeMode).toBe(true)
208
+ expect(stat.own).toEqual({ user: 'u1', level: 2 })
209
+ expect(stat.username).toBe('Alice')
210
+ })
211
+
212
+ it('omits safeMode when not present in response', async () => {
213
+ fetchMock.mockResolvedValue(mockResponse({
214
+ ok: 1,
215
+ stats: {
216
+ W1N1: {
217
+ status: 'normal',
218
+ own: { user: 'u1', level: 1 },
219
+ },
220
+ },
221
+ users: {},
222
+ }))
223
+
224
+ const events: Array<{ room: string; stat: unknown }> = []
225
+ store.on('mapStats:room', (e) => events.push(e))
226
+
227
+ store.request(['W1N1'], 'owner0')
228
+
229
+ await new Promise(r => setTimeout(r, 50))
230
+ expect(events).toHaveLength(1)
231
+ const stat = events[0].stat as { safeMode?: boolean }
232
+ expect(stat.safeMode).toBeUndefined()
233
+ })
234
+ })