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