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.
- package/CHANGELOG.md +40 -0
- package/eslint.config.js +54 -0
- package/package.json +45 -0
- package/src/ScreepsClient.ts +172 -0
- package/src/badge/colors.ts +83 -0
- package/src/badge/generateSvg.ts +70 -0
- package/src/badge/index.ts +1 -0
- package/src/badge/paths.ts +385 -0
- package/src/cache/Cache.ts +71 -0
- package/src/cache/Map2Storage.ts +112 -0
- package/src/file-storage.ts +2 -0
- package/src/http/HttpClient.ts +160 -0
- package/src/http/auth/AuthStrategy.ts +5 -0
- package/src/http/auth/GuestAuth.ts +13 -0
- package/src/http/auth/PasswordAuth.ts +17 -0
- package/src/http/auth/SteamTicketAuth.ts +17 -0
- package/src/http/auth/TokenAuth.ts +14 -0
- package/src/http/decompress.ts +37 -0
- package/src/http/endpoints/auth.ts +23 -0
- package/src/http/endpoints/experimental.ts +13 -0
- package/src/http/endpoints/game.ts +103 -0
- package/src/http/endpoints/leaderboard.ts +16 -0
- package/src/http/endpoints/power-creeps.ts +24 -0
- package/src/http/endpoints/register.ts +19 -0
- package/src/http/endpoints/user-messages.ts +20 -0
- package/src/http/endpoints/user.ts +95 -0
- package/src/http/fetchServerVersion.ts +151 -0
- package/src/index.ts +55 -0
- package/src/logger.ts +25 -0
- package/src/socket/MessageParser.ts +44 -0
- package/src/socket/SocketClient.ts +203 -0
- package/src/storage/FileStorage.ts +44 -0
- package/src/storage/IndexedDBStorage.ts +77 -0
- package/src/storage/NullStorage.ts +8 -0
- package/src/storage/StorageAdapter.ts +6 -0
- package/src/stores/MapStatsStore.ts +115 -0
- package/src/stores/MapStore.ts +254 -0
- package/src/stores/NavigationStore.ts +61 -0
- package/src/stores/RoomStore.ts +264 -0
- package/src/stores/ServerStore.ts +128 -0
- package/src/stores/TypedStore.ts +31 -0
- package/src/stores/UserStore.ts +189 -0
- package/src/subscription/index.ts +18 -0
- package/src/types/api.ts +252 -0
- package/src/types/events.ts +72 -0
- package/src/types/game.ts +160 -0
- package/tests/.gitkeep +0 -0
- package/tests/ScreepsClient.test.ts +229 -0
- package/tests/badge/generateSvg.test.ts +174 -0
- package/tests/cache/Cache.test.ts +99 -0
- package/tests/cache/Map2Storage.test.ts +130 -0
- package/tests/http/HttpClient.test.ts +188 -0
- package/tests/http/decompress.test.ts +52 -0
- package/tests/http/endpoints/auth.test.ts +126 -0
- package/tests/http/endpoints/game.test.ts +210 -0
- package/tests/http/endpoints/power-creeps.test.ts +81 -0
- package/tests/http/endpoints/user-messages.test.ts +68 -0
- package/tests/http/endpoints/user.test.ts +139 -0
- package/tests/socket/MessageParser.test.ts +55 -0
- package/tests/socket/SocketClient.test.ts +144 -0
- package/tests/storage/FileStorage.test.ts +64 -0
- package/tests/storage/IndexedDBStorage.test.ts +36 -0
- package/tests/storage/NullStorage.test.ts +24 -0
- package/tests/stores/MapStatsStore.test.ts +234 -0
- package/tests/stores/MapStore.test.ts +537 -0
- package/tests/stores/NavigationStore.test.ts +166 -0
- package/tests/stores/RoomStore.test.ts +130 -0
- package/tests/stores/ServerStore.test.ts +48 -0
- package/tests/stores/TypedStore.test.ts +54 -0
- package/tests/stores/UserStore.test.ts +136 -0
- package/tests/subscription/SubscriptionGroup.test.ts +34 -0
- package/tests/types/game.test.ts +42 -0
- package/tsconfig.json +17 -0
- package/tsup.config.ts +9 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { MapStore } from '../../src/stores/MapStore.js'
|
|
3
|
+
import { Map2Storage } from '../../src/cache/Map2Storage.js'
|
|
4
|
+
import type { RoomMap2Data } from '../../src/types/game.js'
|
|
5
|
+
|
|
6
|
+
function makeStore(maxEntries = 10000) {
|
|
7
|
+
const socket = {
|
|
8
|
+
subscribe: vi.fn().mockReturnValue({ dispose: vi.fn() }),
|
|
9
|
+
on: vi.fn().mockReturnValue({ dispose: vi.fn() }),
|
|
10
|
+
} as unknown as import('../../src/socket/SocketClient.js').SocketClient
|
|
11
|
+
|
|
12
|
+
const storage = new Map2Storage({ adapter: null, namespace: 'test', maxEntries })
|
|
13
|
+
const store = new MapStore(socket, storage)
|
|
14
|
+
return { store, socket, storage }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('MapStore', () => {
|
|
18
|
+
it('subscribeMap2() calls socket.subscribe with the correct channel (with shard)', () => {
|
|
19
|
+
const { store, socket } = makeStore()
|
|
20
|
+
store.subscribeMap2('W7N7', 'shard0')
|
|
21
|
+
expect(socket.subscribe).toHaveBeenCalledWith('roomMap2:shard0/W7N7')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('subscribeMap2() omits shard prefix when shard is null (private server)', () => {
|
|
25
|
+
const { store, socket } = makeStore()
|
|
26
|
+
store.subscribeMap2('E9N3', null)
|
|
27
|
+
expect(socket.subscribe).toHaveBeenCalledWith('roomMap2:E9N3')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('subscribeMap2() returns a Subscription with dispose()', () => {
|
|
31
|
+
const { store } = makeStore()
|
|
32
|
+
const sub = store.subscribeMap2('W7N7', 'shard0')
|
|
33
|
+
expect(typeof sub.dispose).toBe('function')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('map2data() returns null before any data arrives', () => {
|
|
37
|
+
const { store } = makeStore()
|
|
38
|
+
expect(store.map2data('W7N7', 'shard0')).toBeNull()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('emits room:map2update with source:live on new data', () => {
|
|
42
|
+
const { store, socket } = makeStore()
|
|
43
|
+
|
|
44
|
+
let handler!: (data: unknown) => void
|
|
45
|
+
;(socket.on as ReturnType<typeof vi.fn>).mockImplementation((_ch: string, cb: (data: unknown) => void) => {
|
|
46
|
+
handler = cb
|
|
47
|
+
return { dispose: vi.fn() }
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const listener = vi.fn()
|
|
51
|
+
store.on('room:map2update', listener)
|
|
52
|
+
store.subscribeMap2('W7N7', 'shard0')
|
|
53
|
+
|
|
54
|
+
const data: RoomMap2Data = { s: [[10, 20]], c: [[25, 25]] }
|
|
55
|
+
handler(data)
|
|
56
|
+
|
|
57
|
+
expect(listener).toHaveBeenCalledOnce()
|
|
58
|
+
expect(listener).toHaveBeenCalledWith(expect.objectContaining({
|
|
59
|
+
room: 'W7N7',
|
|
60
|
+
shard: 'shard0',
|
|
61
|
+
source: 'live',
|
|
62
|
+
data,
|
|
63
|
+
}))
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('diff detection: identical successive messages do NOT emit room:map2update again', () => {
|
|
67
|
+
const { store, socket } = makeStore()
|
|
68
|
+
|
|
69
|
+
let handler!: (data: unknown) => void
|
|
70
|
+
;(socket.on as ReturnType<typeof vi.fn>).mockImplementation((_ch: string, cb: (data: unknown) => void) => {
|
|
71
|
+
handler = cb
|
|
72
|
+
return { dispose: vi.fn() }
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const listener = vi.fn()
|
|
76
|
+
store.on('room:map2update', listener)
|
|
77
|
+
store.subscribeMap2('W7N7', 'shard0')
|
|
78
|
+
|
|
79
|
+
const data: RoomMap2Data = { s: [[10, 20]] }
|
|
80
|
+
handler(data)
|
|
81
|
+
handler({ s: [[10, 20]] }) // same data, different object reference
|
|
82
|
+
|
|
83
|
+
expect(listener).toHaveBeenCalledOnce()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('diff detection: changed data DOES emit room:map2update', () => {
|
|
87
|
+
const { store, socket } = makeStore()
|
|
88
|
+
|
|
89
|
+
let handler!: (data: unknown) => void
|
|
90
|
+
;(socket.on as ReturnType<typeof vi.fn>).mockImplementation((_ch: string, cb: (data: unknown) => void) => {
|
|
91
|
+
handler = cb
|
|
92
|
+
return { dispose: vi.fn() }
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const listener = vi.fn()
|
|
96
|
+
store.on('room:map2update', listener)
|
|
97
|
+
store.subscribeMap2('W7N7', 'shard0')
|
|
98
|
+
|
|
99
|
+
handler({ s: [[10, 20]] })
|
|
100
|
+
handler({ s: [[10, 21]] }) // different y
|
|
101
|
+
|
|
102
|
+
expect(listener).toHaveBeenCalledTimes(2)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('diff detection: key ordering in payload does not matter', () => {
|
|
106
|
+
const { store, socket } = makeStore()
|
|
107
|
+
|
|
108
|
+
let handler!: (data: unknown) => void
|
|
109
|
+
;(socket.on as ReturnType<typeof vi.fn>).mockImplementation((_ch: string, cb: (data: unknown) => void) => {
|
|
110
|
+
handler = cb
|
|
111
|
+
return { dispose: vi.fn() }
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const listener = vi.fn()
|
|
115
|
+
store.on('room:map2update', listener)
|
|
116
|
+
store.subscribeMap2('W7N7', 'shard0')
|
|
117
|
+
|
|
118
|
+
handler({ s: [[10, 20]], c: [[25, 25]] })
|
|
119
|
+
handler({ c: [[25, 25]], s: [[10, 20]] }) // same data, different key order
|
|
120
|
+
|
|
121
|
+
expect(listener).toHaveBeenCalledOnce()
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('ref-counting: multiple subscribes to same room share one WS subscription', () => {
|
|
125
|
+
const { store, socket } = makeStore()
|
|
126
|
+
store.subscribeMap2('W7N7', 'shard0')
|
|
127
|
+
store.subscribeMap2('W7N7', 'shard0')
|
|
128
|
+
// MapStore opens exactly one socket subscription per key; second call reuses it
|
|
129
|
+
expect(socket.subscribe).toHaveBeenCalledOnce()
|
|
130
|
+
expect(socket.subscribe).toHaveBeenCalledWith('roomMap2:shard0/W7N7')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('dispose() when last ref is released, closes the socket subscription', () => {
|
|
134
|
+
const { store, socket } = makeStore()
|
|
135
|
+
const socketDispose = vi.fn()
|
|
136
|
+
;(socket.subscribe as ReturnType<typeof vi.fn>).mockReturnValue({ dispose: socketDispose })
|
|
137
|
+
;(socket.on as ReturnType<typeof vi.fn>).mockReturnValue({ dispose: vi.fn() })
|
|
138
|
+
|
|
139
|
+
const sub1 = store.subscribeMap2('W7N7', 'shard0')
|
|
140
|
+
const sub2 = store.subscribeMap2('W7N7', 'shard0')
|
|
141
|
+
|
|
142
|
+
sub1.dispose()
|
|
143
|
+
expect(socketDispose).not.toHaveBeenCalled() // refCount still 1
|
|
144
|
+
|
|
145
|
+
sub2.dispose()
|
|
146
|
+
expect(socketDispose).toHaveBeenCalledOnce() // last ref gone, socket cleaned up
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('map2data() returns data after first message arrives', () => {
|
|
150
|
+
const { store, socket } = makeStore()
|
|
151
|
+
|
|
152
|
+
let handler!: (data: unknown) => void
|
|
153
|
+
;(socket.on as ReturnType<typeof vi.fn>).mockImplementation((_ch: string, cb: (data: unknown) => void) => {
|
|
154
|
+
handler = cb
|
|
155
|
+
return { dispose: vi.fn() }
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
store.subscribeMap2('W7N7', 'shard0')
|
|
159
|
+
const data: RoomMap2Data = { c: [[25, 25]] }
|
|
160
|
+
handler(data)
|
|
161
|
+
|
|
162
|
+
expect(store.map2data('W7N7', 'shard0')).toEqual(data)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('data is retained in storage after dispose (for future cache warm-start)', () => {
|
|
166
|
+
const { store, socket } = makeStore()
|
|
167
|
+
|
|
168
|
+
let handler!: (data: unknown) => void
|
|
169
|
+
;(socket.on as ReturnType<typeof vi.fn>).mockImplementation((_ch: string, cb: (data: unknown) => void) => {
|
|
170
|
+
handler = cb
|
|
171
|
+
return { dispose: vi.fn() }
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
const sub = store.subscribeMap2('W7N7', 'shard0')
|
|
175
|
+
handler({ c: [[25, 25]] })
|
|
176
|
+
sub.dispose()
|
|
177
|
+
|
|
178
|
+
expect(store.map2data('W7N7', 'shard0')).not.toBeNull()
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
function makeStoreWithLimit(maxSubscriptions: number) {
|
|
183
|
+
const socket = {
|
|
184
|
+
subscribe: vi.fn().mockReturnValue({ dispose: vi.fn() }),
|
|
185
|
+
on: vi.fn().mockReturnValue({ dispose: vi.fn() }),
|
|
186
|
+
} as unknown as import('../../src/socket/SocketClient.js').SocketClient
|
|
187
|
+
const storage = new Map2Storage({ adapter: null, namespace: 'test', maxEntries: 10000 })
|
|
188
|
+
const store = new MapStore(socket, storage, { maxSubscriptions })
|
|
189
|
+
return { store, socket, storage }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
describe('MapStore — subscription limit + waitlist', () => {
|
|
193
|
+
it('subscribeMap2() returns active subscription when under limit', () => {
|
|
194
|
+
const { store } = makeStore()
|
|
195
|
+
const sub = store.subscribeMap2('W7N7', 'shard0')
|
|
196
|
+
expect(sub.status()).toBe('active')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('subscribeMap2() returns pending subscription when at limit', () => {
|
|
200
|
+
const { store } = makeStoreWithLimit(1)
|
|
201
|
+
store.subscribeMap2('W1N1', 'shard0') // fills the slot
|
|
202
|
+
const sub = store.subscribeMap2('W7N7', 'shard0')
|
|
203
|
+
expect(sub.status()).toBe('pending')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('unsubscribing active promotes next waitlist entry (FIFO)', () => {
|
|
207
|
+
const { store } = makeStoreWithLimit(1)
|
|
208
|
+
const sub1 = store.subscribeMap2('W1N1', 'shard0') // active
|
|
209
|
+
const sub2 = store.subscribeMap2('W7N7', 'shard0') // pending first
|
|
210
|
+
const sub3 = store.subscribeMap2('W8N8', 'shard0') // pending second
|
|
211
|
+
|
|
212
|
+
expect(sub2.status()).toBe('pending')
|
|
213
|
+
sub1.dispose() // frees slot → W7N7 promoted (FIFO)
|
|
214
|
+
expect(sub2.status()).toBe('active')
|
|
215
|
+
expect(sub3.status()).toBe('pending') // W8N8 still waiting
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('unsubscribing a waitlist entry does NOT trigger promotion', () => {
|
|
219
|
+
const { store } = makeStoreWithLimit(1)
|
|
220
|
+
const sub1 = store.subscribeMap2('W1N1', 'shard0') // active
|
|
221
|
+
const sub2 = store.subscribeMap2('W7N7', 'shard0') // pending
|
|
222
|
+
const sub3 = store.subscribeMap2('W8N8', 'shard0') // pending
|
|
223
|
+
|
|
224
|
+
sub2.dispose() // remove from waitlist — no slot freed, no promotion
|
|
225
|
+
expect(sub1.status()).toBe('active')
|
|
226
|
+
expect(sub3.status()).toBe('pending')
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('emits room:map2state active on new subscription under limit', () => {
|
|
230
|
+
const { store } = makeStore()
|
|
231
|
+
const events: string[] = []
|
|
232
|
+
store.on('room:map2state', ({ status }) => events.push(status))
|
|
233
|
+
store.subscribeMap2('W7N7', 'shard0')
|
|
234
|
+
expect(events).toContain('active')
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('emits room:map2state pending when at limit', () => {
|
|
238
|
+
const { store } = makeStoreWithLimit(1)
|
|
239
|
+
store.subscribeMap2('W1N1', 'shard0')
|
|
240
|
+
const events: Array<{ room: string; status: string }> = []
|
|
241
|
+
store.on('room:map2state', e => events.push(e))
|
|
242
|
+
store.subscribeMap2('W7N7', 'shard0')
|
|
243
|
+
expect(events).toContainEqual(expect.objectContaining({ room: 'W7N7', status: 'pending' }))
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('emits room:map2state active when waitlist entry is promoted', () => {
|
|
247
|
+
const { store } = makeStoreWithLimit(1)
|
|
248
|
+
const sub1 = store.subscribeMap2('W1N1', 'shard0')
|
|
249
|
+
store.subscribeMap2('W7N7', 'shard0')
|
|
250
|
+
const events: Array<{ room: string; status: string }> = []
|
|
251
|
+
store.on('room:map2state', e => events.push(e))
|
|
252
|
+
sub1.dispose()
|
|
253
|
+
expect(events).toContainEqual(expect.objectContaining({ room: 'W7N7', status: 'active' }))
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('onStatusChange fires when subscription transitions pending → active', () => {
|
|
257
|
+
const { store } = makeStoreWithLimit(1)
|
|
258
|
+
const sub1 = store.subscribeMap2('W1N1', 'shard0')
|
|
259
|
+
const sub2 = store.subscribeMap2('W7N7', 'shard0')
|
|
260
|
+
|
|
261
|
+
const history: string[] = []
|
|
262
|
+
sub2.onStatusChange(s => history.push(s))
|
|
263
|
+
|
|
264
|
+
sub1.dispose()
|
|
265
|
+
expect(history).toEqual(['active'])
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('onStatusChange handler can be disposed independently', () => {
|
|
269
|
+
const { store } = makeStoreWithLimit(1)
|
|
270
|
+
const sub1 = store.subscribeMap2('W1N1', 'shard0')
|
|
271
|
+
const sub2 = store.subscribeMap2('W7N7', 'shard0')
|
|
272
|
+
|
|
273
|
+
const handler = vi.fn()
|
|
274
|
+
const handlerSub = sub2.onStatusChange(handler)
|
|
275
|
+
handlerSub.dispose() // remove handler before promotion
|
|
276
|
+
|
|
277
|
+
sub1.dispose()
|
|
278
|
+
expect(handler).not.toHaveBeenCalled()
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('dispose() is idempotent — second call has no effect', () => {
|
|
282
|
+
const { store } = makeStoreWithLimit(1)
|
|
283
|
+
store.subscribeMap2('W1N1', 'shard0')
|
|
284
|
+
const sub2 = store.subscribeMap2('W7N7', 'shard0')
|
|
285
|
+
|
|
286
|
+
sub2.dispose()
|
|
287
|
+
sub2.dispose() // second call — must not throw or double-decrement
|
|
288
|
+
|
|
289
|
+
// Sub1 still active (refCount unchanged)
|
|
290
|
+
expect(store.subscribeMap2('W1N1', 'shard0').status()).toBe('active')
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('cachedData() returns current memory data synchronously', () => {
|
|
294
|
+
const { store, storage } = makeStore()
|
|
295
|
+
const data: RoomMap2Data = { s: [[10, 20]] }
|
|
296
|
+
void storage.put('W7N7', 'shard0', data)
|
|
297
|
+
const sub = store.subscribeMap2('W7N7', 'shard0')
|
|
298
|
+
expect(sub.cachedData()).toEqual(data)
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('promotion opens a new WS socket subscription for the promoted room', () => {
|
|
302
|
+
const { store, socket } = makeStoreWithLimit(1)
|
|
303
|
+
const sub1 = store.subscribeMap2('W1N1', 'shard0') // active, socket.subscribe #1
|
|
304
|
+
store.subscribeMap2('W7N7', 'shard0') // pending, no socket.subscribe
|
|
305
|
+
|
|
306
|
+
expect(socket.subscribe).toHaveBeenCalledOnce() // only W1N1 subscribed so far
|
|
307
|
+
|
|
308
|
+
sub1.dispose() // promotes W7N7
|
|
309
|
+
|
|
310
|
+
expect(socket.subscribe).toHaveBeenCalledTimes(2)
|
|
311
|
+
expect(socket.subscribe).toHaveBeenLastCalledWith('roomMap2:shard0/W7N7')
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it('live data arrives for promoted room after promotion', () => {
|
|
315
|
+
let capturedHandler: ((data: unknown) => void) | null = null
|
|
316
|
+
const socketMock = {
|
|
317
|
+
subscribe: vi.fn().mockReturnValue({ dispose: vi.fn() }),
|
|
318
|
+
on: vi.fn().mockImplementation((_ch: string, cb: (data: unknown) => void) => {
|
|
319
|
+
capturedHandler = cb
|
|
320
|
+
return { dispose: vi.fn() }
|
|
321
|
+
}),
|
|
322
|
+
} as unknown as import('../../src/socket/SocketClient.js').SocketClient
|
|
323
|
+
|
|
324
|
+
const storage = new Map2Storage({ adapter: null, namespace: 'test', maxEntries: 10000 })
|
|
325
|
+
const store = new MapStore(socketMock, storage, { maxSubscriptions: 1 })
|
|
326
|
+
|
|
327
|
+
const sub1 = store.subscribeMap2('W1N1', 'shard0')
|
|
328
|
+
store.subscribeMap2('W7N7', 'shard0')
|
|
329
|
+
|
|
330
|
+
sub1.dispose() // promotes W7N7, socket.on registered for W7N7
|
|
331
|
+
|
|
332
|
+
const liveEvents: string[] = []
|
|
333
|
+
store.on('room:map2update', ({ room, source }) => {
|
|
334
|
+
if (source === 'live') liveEvents.push(room)
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
capturedHandler!({ s: [[10, 20]] })
|
|
338
|
+
expect(liveEvents).toContain('W7N7')
|
|
339
|
+
})
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
describe('MapStore — cache warm-start', () => {
|
|
343
|
+
it('emits source:cache via microtask when memory has data at subscribe time', async () => {
|
|
344
|
+
const { store, storage } = makeStore()
|
|
345
|
+
|
|
346
|
+
const data: RoomMap2Data = { s: [[10, 20]] }
|
|
347
|
+
// Populate memory synchronously (put() updates memory before its first await)
|
|
348
|
+
void storage.put('W7N7', 'shard0', data)
|
|
349
|
+
|
|
350
|
+
const listener = vi.fn()
|
|
351
|
+
store.on('room:map2update', listener)
|
|
352
|
+
store.subscribeMap2('W7N7', 'shard0')
|
|
353
|
+
|
|
354
|
+
// No emission yet — warm-start is deferred to a microtask
|
|
355
|
+
expect(listener).not.toHaveBeenCalled()
|
|
356
|
+
|
|
357
|
+
await Promise.resolve()
|
|
358
|
+
|
|
359
|
+
expect(listener).toHaveBeenCalledOnce()
|
|
360
|
+
expect(listener).toHaveBeenCalledWith(expect.objectContaining({
|
|
361
|
+
room: 'W7N7',
|
|
362
|
+
shard: 'shard0',
|
|
363
|
+
source: 'cache',
|
|
364
|
+
data,
|
|
365
|
+
}))
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it('warm-start does NOT fire when there is no cached data', async () => {
|
|
369
|
+
const { store } = makeStore()
|
|
370
|
+
const listener = vi.fn()
|
|
371
|
+
store.on('room:map2update', listener)
|
|
372
|
+
store.subscribeMap2('W7N7', 'shard0')
|
|
373
|
+
|
|
374
|
+
await Promise.resolve()
|
|
375
|
+
|
|
376
|
+
expect(listener).not.toHaveBeenCalled()
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
it('warm-start does NOT fire if subscription is disposed before microtask runs', async () => {
|
|
380
|
+
const { store, storage } = makeStore()
|
|
381
|
+
void storage.put('W7N7', 'shard0', { s: [[10, 20]] })
|
|
382
|
+
|
|
383
|
+
const listener = vi.fn()
|
|
384
|
+
store.on('room:map2update', listener)
|
|
385
|
+
const sub = store.subscribeMap2('W7N7', 'shard0')
|
|
386
|
+
sub.dispose() // dispose immediately, before microtask runs
|
|
387
|
+
|
|
388
|
+
await Promise.resolve()
|
|
389
|
+
|
|
390
|
+
expect(listener).not.toHaveBeenCalled()
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
it('warm-start source:cache is emitted even when room is on second subscribe (ref already held)', async () => {
|
|
394
|
+
const { store, socket, storage } = makeStore()
|
|
395
|
+
;(socket.on as ReturnType<typeof vi.fn>).mockReturnValue({ dispose: vi.fn() })
|
|
396
|
+
|
|
397
|
+
const data: RoomMap2Data = { c: [[25, 25]] }
|
|
398
|
+
void storage.put('W7N7', 'shard0', data)
|
|
399
|
+
|
|
400
|
+
const listener = vi.fn()
|
|
401
|
+
store.on('room:map2update', listener)
|
|
402
|
+
|
|
403
|
+
store.subscribeMap2('W7N7', 'shard0') // first sub — warm-start scheduled
|
|
404
|
+
await Promise.resolve() // first warm-start fires
|
|
405
|
+
listener.mockClear()
|
|
406
|
+
|
|
407
|
+
store.subscribeMap2('W7N7', 'shard0') // second sub — another warm-start scheduled
|
|
408
|
+
await Promise.resolve()
|
|
409
|
+
|
|
410
|
+
expect(listener).toHaveBeenCalledOnce()
|
|
411
|
+
expect(listener).toHaveBeenCalledWith(expect.objectContaining({ source: 'cache' }))
|
|
412
|
+
})
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
function makeSocketMockWithReconnect() {
|
|
416
|
+
const listeners = new Map<string, (data: unknown) => void>()
|
|
417
|
+
const socket = {
|
|
418
|
+
subscribe: vi.fn().mockReturnValue({ dispose: vi.fn() }),
|
|
419
|
+
on: vi.fn().mockImplementation((channel: string, cb: (data: unknown) => void) => {
|
|
420
|
+
listeners.set(channel, cb)
|
|
421
|
+
return { dispose: vi.fn() }
|
|
422
|
+
}),
|
|
423
|
+
trigger: (channel: string, data: unknown) => listeners.get(channel)?.(data),
|
|
424
|
+
} as unknown as import('../../src/socket/SocketClient.js').SocketClient & { trigger: (ch: string, d: unknown) => void }
|
|
425
|
+
return socket
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
describe('MapStore — reconnect handling', () => {
|
|
429
|
+
it('re-emits room:map2state active for all active subs on connected', () => {
|
|
430
|
+
const socket = makeSocketMockWithReconnect()
|
|
431
|
+
const storage = new Map2Storage({ adapter: null, namespace: 'test', maxEntries: 10000 })
|
|
432
|
+
const store = new MapStore(socket, storage)
|
|
433
|
+
|
|
434
|
+
store.subscribeMap2('W1N1', 'shard0')
|
|
435
|
+
store.subscribeMap2('W2N2', 'shard0')
|
|
436
|
+
|
|
437
|
+
const events: Array<{ room: string; status: string }> = []
|
|
438
|
+
store.on('room:map2state', e => events.push(e))
|
|
439
|
+
|
|
440
|
+
// Simulate reconnect
|
|
441
|
+
socket.trigger('connected', {})
|
|
442
|
+
|
|
443
|
+
expect(events).toContainEqual(expect.objectContaining({ room: 'W1N1', status: 'active' }))
|
|
444
|
+
expect(events).toContainEqual(expect.objectContaining({ room: 'W2N2', status: 'active' }))
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
it('re-emits room:map2state pending for all waitlist subs on connected', () => {
|
|
448
|
+
const socket = makeSocketMockWithReconnect()
|
|
449
|
+
const storage = new Map2Storage({ adapter: null, namespace: 'test', maxEntries: 10000 })
|
|
450
|
+
const store = new MapStore(socket, storage, { maxSubscriptions: 1 })
|
|
451
|
+
|
|
452
|
+
store.subscribeMap2('W1N1', 'shard0') // active
|
|
453
|
+
store.subscribeMap2('W7N7', 'shard0') // pending
|
|
454
|
+
|
|
455
|
+
const events: Array<{ room: string; status: string }> = []
|
|
456
|
+
store.on('room:map2state', e => events.push(e))
|
|
457
|
+
|
|
458
|
+
socket.trigger('connected', {})
|
|
459
|
+
|
|
460
|
+
expect(events).toContainEqual(expect.objectContaining({ room: 'W1N1', status: 'active' }))
|
|
461
|
+
expect(events).toContainEqual(expect.objectContaining({ room: 'W7N7', status: 'pending' }))
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
it('live data still flows for active subs after reconnect', () => {
|
|
465
|
+
const socket = makeSocketMockWithReconnect()
|
|
466
|
+
const storage = new Map2Storage({ adapter: null, namespace: 'test', maxEntries: 10000 })
|
|
467
|
+
const store = new MapStore(socket, storage)
|
|
468
|
+
|
|
469
|
+
store.subscribeMap2('W1N1', 'shard0')
|
|
470
|
+
|
|
471
|
+
socket.trigger('connected', {})
|
|
472
|
+
|
|
473
|
+
const liveEvents: string[] = []
|
|
474
|
+
store.on('room:map2update', ({ room, source }) => { if (source === 'live') liveEvents.push(room) })
|
|
475
|
+
|
|
476
|
+
socket.trigger('roomMap2:shard0/W1N1', { s: [[10, 20]] })
|
|
477
|
+
expect(liveEvents).toContain('W1N1')
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
it('emits no events on connected when store has no subscriptions', () => {
|
|
481
|
+
const socket = makeSocketMockWithReconnect()
|
|
482
|
+
const storage = new Map2Storage({ adapter: null, namespace: 'test', maxEntries: 10000 })
|
|
483
|
+
const store = new MapStore(socket, storage)
|
|
484
|
+
|
|
485
|
+
const events: unknown[] = []
|
|
486
|
+
store.on('room:map2state', e => events.push(e))
|
|
487
|
+
|
|
488
|
+
socket.trigger('connected', {})
|
|
489
|
+
expect(events).toHaveLength(0)
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
it('first message after reconnect always emits, even when payload is unchanged', () => {
|
|
493
|
+
const socket = makeSocketMockWithReconnect()
|
|
494
|
+
const storage = new Map2Storage({ adapter: null, namespace: 'test', maxEntries: 10000 })
|
|
495
|
+
const store = new MapStore(socket, storage)
|
|
496
|
+
|
|
497
|
+
store.subscribeMap2('W1N1', 'shard0')
|
|
498
|
+
|
|
499
|
+
const liveEvents: unknown[] = []
|
|
500
|
+
store.on('room:map2update', e => { if (e.source === 'live') liveEvents.push(e) })
|
|
501
|
+
|
|
502
|
+
const payload = { s: [[10, 20]] as [number, number][] }
|
|
503
|
+
socket.trigger('roomMap2:shard0/W1N1', payload)
|
|
504
|
+
expect(liveEvents).toHaveLength(1)
|
|
505
|
+
|
|
506
|
+
// Identical payload before reconnect is deduped
|
|
507
|
+
socket.trigger('roomMap2:shard0/W1N1', { s: [[10, 20]] })
|
|
508
|
+
expect(liveEvents).toHaveLength(1)
|
|
509
|
+
|
|
510
|
+
// After reconnect the server resends initial state — same payload must emit again
|
|
511
|
+
socket.trigger('connected', {})
|
|
512
|
+
socket.trigger('roomMap2:shard0/W1N1', { s: [[10, 20]] })
|
|
513
|
+
expect(liveEvents).toHaveLength(2)
|
|
514
|
+
|
|
515
|
+
// Subsequent identical messages after that are deduped again
|
|
516
|
+
socket.trigger('roomMap2:shard0/W1N1', { s: [[10, 20]] })
|
|
517
|
+
expect(liveEvents).toHaveLength(2)
|
|
518
|
+
})
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
describe('Map2Storage LRU eviction', () => {
|
|
522
|
+
it('evicts least-recently-accessed entry when over maxEntries', () => {
|
|
523
|
+
const storage = new Map2Storage({ adapter: null, namespace: 'test', maxEntries: 2 })
|
|
524
|
+
const data: RoomMap2Data = {}
|
|
525
|
+
|
|
526
|
+
storage.put('W1N1', null, data)
|
|
527
|
+
storage.put('W2N2', null, data)
|
|
528
|
+
// Touch W1N1 so W2N2 is oldest
|
|
529
|
+
storage.getMemory('W1N1', null)
|
|
530
|
+
// Adding W3N3 should evict W2N2
|
|
531
|
+
storage.put('W3N3', null, data)
|
|
532
|
+
|
|
533
|
+
expect(storage.getMemory('W1N1', null)).not.toBeNull()
|
|
534
|
+
expect(storage.getMemory('W2N2', null)).toBeNull()
|
|
535
|
+
expect(storage.getMemory('W3N3', null)).not.toBeNull()
|
|
536
|
+
})
|
|
537
|
+
})
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { NavigationStore } from '../../src/stores/NavigationStore.js'
|
|
3
|
+
|
|
4
|
+
function makeStore(maxHistory = 50) {
|
|
5
|
+
return new NavigationStore(maxHistory)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
describe('NavigationStore', () => {
|
|
9
|
+
it('current() returns null room/shard before any navigation', () => {
|
|
10
|
+
const store = makeStore()
|
|
11
|
+
const state = store.current()
|
|
12
|
+
expect(state.room).toBeNull()
|
|
13
|
+
expect(state.shard).toBeNull()
|
|
14
|
+
expect(state.index).toBe(-1)
|
|
15
|
+
expect(state.history).toHaveLength(0)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('navigateTo() appends to history and emits navigation:change', () => {
|
|
19
|
+
const store = makeStore()
|
|
20
|
+
const events: Array<{ room: string | null; shard: string | null }> = []
|
|
21
|
+
store.on('navigation:change', e => events.push(e))
|
|
22
|
+
|
|
23
|
+
store.navigateTo('W7N7', 'shard0')
|
|
24
|
+
expect(events).toHaveLength(1)
|
|
25
|
+
expect(events[0].room).toBe('W7N7')
|
|
26
|
+
expect(events[0].shard).toBe('shard0')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('navigateTo() updates current() and history', () => {
|
|
30
|
+
const store = makeStore()
|
|
31
|
+
store.navigateTo('W1N1', null)
|
|
32
|
+
store.navigateTo('W7N7', 'shard0')
|
|
33
|
+
|
|
34
|
+
const state = store.current()
|
|
35
|
+
expect(state.room).toBe('W7N7')
|
|
36
|
+
expect(state.shard).toBe('shard0')
|
|
37
|
+
expect(state.index).toBe(1)
|
|
38
|
+
expect(state.history).toHaveLength(2)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('canBack() is false before any navigation', () => {
|
|
42
|
+
const store = makeStore()
|
|
43
|
+
expect(store.canBack()).toBe(false)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('canBack() is false with only one entry', () => {
|
|
47
|
+
const store = makeStore()
|
|
48
|
+
store.navigateTo('W1N1', null)
|
|
49
|
+
expect(store.canBack()).toBe(false)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('canBack() is true after two navigations', () => {
|
|
53
|
+
const store = makeStore()
|
|
54
|
+
store.navigateTo('W1N1', null)
|
|
55
|
+
store.navigateTo('W7N7', null)
|
|
56
|
+
expect(store.canBack()).toBe(true)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('back() returns false and does nothing when at start', () => {
|
|
60
|
+
const store = makeStore()
|
|
61
|
+
store.navigateTo('W1N1', null)
|
|
62
|
+
const result = store.back()
|
|
63
|
+
expect(result).toBe(false)
|
|
64
|
+
expect(store.current().room).toBe('W1N1')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('back() moves to previous entry and emits', () => {
|
|
68
|
+
const store = makeStore()
|
|
69
|
+
store.navigateTo('W1N1', null)
|
|
70
|
+
store.navigateTo('W7N7', null)
|
|
71
|
+
|
|
72
|
+
const events: string[] = []
|
|
73
|
+
store.on('navigation:change', e => events.push(e.room ?? ''))
|
|
74
|
+
|
|
75
|
+
store.back()
|
|
76
|
+
expect(store.current().room).toBe('W1N1')
|
|
77
|
+
expect(events).toEqual(['W1N1'])
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('canForward() is false when at the end of history', () => {
|
|
81
|
+
const store = makeStore()
|
|
82
|
+
store.navigateTo('W1N1', null)
|
|
83
|
+
store.navigateTo('W7N7', null)
|
|
84
|
+
expect(store.canForward()).toBe(false)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('canForward() is true after going back', () => {
|
|
88
|
+
const store = makeStore()
|
|
89
|
+
store.navigateTo('W1N1', null)
|
|
90
|
+
store.navigateTo('W7N7', null)
|
|
91
|
+
store.back()
|
|
92
|
+
expect(store.canForward()).toBe(true)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('forward() moves to next entry and emits', () => {
|
|
96
|
+
const store = makeStore()
|
|
97
|
+
store.navigateTo('W1N1', null)
|
|
98
|
+
store.navigateTo('W7N7', null)
|
|
99
|
+
store.back()
|
|
100
|
+
|
|
101
|
+
const events: string[] = []
|
|
102
|
+
store.on('navigation:change', e => events.push(e.room ?? ''))
|
|
103
|
+
|
|
104
|
+
store.forward()
|
|
105
|
+
expect(store.current().room).toBe('W7N7')
|
|
106
|
+
expect(events).toEqual(['W7N7'])
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('forward() returns false and does nothing when at end', () => {
|
|
110
|
+
const store = makeStore()
|
|
111
|
+
store.navigateTo('W1N1', null)
|
|
112
|
+
const result = store.forward()
|
|
113
|
+
expect(result).toBe(false)
|
|
114
|
+
expect(store.current().room).toBe('W1N1')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('navigateTo() after back() truncates forward entries', () => {
|
|
118
|
+
const store = makeStore()
|
|
119
|
+
store.navigateTo('W1N1', null)
|
|
120
|
+
store.navigateTo('W7N7', null)
|
|
121
|
+
store.navigateTo('W8N8', null)
|
|
122
|
+
store.back()
|
|
123
|
+
store.back()
|
|
124
|
+
// now at index 0, can forward to W7N7 and W8N8
|
|
125
|
+
expect(store.canForward()).toBe(true)
|
|
126
|
+
|
|
127
|
+
store.navigateTo('W2N2', null) // truncates W7N7 and W8N8
|
|
128
|
+
expect(store.canForward()).toBe(false)
|
|
129
|
+
expect(store.current().room).toBe('W2N2')
|
|
130
|
+
expect(store.current().history).toHaveLength(2) // W1N1, W2N2
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('history is bounded to maxHistory entries', () => {
|
|
134
|
+
const store = makeStore(3)
|
|
135
|
+
store.navigateTo('W1N1', null)
|
|
136
|
+
store.navigateTo('W2N2', null)
|
|
137
|
+
store.navigateTo('W3N3', null)
|
|
138
|
+
store.navigateTo('W4N4', null) // evicts W1N1
|
|
139
|
+
|
|
140
|
+
const state = store.current()
|
|
141
|
+
expect(state.history).toHaveLength(3)
|
|
142
|
+
expect(state.history[0].room).toBe('W2N2')
|
|
143
|
+
expect(state.room).toBe('W4N4')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('back() still works after history bound is reached', () => {
|
|
147
|
+
const store = makeStore(2)
|
|
148
|
+
store.navigateTo('W1N1', null)
|
|
149
|
+
store.navigateTo('W2N2', null)
|
|
150
|
+
store.navigateTo('W3N3', null) // evicts W1N1, history=[W2N2, W3N3]
|
|
151
|
+
|
|
152
|
+
expect(store.canBack()).toBe(true)
|
|
153
|
+
store.back()
|
|
154
|
+
expect(store.current().room).toBe('W2N2')
|
|
155
|
+
expect(store.canBack()).toBe(false)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('current() returns a snapshot — modifying it does not affect store', () => {
|
|
159
|
+
const store = makeStore()
|
|
160
|
+
store.navigateTo('W1N1', null)
|
|
161
|
+
const state = store.current()
|
|
162
|
+
state.history.push({ room: 'W9N9', shard: null })
|
|
163
|
+
|
|
164
|
+
expect(store.current().history).toHaveLength(1)
|
|
165
|
+
})
|
|
166
|
+
})
|