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,115 @@
1
+ import { TypedStore } from './TypedStore.js'
2
+ import type { Logger } from '../logger.js'
3
+ import type { HttpClient } from '../http/HttpClient.js'
4
+ import type { ApiMapStatsRoomStat, ApiMapStatsBadge } from '../types/api.js'
5
+
6
+ export interface MapStatsRoomData {
7
+ own?: { user: string; level: number }
8
+ mineral?: string
9
+ density?: number
10
+ username?: string
11
+ safeMode?: boolean
12
+ badge?: ApiMapStatsBadge
13
+ }
14
+
15
+ export interface MapStatsStoreEvents {
16
+ 'mapStats:room': { room: string; shard: string | null; stat: MapStatsRoomData }
17
+ }
18
+
19
+ interface PendingBatch {
20
+ rooms: Set<string>
21
+ statName: string
22
+ shard: string
23
+ }
24
+
25
+ export class MapStatsStore extends TypedStore<MapStatsStoreEvents> {
26
+ private readonly http: HttpClient
27
+ private readonly debounceMs: number
28
+ private readonly minIntervalMs: number
29
+ private pending = new Map<string, PendingBatch>()
30
+ private timer: ReturnType<typeof setTimeout> | null = null
31
+ private lastFlushTime = 0
32
+
33
+ constructor(http: HttpClient, debounceMs = 100, minIntervalMs = 500, logger?: Logger) {
34
+ super(logger)
35
+ this.http = http
36
+ this.debounceMs = debounceMs
37
+ this.minIntervalMs = minIntervalMs
38
+ }
39
+
40
+ /** Queue rooms for a batched mapStats fetch. No-op when rooms is empty. */
41
+ request(rooms: string[], statName: string, shard?: string): void {
42
+ if (rooms.length === 0) return
43
+
44
+ const key = JSON.stringify([statName, shard ?? 'shard0'])
45
+ let entry = this.pending.get(key)
46
+ if (!entry) {
47
+ entry = { rooms: new Set(), statName, shard: shard ?? 'shard0' }
48
+ this.pending.set(key, entry)
49
+ }
50
+ for (const room of rooms) entry.rooms.add(room)
51
+
52
+ if (this.timer) clearTimeout(this.timer)
53
+
54
+ const now = Date.now()
55
+ const timeSinceLastFlush = now - this.lastFlushTime
56
+ const delay = Math.max(this.debounceMs, this.minIntervalMs - timeSinceLastFlush)
57
+
58
+ this.timer = setTimeout(() => this.flush(), delay)
59
+ }
60
+
61
+ private async flush(): Promise<void> {
62
+ const toFlush = new Map(this.pending)
63
+ this.pending.clear()
64
+ this.timer = null
65
+ this.lastFlushTime = Date.now()
66
+
67
+ for (const [, batch] of toFlush) {
68
+ const allRooms = [...batch.rooms]
69
+ try {
70
+ const res = await this.http.request<{ ok: number; stats: Record<string, ApiMapStatsRoomStat>; users: Record<string, { _id: string; username: string; badge: ApiMapStatsBadge }> }>(
71
+ 'POST', '/api/game/map-stats', { rooms: allRooms, statName: batch.statName, shard: batch.shard }
72
+ )
73
+
74
+ const userMap = res.users ?? {}
75
+
76
+ for (const [room, stat] of Object.entries(res.stats)) {
77
+ const data = this.buildData(stat, userMap)
78
+ this.emit('mapStats:room', { room, shard: batch.shard === 'shard0' ? null : batch.shard, stat: data })
79
+ }
80
+
81
+ // Emit empty data for rooms that don't exist on server
82
+ for (const room of allRooms) {
83
+ if (!res.stats[room]) {
84
+ this.emit('mapStats:room', { room, shard: batch.shard === 'shard0' ? null : batch.shard, stat: {} })
85
+ }
86
+ }
87
+ } catch (err) {
88
+ this.logger.log('mapStats fetch failed:', err)
89
+ }
90
+ }
91
+ }
92
+
93
+ private buildData(stat: ApiMapStatsRoomStat, userMap: Record<string, { username: string; badge: ApiMapStatsBadge }>): MapStatsRoomData {
94
+ let mineral: string | undefined
95
+ let density: number | undefined
96
+ for (let i = 0; i < 3; i++) {
97
+ const mineralKey = `minerals${i}` as `minerals${number}`
98
+ const mineralData = stat[mineralKey]
99
+ if (mineralData) {
100
+ mineral = mineralData.type
101
+ density = mineralData.density
102
+ break
103
+ }
104
+ }
105
+ const ownerId = stat.own?.user
106
+ return {
107
+ own: stat.own,
108
+ mineral,
109
+ density,
110
+ username: ownerId ? userMap[ownerId]?.username : undefined,
111
+ safeMode: stat.safeMode,
112
+ badge: ownerId ? userMap[ownerId]?.badge : undefined,
113
+ }
114
+ }
115
+ }
@@ -0,0 +1,254 @@
1
+ import { TypedStore } from './TypedStore.js'
2
+ import { Map2Storage } from '../cache/Map2Storage.js'
3
+ import type { SocketClient } from '../socket/SocketClient.js'
4
+ import type { Subscription } from '../subscription/index.js'
5
+ import type { MapStoreEvents, Map2SubscriptionStatus } from '../types/events.js'
6
+ import type { RoomMap2Data } from '../types/game.js'
7
+ import type { Logger } from '../logger.js'
8
+
9
+ function canonicalize(data: RoomMap2Data): string {
10
+ const sortedKeys = Object.keys(data).sort()
11
+ const obj: Record<string, [number, number][] | null> = {}
12
+ for (const k of sortedKeys) {
13
+ const v = data[k]
14
+ obj[k] = v ? [...v].sort((a, b) => a[0] - b[0] || a[1] - b[1]) : null
15
+ }
16
+ return JSON.stringify(obj)
17
+ }
18
+
19
+ export interface Map2Subscription extends Subscription {
20
+ /** Current subscription status — updated in place when waitlist entry is promoted. */
21
+ readonly status: () => Map2SubscriptionStatus
22
+ /** Last data the library has for this room (memory only; synchronous). */
23
+ readonly cachedData: () => RoomMap2Data | null
24
+ /** Register a handler called whenever status changes. Returns a disposable. */
25
+ onStatusChange(handler: (status: Map2SubscriptionStatus) => void): Subscription
26
+ }
27
+
28
+ export interface MapStoreOptions {
29
+ /** Max simultaneous WebSocket roomMap2 subscriptions. Default 500. */
30
+ maxSubscriptions?: number
31
+ }
32
+
33
+ interface ActiveEntry {
34
+ room: string
35
+ shard: string | null
36
+ refCount: number
37
+ socketSub: Subscription
38
+ listenerSub: Subscription
39
+ /** Canonical hash of the last data we emitted/stored. null until first message or cache-warm init. */
40
+ lastHash: string | null
41
+ }
42
+
43
+ interface WaitlistEntry {
44
+ key: string
45
+ room: string
46
+ shard: string | null
47
+ refCount: number
48
+ }
49
+
50
+ interface KeyState {
51
+ status: Map2SubscriptionStatus
52
+ statusHandlers: Set<(status: Map2SubscriptionStatus) => void>
53
+ }
54
+
55
+ export class MapStore extends TypedStore<MapStoreEvents> {
56
+ private readonly socket: SocketClient
57
+ private readonly storage: Map2Storage
58
+ private readonly maxSubscriptions: number
59
+ private readonly active = new Map<string, ActiveEntry>()
60
+ private readonly waitlist: WaitlistEntry[] = []
61
+ private readonly keyStates = new Map<string, KeyState>()
62
+ private warnedAboutWaitlist = false
63
+
64
+ constructor(socket: SocketClient, storage: Map2Storage, opts: MapStoreOptions = {}, logger?: Logger) {
65
+ super(logger)
66
+ this.socket = socket
67
+ this.storage = storage
68
+ this.maxSubscriptions = opts.maxSubscriptions ?? 500
69
+ void this.socket.on('connected', () => this.onReconnect())
70
+ }
71
+
72
+ map2data(room: string, shard: string | null): RoomMap2Data | null {
73
+ return this.storage.getMemory(room, shard)
74
+ }
75
+
76
+ subscribeMap2(room: string, shard: string | null): Map2Subscription {
77
+ const mapKey = `${room}/${shard}`
78
+
79
+ // Case 1: already active — increment refCount and reuse the open WS sub
80
+ const activeEntry = this.active.get(mapKey)
81
+ if (activeEntry) {
82
+ activeEntry.refCount++
83
+ this.logger.log('subscribeMap2', room, shard, `(active, refs: ${activeEntry.refCount})`)
84
+ const keyState = this.getOrCreateKeyState(mapKey, 'active')
85
+ this.emitWarmStart(room, shard)
86
+ return this.makeSubscription(room, shard, mapKey, keyState)
87
+ }
88
+
89
+ // Case 2: already on waitlist — increment refCount
90
+ const waitEntry = this.waitlist.find(e => e.key === mapKey)
91
+ if (waitEntry) {
92
+ waitEntry.refCount++
93
+ this.logger.log('subscribeMap2', room, shard, `(pending, refs: ${waitEntry.refCount})`)
94
+ const keyState = this.getOrCreateKeyState(mapKey, 'pending')
95
+ this.emitWarmStart(room, shard)
96
+ return this.makeSubscription(room, shard, mapKey, keyState)
97
+ }
98
+
99
+ // Case 3: new subscription
100
+ if (this.active.size < this.maxSubscriptions) {
101
+ this.activateKey(room, shard, mapKey, 1)
102
+ this.logger.log('subscribeMap2', room, shard, '(new active)')
103
+ const keyState = this.getOrCreateKeyState(mapKey, 'active')
104
+ this.emit('room:map2state', { room, shard, status: 'active' })
105
+ this.emitWarmStart(room, shard)
106
+ return this.makeSubscription(room, shard, mapKey, keyState)
107
+ }
108
+
109
+ // Limit reached — enqueue on waitlist
110
+ this.waitlist.push({ key: mapKey, room, shard, refCount: 1 })
111
+ this.logger.log('subscribeMap2', room, shard, '(new pending)')
112
+ const keyState = this.getOrCreateKeyState(mapKey, 'pending')
113
+ this.emit('room:map2state', { room, shard, status: 'pending' })
114
+ this.emitWarmStart(room, shard)
115
+ if (!this.warnedAboutWaitlist) {
116
+ this.logger.log(
117
+ `subscription limit (${this.maxSubscriptions}) reached — `,
118
+ 'some rooms are on a waitlist and will be promoted as slots free up.'
119
+ )
120
+ this.warnedAboutWaitlist = true
121
+ }
122
+ return this.makeSubscription(room, shard, mapKey, keyState)
123
+ }
124
+
125
+ private activateKey(room: string, shard: string | null, mapKey: string, refCount: number): void {
126
+ const channel = shard ? `roomMap2:${shard}/${room}` : `roomMap2:${room}`
127
+ const socketSub = this.socket.subscribe(channel)
128
+ const cached = this.storage.getMemory(room, shard)
129
+ const entry: ActiveEntry = {
130
+ room,
131
+ shard,
132
+ refCount,
133
+ socketSub,
134
+ listenerSub: undefined as unknown as Subscription,
135
+ lastHash: cached ? canonicalize(cached) : null,
136
+ }
137
+ entry.listenerSub = this.socket.on(channel, (data) => {
138
+ const next = data as RoomMap2Data
139
+ const nextHash = canonicalize(next)
140
+ if (entry.lastHash === nextHash) return
141
+ entry.lastHash = nextHash
142
+ void this.storage.put(room, shard, next)
143
+ this.emit('room:map2update', { room, shard, data: next, source: 'live' })
144
+ })
145
+ this.active.set(mapKey, entry)
146
+ }
147
+
148
+ private promoteNext(): void {
149
+ const next = this.waitlist.shift()
150
+ if (!next) return
151
+
152
+ this.activateKey(next.room, next.shard, next.key, next.refCount)
153
+ this.logger.log('promoteNext', next.room, next.shard, '(promoted from waitlist)')
154
+
155
+ const keyState = this.keyStates.get(next.key)
156
+ if (keyState) {
157
+ keyState.status = 'active'
158
+ keyState.statusHandlers.forEach(h => h('active'))
159
+ }
160
+
161
+ this.emit('room:map2state', { room: next.room, shard: next.shard, status: 'active' })
162
+ }
163
+
164
+ private getOrCreateKeyState(mapKey: string, status: Map2SubscriptionStatus): KeyState {
165
+ let state = this.keyStates.get(mapKey)
166
+ if (!state) {
167
+ state = { status, statusHandlers: new Set() }
168
+ this.keyStates.set(mapKey, state)
169
+ }
170
+ return state
171
+ }
172
+
173
+ private makeSubscription(room: string, shard: string | null, mapKey: string, keyState: KeyState): Map2Subscription {
174
+ let disposed = false
175
+ return {
176
+ status: () => keyState.status,
177
+ cachedData: () => this.storage.getMemory(room, shard),
178
+ onStatusChange: (handler) => {
179
+ keyState.statusHandlers.add(handler)
180
+ return { dispose: () => { keyState.statusHandlers.delete(handler) } }
181
+ },
182
+ dispose: () => {
183
+ if (disposed) return
184
+ disposed = true
185
+ this.disposeSubscription(room, shard, mapKey)
186
+ },
187
+ }
188
+ }
189
+
190
+ private disposeSubscription(room: string, shard: string | null, mapKey: string): void {
191
+ // Check waitlist first — no WS sub to close, no promotion needed
192
+ const waitIdx = this.waitlist.findIndex(e => e.key === mapKey)
193
+ if (waitIdx >= 0) {
194
+ const wait = this.waitlist[waitIdx]
195
+ wait.refCount--
196
+ this.logger.log('unsubscribeMap2', room, shard, `(pending, refs: ${wait.refCount})`)
197
+ if (wait.refCount <= 0) {
198
+ this.waitlist.splice(waitIdx, 1)
199
+ this.keyStates.delete(mapKey)
200
+ this.logger.log('unsubscribeMap2', room, shard, '(removed from waitlist)')
201
+ }
202
+ return
203
+ }
204
+
205
+ const active = this.active.get(mapKey)
206
+ if (!active) return
207
+
208
+ active.refCount--
209
+ this.logger.log('unsubscribeMap2', room, shard, `(active, refs: ${active.refCount})`)
210
+ if (active.refCount <= 0) {
211
+ active.socketSub.dispose()
212
+ active.listenerSub.dispose()
213
+ this.active.delete(mapKey)
214
+ this.keyStates.delete(mapKey)
215
+ this.logger.log('unsubscribeMap2', room, shard, '(deactivated, promoting next)')
216
+ this.promoteNext()
217
+ }
218
+ }
219
+
220
+ private emitWarmStart(room: string, shard: string | null): void {
221
+ const mapKey = `${room}/${shard}`
222
+ const cached = this.storage.getMemory(room, shard)
223
+ if (cached) {
224
+ queueMicrotask(() => {
225
+ if (this.isSubscribed(mapKey)) {
226
+ this.emit('room:map2update', { room, shard, data: cached, source: 'cache' })
227
+ }
228
+ })
229
+ } else {
230
+ void this.storage.get(room, shard).then(data => {
231
+ if (data && this.isSubscribed(mapKey)) {
232
+ this.emit('room:map2update', { room, shard, data, source: 'cache' })
233
+ }
234
+ })
235
+ }
236
+ }
237
+
238
+ private onReconnect(): void {
239
+ this.logger.log('onReconnect —', this.active.size, 'active,', this.waitlist.length, 'pending')
240
+ for (const entry of this.active.values()) {
241
+ // After a resubscribe, the server resends initial state. Clear the dedup hash so the first
242
+ // message after reconnect always fires an emit — even if the payload happens to match.
243
+ entry.lastHash = null
244
+ this.emit('room:map2state', { room: entry.room, shard: entry.shard, status: 'active' })
245
+ }
246
+ for (const entry of this.waitlist) {
247
+ this.emit('room:map2state', { room: entry.room, shard: entry.shard, status: 'pending' })
248
+ }
249
+ }
250
+
251
+ private isSubscribed(mapKey: string): boolean {
252
+ return this.active.has(mapKey) || this.waitlist.some(e => e.key === mapKey)
253
+ }
254
+ }
@@ -0,0 +1,61 @@
1
+ import { TypedStore } from './TypedStore.js'
2
+ import type { Logger } from '../logger.js'
3
+
4
+ export interface NavigationState {
5
+ room: string | null
6
+ shard: string | null
7
+ index: number
8
+ history: Array<{ room: string; shard: string | null }>
9
+ }
10
+
11
+ export interface NavigationStoreEvents {
12
+ 'navigation:change': NavigationState
13
+ }
14
+
15
+ export class NavigationStore extends TypedStore<NavigationStoreEvents> {
16
+ private _history: Array<{ room: string; shard: string | null }> = []
17
+ private _index = -1
18
+ private readonly maxHistory: number
19
+
20
+ constructor(maxHistory = 50, logger?: Logger) {
21
+ super(logger)
22
+ this.maxHistory = maxHistory
23
+ }
24
+
25
+ navigateTo(room: string, shard: string | null): void {
26
+ this._history = this._history.slice(0, this._index + 1)
27
+ this._history.push({ room, shard })
28
+ if (this._history.length > this.maxHistory) {
29
+ this._history.shift()
30
+ } else {
31
+ this._index++
32
+ }
33
+ this.emit('navigation:change', this.current())
34
+ }
35
+
36
+ back(): boolean {
37
+ if (!this.canBack()) return false
38
+ this._index--
39
+ this.emit('navigation:change', this.current())
40
+ return true
41
+ }
42
+
43
+ forward(): boolean {
44
+ if (!this.canForward()) return false
45
+ this._index++
46
+ this.emit('navigation:change', this.current())
47
+ return true
48
+ }
49
+
50
+ canBack(): boolean { return this._index > 0 }
51
+ canForward(): boolean { return this._index < this._history.length - 1 }
52
+
53
+ current(): NavigationState {
54
+ return {
55
+ room: this._history[this._index]?.room ?? null,
56
+ shard: this._history[this._index]?.shard ?? null,
57
+ index: this._index,
58
+ history: [...this._history],
59
+ }
60
+ }
61
+ }
@@ -0,0 +1,264 @@
1
+ import { TypedStore } from './TypedStore.js'
2
+ import { RoomTerrain } from '../types/game.js'
3
+ import type { Logger } from '../logger.js'
4
+ import type { RoomStoreEvents } from '../types/events.js'
5
+ import type { RoomObject, RoomObjectMap, RoomObjectDiff } from '../types/game.js'
6
+ import type { HttpClient } from '../http/HttpClient.js'
7
+ import type { SocketClient } from '../socket/SocketClient.js'
8
+ import type { Cache } from '../cache/Cache.js'
9
+ import type { Subscription } from '../subscription/index.js'
10
+
11
+ export class RoomStore extends TypedStore<RoomStoreEvents> {
12
+ private readonly http: HttpClient
13
+ private readonly socket: SocketClient
14
+ private readonly cache: Cache
15
+ private readonly roomObjects = new Map<string, RoomObjectMap>()
16
+ private readonly roomUsers = new Map<string, Record<string, { _id: string; username: string }>>()
17
+ private readonly roomSubCount = new Map<string, number>()
18
+ private readonly lastFlagsString = new Map<string, string>()
19
+
20
+ private parseFlagsString(flagsStr: string | undefined, roomName: string): RoomObject[] {
21
+ if (!flagsStr || flagsStr.length === 0) return []
22
+ const result: RoomObject[] = []
23
+ const entries = flagsStr.split('|')
24
+ for (const entry of entries) {
25
+ const parts = entry.split('~')
26
+ if (parts.length !== 5) continue
27
+ const [name, colorStr, secColorStr, xStr, yStr] = parts
28
+ const color = parseInt(colorStr, 10)
29
+ const secondaryColor = parseInt(secColorStr, 10)
30
+ const x = parseInt(xStr, 10)
31
+ const y = parseInt(yStr, 10)
32
+ if (isNaN(color) || isNaN(secondaryColor) || isNaN(x) || isNaN(y)) continue
33
+ result.push({
34
+ _id: name,
35
+ type: 'flag',
36
+ room: roomName,
37
+ name,
38
+ x,
39
+ y,
40
+ color,
41
+ secondaryColor,
42
+ })
43
+ }
44
+ return result
45
+ }
46
+
47
+ constructor(http: HttpClient, socket: SocketClient, cache: Cache, logger?: Logger) {
48
+ super(logger)
49
+ this.http = http
50
+ this.socket = socket
51
+ this.cache = cache
52
+ }
53
+
54
+ async terrain(room: string, shard: string | null): Promise<RoomTerrain> {
55
+ const key = `terrain/${shard}/${room}`
56
+
57
+ const cached = this.cache.get<RoomTerrain>(key)
58
+ if (cached) {
59
+ this.logger.log('terrain', room, shard, '(memory cache hit)')
60
+ return cached
61
+ }
62
+
63
+ const persisted = await this.cache.getPersistent(key)
64
+ if (persisted) {
65
+ this.logger.log('terrain', room, shard, '(persistent cache hit)')
66
+ const terrain = new RoomTerrain(persisted)
67
+ this.cache.set(key, terrain)
68
+ return terrain
69
+ }
70
+
71
+ this.logger.log('terrain', room, shard, '(fetching)')
72
+ const res = await this.http.game.roomTerrain(room, shard ?? undefined)
73
+ const entry = res.terrain[0]
74
+ if (!entry) throw new Error(`No terrain data for room ${room} shard ${shard}`)
75
+ const terrain = RoomTerrain.fromEncodedString(entry.terrain)
76
+
77
+ this.cache.set(key, terrain)
78
+ await this.cache.setPersistent(key, terrain.raw)
79
+ this.emit('room:terrainavailable', { room, shard, terrain })
80
+
81
+ return terrain
82
+ }
83
+
84
+ async terrainBulk(rooms: string[], shard: string | null): Promise<Map<string, RoomTerrain>> {
85
+ const result = new Map<string, RoomTerrain>()
86
+ const needPersistentCheck: string[] = []
87
+
88
+ for (const room of rooms) {
89
+ const cached = this.cache.get<RoomTerrain>(`terrain/${shard}/${room}`)
90
+ if (cached) {
91
+ result.set(room, cached)
92
+ } else {
93
+ needPersistentCheck.push(room)
94
+ }
95
+ }
96
+
97
+ if (needPersistentCheck.length === 0) return result
98
+
99
+ const needFetch: string[] = []
100
+ await Promise.all(needPersistentCheck.map(async (room) => {
101
+ const key = `terrain/${shard}/${room}`
102
+ const persisted = await this.cache.getPersistent(key)
103
+ if (persisted) {
104
+ const terrain = new RoomTerrain(persisted)
105
+ this.cache.set(key, terrain)
106
+ result.set(room, terrain)
107
+ } else {
108
+ needFetch.push(room)
109
+ }
110
+ }))
111
+
112
+ if (needFetch.length === 0) return result
113
+
114
+ this.logger.log('terrainBulk', `fetching ${needFetch.length} rooms`, shard)
115
+ const res = await this.http.game.roomsTerrain(needFetch, shard ?? undefined)
116
+
117
+ await Promise.all(res.rooms.map(async (entry) => {
118
+ const terrain = RoomTerrain.fromEncodedString(entry.terrain)
119
+ const key = `terrain/${shard}/${entry.room}`
120
+ this.cache.set(key, terrain)
121
+ await this.cache.setPersistent(key, terrain.raw)
122
+ this.emit('room:terrainavailable', { room: entry.room, shard, terrain })
123
+ result.set(entry.room, terrain)
124
+ }))
125
+
126
+ return result
127
+ }
128
+
129
+ /**
130
+ * Snapshot of the room's current object map, or null if nothing has been
131
+ * received yet. Returns a shallow copy so callers cannot mutate the
132
+ * store's internal state. Each tick's update creates fresh object refs,
133
+ * so the shallow copy is sufficient.
134
+ */
135
+ objects(room: string, shard: string | null): RoomObjectMap | null {
136
+ const map = this.roomObjects.get(`${room}/${shard}`)
137
+ return map ? { ...map } : null
138
+ }
139
+
140
+ /** @deprecated Room objects are delivered via WebSocket on subscription. This endpoint is not supported by all servers and is not needed. */
141
+ async fetchObjects(room: string, shard: string | null): Promise<void> {
142
+ const mapKey = `${room}/${shard}`
143
+ const res = await this.http.game.roomObjects(room, shard ?? undefined)
144
+ const map: RoomObjectMap = {}
145
+ for (const obj of res.objects as RoomObject[]) {
146
+ if (obj && typeof obj === 'object' && '_id' in obj) {
147
+ map[(obj as RoomObject)._id] = obj as RoomObject
148
+ }
149
+ }
150
+ this.roomObjects.set(mapKey, map)
151
+ const users = this.roomUsers.get(mapKey)
152
+ this.emit('room:update', { room, shard, gameTime: undefined, objects: map, diff: map, visual: '', users })
153
+ }
154
+
155
+ private activeRooms(): string {
156
+ return Array.from(this.roomSubCount.entries())
157
+ .map(([key, count]) => `${key}(${count})`)
158
+ .join(', ') || '(none)'
159
+ }
160
+
161
+ subscribe(room: string, shard: string | null): Subscription {
162
+ const mapKey = `${room}/${shard}`
163
+ const count = this.roomSubCount.get(mapKey) ?? 0
164
+ this.roomSubCount.set(mapKey, count + 1)
165
+ this.logger.log('subscribe', room, shard, `(refs: ${count + 1})`, 'active:', this.activeRooms())
166
+
167
+ const channel = shard ? `room:${shard}/${room}` : `room:${room}`
168
+ const errChannel = shard ? `err@room:${shard}/${room}` : `err@room:${room}`
169
+ const socketSub = this.socket.subscribe(channel)
170
+
171
+ const errListenerSub = this.socket.on(errChannel, (data) => {
172
+ const msg = typeof data === 'string' ? data : JSON.stringify(data)
173
+ this.logger.log('room error', room, shard, msg)
174
+ this.emit('room:error', { room, shard, message: msg })
175
+ })
176
+
177
+ const listenerSub = this.socket.on(channel, (data) => {
178
+ const update = data as { objects: RoomObjectDiff; gameTime?: number; visual?: string; flags?: string; users?: Record<string, { _id: string; username: string }> }
179
+ const current: RoomObjectMap = { ...(this.roomObjects.get(mapKey) ?? {}) }
180
+
181
+ for (const [id, obj] of Object.entries(update.objects)) {
182
+ if (obj === null) {
183
+ delete current[id]
184
+ } else if (current[id]) {
185
+ current[id] = { ...current[id], ...obj } as RoomObject
186
+ } else {
187
+ current[id] = obj as RoomObject
188
+ }
189
+ }
190
+
191
+ // Build diff that includes flags so ObjectLayer can incremental-update them
192
+ const diff: RoomObjectDiff = { ...update.objects }
193
+
194
+ // Parse and inject flags from the dedicated flags field — only when the
195
+ // server-sent flag string actually changed. The flags field is included
196
+ // on every tick even when nothing about the flags changed, so without
197
+ // this guard every selected flag would get a fresh object each tick and
198
+ // re-trigger UI subscriptions for no reason.
199
+ if ('flags' in update) {
200
+ const newFlagsString = update.flags ?? ''
201
+ const prevFlagsString = this.lastFlagsString.get(mapKey) ?? ''
202
+
203
+ if (newFlagsString !== prevFlagsString) {
204
+ this.lastFlagsString.set(mapKey, newFlagsString)
205
+
206
+ const parsed = this.parseFlagsString(newFlagsString, room)
207
+ const newFlags = new Map<string, RoomObject>()
208
+ for (const f of parsed) newFlags.set(f.name as string, f)
209
+
210
+ // Emit removed flags as null in diff and drop them from current
211
+ for (const id in current) {
212
+ const obj = current[id]
213
+ if (obj?.type === 'flag' && !newFlags.has(id)) {
214
+ delete current[id]
215
+ diff[id] = null
216
+ this.logger.log(`[flag:room] removed ${id} @ ${room}`)
217
+ }
218
+ }
219
+
220
+ // Emit added/changed flags
221
+ for (const [id, flag] of newFlags) {
222
+ const prev = current[id]
223
+ const changed =
224
+ !prev ||
225
+ prev.x !== flag.x ||
226
+ prev.y !== flag.y ||
227
+ prev.color !== flag.color ||
228
+ prev.secondaryColor !== flag.secondaryColor
229
+ if (changed) {
230
+ current[id] = flag
231
+ diff[id] = flag
232
+ this.logger.log(`[flag:room] ${prev ? 'changed' : 'added'} ${id} @ ${room} (${flag.x},${flag.y})`)
233
+ }
234
+ }
235
+ }
236
+ }
237
+
238
+ if (update.users) {
239
+ this.roomUsers.set(mapKey, update.users)
240
+ }
241
+ this.roomObjects.set(mapKey, current)
242
+ const users = this.roomUsers.get(mapKey)
243
+ this.emit('room:update', { room, shard, gameTime: update.gameTime, objects: current, diff, visual: update.visual ?? '', users })
244
+ })
245
+
246
+ return {
247
+ dispose: () => {
248
+ socketSub.dispose()
249
+ listenerSub.dispose()
250
+ errListenerSub.dispose()
251
+ const remaining = (this.roomSubCount.get(mapKey) ?? 1) - 1
252
+ if (remaining <= 0) {
253
+ this.roomSubCount.delete(mapKey)
254
+ this.roomObjects.delete(mapKey)
255
+ this.lastFlagsString.delete(mapKey)
256
+ this.logger.log('unsubscribe', room, shard, '(last ref)', 'active:', this.activeRooms())
257
+ } else {
258
+ this.roomSubCount.set(mapKey, remaining)
259
+ this.logger.log('unsubscribe', room, shard, `(refs: ${remaining})`, 'active:', this.activeRooms())
260
+ }
261
+ },
262
+ }
263
+ }
264
+ }