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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "screeps-connectivity",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "repository": {
@@ -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
  }
@@ -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] = { ...current[id], ...obj } as RoomObject
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 = () => {}