screeps-connectivity 0.5.1 → 0.6.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 +23 -0
- package/package.json +1 -1
- package/src/stores/MapStatsStore.ts +15 -0
- package/src/stores/RoomStore.ts +28 -1
- package/src/types/api.ts +1 -0
- package/src/types/game.ts +3 -0
- package/tests/stores/MapStatsStore.test.ts +96 -0
- package/tests/stores/RoomStore.test.ts +97 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 71ce50f: Show reservation vs. owner, RCL, and the controller sign in the world-map tooltip.
|
|
8
|
+
- f525f2b: Replace the top-right logout button with a username chip (badge + name) that opens an account dropdown. The dropdown holds Settings, Respawn (with a destructive confirmation dialog), Change/Set password, and Logout. Password management works for email/password and Steam sessions — Steam-only accounts without a password get a "Set password" flow — while pasted API-token and guest sessions hide it. Settings now opens from the dropdown (guests keep the header gear); the panel's existing close button is the only toggle. Trimmed the Settings panel of options already available directly in the room/map views (creep labels, map view options) and removed the "Verbose creep details" toggle — the body-part breakdown is now always shown.
|
|
9
|
+
|
|
10
|
+
`screeps-connectivity`: `UserInfo` gains an optional `password?: boolean` field, surfaced from `/api/auth/me`, indicating whether the account has a password set.
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- 270fabf: Deep-merge room-object diffs so structure stores keep non-energy resources across ticks.
|
|
15
|
+
|
|
16
|
+
## 0.5.2
|
|
17
|
+
|
|
18
|
+
### Patch Changes
|
|
19
|
+
|
|
20
|
+
- 9523f3c: Make highway resources easier to spot on the world map. Power banks are now
|
|
21
|
+
drawn as larger bright-red dots (radius 1.5 → 2.5) instead of small orange ones,
|
|
22
|
+
and deposits — previously rendered as tiny muted-red "foreign" dots because
|
|
23
|
+
their `d` map2 key fell through to the generic user-object path — now show as
|
|
24
|
+
prominent white dots. The deposit key is documented on `RoomMap2Data`.
|
|
25
|
+
|
|
3
26
|
## 0.5.1
|
|
4
27
|
|
|
5
28
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -11,6 +11,11 @@ export interface MapStatsRoomData {
|
|
|
11
11
|
safeMode?: boolean
|
|
12
12
|
badge?: ApiMapStatsBadge
|
|
13
13
|
status?: string
|
|
14
|
+
/**
|
|
15
|
+
* Controller sign. `user` is the raw signer id; `username`/`badge` are resolved from the
|
|
16
|
+
* response's user map and may be absent if the signer isn't included there.
|
|
17
|
+
*/
|
|
18
|
+
sign?: { user: string; text: string; datetime: number; username?: string; badge?: ApiMapStatsBadge }
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
export interface MapStatsStoreEvents {
|
|
@@ -104,6 +109,7 @@ export class MapStatsStore extends TypedStore<MapStatsStoreEvents> {
|
|
|
104
109
|
}
|
|
105
110
|
}
|
|
106
111
|
const ownerId = stat.own?.user
|
|
112
|
+
const signUserId = stat.sign?.user
|
|
107
113
|
return {
|
|
108
114
|
own: stat.own,
|
|
109
115
|
mineral,
|
|
@@ -112,6 +118,15 @@ export class MapStatsStore extends TypedStore<MapStatsStoreEvents> {
|
|
|
112
118
|
safeMode: stat.safeMode,
|
|
113
119
|
badge: ownerId ? userMap[ownerId]?.badge : undefined,
|
|
114
120
|
status: stat.status,
|
|
121
|
+
sign: stat.sign
|
|
122
|
+
? {
|
|
123
|
+
user: stat.sign.user,
|
|
124
|
+
text: stat.sign.text,
|
|
125
|
+
datetime: stat.sign.datetime,
|
|
126
|
+
username: signUserId ? userMap[signUserId]?.username : undefined,
|
|
127
|
+
badge: signUserId ? userMap[signUserId]?.badge : undefined,
|
|
128
|
+
}
|
|
129
|
+
: undefined,
|
|
115
130
|
}
|
|
116
131
|
}
|
|
117
132
|
}
|
package/src/stores/RoomStore.ts
CHANGED
|
@@ -8,6 +8,33 @@ import type { SocketClient } from '../socket/SocketClient.js'
|
|
|
8
8
|
import type { Cache } from '../cache/Cache.js'
|
|
9
9
|
import type { Subscription } from '../subscription/index.js'
|
|
10
10
|
|
|
11
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
12
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Apply a room-object diff onto the previous value following Screeps protocol semantics:
|
|
16
|
+
// the first message for an object carries full state, later ticks send only the fields that
|
|
17
|
+
// changed. Nested objects (`store`, `storeCapacityResource`, `effects`, …) are diffed
|
|
18
|
+
// recursively rather than replaced wholesale — a plain shallow spread would clobber the whole
|
|
19
|
+
// `store` with that tick's single changed resource (e.g. `{ energy }`), silently dropping every
|
|
20
|
+
// other resource until it next changes. A `null` leaf means the field was removed; arrays and
|
|
21
|
+
// primitives replace as-is. Returns fresh objects so previously returned snapshots are never
|
|
22
|
+
// mutated.
|
|
23
|
+
function mergeObjectDiff(prev: Record<string, unknown>, diff: Record<string, unknown>): Record<string, unknown> {
|
|
24
|
+
const out: Record<string, unknown> = { ...prev }
|
|
25
|
+
for (const key in diff) {
|
|
26
|
+
const next = diff[key]
|
|
27
|
+
if (next === null) {
|
|
28
|
+
delete out[key]
|
|
29
|
+
} else if (isPlainObject(next) && isPlainObject(out[key])) {
|
|
30
|
+
out[key] = mergeObjectDiff(out[key] as Record<string, unknown>, next)
|
|
31
|
+
} else {
|
|
32
|
+
out[key] = next
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out
|
|
36
|
+
}
|
|
37
|
+
|
|
11
38
|
export class RoomStore extends TypedStore<RoomStoreEvents> {
|
|
12
39
|
private readonly http: HttpClient
|
|
13
40
|
private readonly socket: SocketClient
|
|
@@ -190,7 +217,7 @@ export class RoomStore extends TypedStore<RoomStoreEvents> {
|
|
|
190
217
|
if (obj === null) {
|
|
191
218
|
delete current[id]
|
|
192
219
|
} else if (current[id]) {
|
|
193
|
-
current[id] =
|
|
220
|
+
current[id] = mergeObjectDiff(current[id] as Record<string, unknown>, obj as Record<string, unknown>) as RoomObject
|
|
194
221
|
} else {
|
|
195
222
|
current[id] = obj as RoomObject
|
|
196
223
|
}
|
package/src/types/api.ts
CHANGED
|
@@ -121,6 +121,7 @@ export interface ApiMapStatsRoomStat {
|
|
|
121
121
|
respawnArea: number | null
|
|
122
122
|
openTime: number | null
|
|
123
123
|
own?: { user: string; level: number }
|
|
124
|
+
sign?: { user: string; text: string; time: number; datetime: number }
|
|
124
125
|
safeMode?: boolean
|
|
125
126
|
[mineral: `minerals${number}`]: { type: string; density: number } | undefined
|
|
126
127
|
}
|
package/src/types/game.ts
CHANGED
|
@@ -7,6 +7,7 @@ export interface RoomMap2Data {
|
|
|
7
7
|
c?: [number, number][] | null // controllers
|
|
8
8
|
m?: [number, number][] | null // minerals
|
|
9
9
|
k?: [number, number][] | null // source keeper lairs
|
|
10
|
+
d?: [number, number][] | null // deposits (highway commodity resource)
|
|
10
11
|
[userId: string]: [number, number][] | null | undefined // structures + creeps for that user
|
|
11
12
|
}
|
|
12
13
|
|
|
@@ -67,6 +68,8 @@ export interface UserInfo {
|
|
|
67
68
|
gcl: number
|
|
68
69
|
credits: number
|
|
69
70
|
badge: Badge
|
|
71
|
+
/** True when the account has a password set. Absent for password-less accounts (e.g. Steam-only logins). Only present for the authenticated user's own info. */
|
|
72
|
+
password?: boolean
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
export interface CpuStats {
|
|
@@ -231,4 +231,100 @@ describe('MapStatsStore', () => {
|
|
|
231
231
|
const stat = events[0].stat as { safeMode?: boolean }
|
|
232
232
|
expect(stat.safeMode).toBeUndefined()
|
|
233
233
|
})
|
|
234
|
+
|
|
235
|
+
it('passes reservations through as own with level 0', async () => {
|
|
236
|
+
fetchMock.mockResolvedValue(mockResponse({
|
|
237
|
+
ok: 1,
|
|
238
|
+
stats: {
|
|
239
|
+
W1N1: { own: { user: 'u1', level: 0 } },
|
|
240
|
+
},
|
|
241
|
+
users: { u1: { _id: 'u1', username: 'Alice' } },
|
|
242
|
+
}))
|
|
243
|
+
|
|
244
|
+
const events: Array<{ room: string; stat: unknown }> = []
|
|
245
|
+
store.on('mapStats:room', (e) => events.push(e))
|
|
246
|
+
|
|
247
|
+
store.request(['W1N1'], 'owner0')
|
|
248
|
+
|
|
249
|
+
await new Promise(r => setTimeout(r, 50))
|
|
250
|
+
expect(events).toHaveLength(1)
|
|
251
|
+
const stat = events[0].stat as { own: { user: string; level: number }; username: string }
|
|
252
|
+
expect(stat.own).toEqual({ user: 'u1', level: 0 })
|
|
253
|
+
expect(stat.username).toBe('Alice')
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('resolves the controller sign with the signer username and badge', async () => {
|
|
257
|
+
fetchMock.mockResolvedValue(mockResponse({
|
|
258
|
+
ok: 1,
|
|
259
|
+
stats: {
|
|
260
|
+
W1N1: {
|
|
261
|
+
own: { user: 'u1', level: 5 },
|
|
262
|
+
sign: { user: 'u1', text: 'Territory of Alice', time: 100, datetime: 1700000000000 },
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
users: {
|
|
266
|
+
u1: { _id: 'u1', username: 'Alice', badge: { type: 7, color1: '#fff', color2: '#000', color3: '#f00', flip: false } },
|
|
267
|
+
},
|
|
268
|
+
}))
|
|
269
|
+
|
|
270
|
+
const events: Array<{ room: string; stat: unknown }> = []
|
|
271
|
+
store.on('mapStats:room', (e) => events.push(e))
|
|
272
|
+
|
|
273
|
+
store.request(['W1N1'], 'owner0')
|
|
274
|
+
|
|
275
|
+
await new Promise(r => setTimeout(r, 50))
|
|
276
|
+
expect(events).toHaveLength(1)
|
|
277
|
+
const stat = events[0].stat as { sign: { user: string; text: string; datetime: number; username: string; badge: { type: number } } }
|
|
278
|
+
expect(stat.sign.user).toBe('u1')
|
|
279
|
+
expect(stat.sign.text).toBe('Territory of Alice')
|
|
280
|
+
expect(stat.sign.datetime).toBe(1700000000000)
|
|
281
|
+
expect(stat.sign.username).toBe('Alice')
|
|
282
|
+
expect(stat.sign.badge.type).toBe(7)
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('keeps the raw signer id but omits username/badge when the signer is not in the user map', async () => {
|
|
286
|
+
fetchMock.mockResolvedValue(mockResponse({
|
|
287
|
+
ok: 1,
|
|
288
|
+
stats: {
|
|
289
|
+
W1N1: {
|
|
290
|
+
own: { user: 'u1', level: 4 },
|
|
291
|
+
sign: { user: 'ghost', text: 'Was here', time: 5, datetime: 1700000000000 },
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
users: { u1: { _id: 'u1', username: 'Alice' } },
|
|
295
|
+
}))
|
|
296
|
+
|
|
297
|
+
const events: Array<{ room: string; stat: unknown }> = []
|
|
298
|
+
store.on('mapStats:room', (e) => events.push(e))
|
|
299
|
+
|
|
300
|
+
store.request(['W1N1'], 'owner0')
|
|
301
|
+
|
|
302
|
+
await new Promise(r => setTimeout(r, 50))
|
|
303
|
+
expect(events).toHaveLength(1)
|
|
304
|
+
const stat = events[0].stat as { sign: { user: string; text: string; username?: string; badge?: unknown } }
|
|
305
|
+
expect(stat.sign.user).toBe('ghost')
|
|
306
|
+
expect(stat.sign.text).toBe('Was here')
|
|
307
|
+
expect(stat.sign.username).toBeUndefined()
|
|
308
|
+
expect(stat.sign.badge).toBeUndefined()
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('omits sign when not present in response', async () => {
|
|
312
|
+
fetchMock.mockResolvedValue(mockResponse({
|
|
313
|
+
ok: 1,
|
|
314
|
+
stats: {
|
|
315
|
+
W1N1: { own: { user: 'u1', level: 1 } },
|
|
316
|
+
},
|
|
317
|
+
users: {},
|
|
318
|
+
}))
|
|
319
|
+
|
|
320
|
+
const events: Array<{ room: string; stat: unknown }> = []
|
|
321
|
+
store.on('mapStats:room', (e) => events.push(e))
|
|
322
|
+
|
|
323
|
+
store.request(['W1N1'], 'owner0')
|
|
324
|
+
|
|
325
|
+
await new Promise(r => setTimeout(r, 50))
|
|
326
|
+
expect(events).toHaveLength(1)
|
|
327
|
+
const stat = events[0].stat as { sign?: unknown }
|
|
328
|
+
expect(stat.sign).toBeUndefined()
|
|
329
|
+
})
|
|
234
330
|
})
|
|
@@ -91,6 +91,103 @@ describe('RoomStore', () => {
|
|
|
91
91
|
expect(store.objects('W7N7', 'shard0')?.['id1']).toMatchObject({ x: 11, y: 11, type: 'creep' })
|
|
92
92
|
})
|
|
93
93
|
|
|
94
|
+
it('deep-merges nested store diffs instead of clobbering the whole store', async () => {
|
|
95
|
+
const { store, socket } = makeStore()
|
|
96
|
+
let messageHandler: (data: unknown) => void = () => {}
|
|
97
|
+
;(socket.on as ReturnType<typeof vi.fn>).mockImplementation((_ch: string, cb: (data: unknown) => void) => {
|
|
98
|
+
messageHandler = cb
|
|
99
|
+
return { dispose: vi.fn() }
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
store.subscribe('W7N7', 'shard0')
|
|
103
|
+
|
|
104
|
+
// Full state: a storage holding several resources.
|
|
105
|
+
messageHandler({
|
|
106
|
+
objects: { s1: { _id: 's1', type: 'storage', room: 'W7N7', x: 28, y: 26, store: { energy: 239076, power: 2000, H: 5000, L: 5000 } } },
|
|
107
|
+
gameTime: 1000,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// Diff tick: only energy changed within the store.
|
|
111
|
+
messageHandler({ objects: { s1: { store: { energy: 223706 } } }, gameTime: 1001 })
|
|
112
|
+
|
|
113
|
+
// Other resources must survive — a shallow merge would have dropped them.
|
|
114
|
+
expect((store.objects('W7N7', 'shard0')?.['s1'] as { store: Record<string, number> }).store)
|
|
115
|
+
.toEqual({ energy: 223706, power: 2000, H: 5000, L: 5000 })
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('removes a store resource when the diff sends a null leaf', async () => {
|
|
119
|
+
const { store, socket } = makeStore()
|
|
120
|
+
let messageHandler: (data: unknown) => void = () => {}
|
|
121
|
+
;(socket.on as ReturnType<typeof vi.fn>).mockImplementation((_ch: string, cb: (data: unknown) => void) => {
|
|
122
|
+
messageHandler = cb
|
|
123
|
+
return { dispose: vi.fn() }
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
store.subscribe('W7N7', 'shard0')
|
|
127
|
+
messageHandler({ objects: { s1: { _id: 's1', type: 'storage', room: 'W7N7', store: { energy: 100, H: 5000 } } }, gameTime: 1000 })
|
|
128
|
+
messageHandler({ objects: { s1: { store: { H: null } } }, gameTime: 1001 })
|
|
129
|
+
|
|
130
|
+
expect((store.objects('W7N7', 'shard0')?.['s1'] as { store: Record<string, number> }).store).toEqual({ energy: 100 })
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('replaces array fields wholesale rather than merging them', async () => {
|
|
134
|
+
const { store, socket } = makeStore()
|
|
135
|
+
let messageHandler: (data: unknown) => void = () => {}
|
|
136
|
+
;(socket.on as ReturnType<typeof vi.fn>).mockImplementation((_ch: string, cb: (data: unknown) => void) => {
|
|
137
|
+
messageHandler = cb
|
|
138
|
+
return { dispose: vi.fn() }
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
store.subscribe('W7N7', 'shard0')
|
|
142
|
+
messageHandler({ objects: { c1: { _id: 'c1', type: 'creep', room: 'W7N7', body: [{ type: 'work' }, { type: 'move' }] } }, gameTime: 1000 })
|
|
143
|
+
messageHandler({ objects: { c1: { body: [{ type: 'carry' }] } }, gameTime: 1001 })
|
|
144
|
+
|
|
145
|
+
expect((store.objects('W7N7', 'shard0')?.['c1'] as { body: unknown[] }).body).toEqual([{ type: 'carry' }])
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('does not mutate a previously returned snapshot when applying a diff', async () => {
|
|
149
|
+
const { store, socket } = makeStore()
|
|
150
|
+
let messageHandler: (data: unknown) => void = () => {}
|
|
151
|
+
;(socket.on as ReturnType<typeof vi.fn>).mockImplementation((_ch: string, cb: (data: unknown) => void) => {
|
|
152
|
+
messageHandler = cb
|
|
153
|
+
return { dispose: vi.fn() }
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
store.subscribe('W7N7', 'shard0')
|
|
157
|
+
messageHandler({ objects: { s1: { _id: 's1', type: 'storage', room: 'W7N7', store: { energy: 100, H: 5000 } } }, gameTime: 1000 })
|
|
158
|
+
const before = store.objects('W7N7', 'shard0')?.['s1'] as { store: Record<string, number> }
|
|
159
|
+
|
|
160
|
+
messageHandler({ objects: { s1: { store: { energy: 200 } } }, gameTime: 1001 })
|
|
161
|
+
|
|
162
|
+
// The snapshot captured before the diff must be unchanged.
|
|
163
|
+
expect(before.store).toEqual({ energy: 100, H: 5000 })
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
// actionLog is a transient per-tick field the client reads from merged state to drive
|
|
167
|
+
// action beams. The engine emits explicit `null` when an action stops, so deep-merge must
|
|
168
|
+
// (a) keep an unchanged action across ticks where the diff omits it (continuous beam) and
|
|
169
|
+
// (b) drop it on a null leaf (beam stops) — never accumulate a stale action.
|
|
170
|
+
it('keeps a continuing actionLog entry but clears it on an explicit null', async () => {
|
|
171
|
+
const { store, socket } = makeStore()
|
|
172
|
+
let messageHandler: (data: unknown) => void = () => {}
|
|
173
|
+
;(socket.on as ReturnType<typeof vi.fn>).mockImplementation((_ch: string, cb: (data: unknown) => void) => {
|
|
174
|
+
messageHandler = cb
|
|
175
|
+
return { dispose: vi.fn() }
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
store.subscribe('W7N7', 'shard0')
|
|
179
|
+
// Harvest begins.
|
|
180
|
+
messageHandler({ objects: { c1: { _id: 'c1', type: 'creep', room: 'W7N7', actionLog: { harvest: { x: 5, y: 6 } } } }, gameTime: 1000 })
|
|
181
|
+
// Next tick the source is unchanged, so the diff omits actionLog entirely — the beam must persist.
|
|
182
|
+
messageHandler({ objects: { c1: { fatigue: 0 } }, gameTime: 1001 })
|
|
183
|
+
expect((store.objects('W7N7', 'shard0')?.['c1'] as { actionLog: Record<string, unknown> }).actionLog)
|
|
184
|
+
.toEqual({ harvest: { x: 5, y: 6 } })
|
|
185
|
+
// Creep stops harvesting: the engine sends a null leaf — the entry (and beam) must clear.
|
|
186
|
+
messageHandler({ objects: { c1: { actionLog: { harvest: null } } }, gameTime: 1002 })
|
|
187
|
+
expect((store.objects('W7N7', 'shard0')?.['c1'] as { actionLog: Record<string, unknown> }).actionLog)
|
|
188
|
+
.toEqual({})
|
|
189
|
+
})
|
|
190
|
+
|
|
94
191
|
it('emits room:update event on WS message', async () => {
|
|
95
192
|
const { store, socket } = makeStore()
|
|
96
193
|
let messageHandler: (data: unknown) => void = () => {}
|