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,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
+ })