screeps-connectivity 0.5.2 → 0.7.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,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 69d132d: Add an account Overview page (GCL/GPL rings, lifetime stats, per-room minimap previews) and a public Profile page with routing between them; optional dashboard endpoints (`user/overview`, `user/rooms`) now fail silently on servers that don't implement them.
8
+
9
+ ## 0.6.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 71ce50f: Show reservation vs. owner, RCL, and the controller sign in the world-map tooltip.
14
+ - 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.
15
+
16
+ `screeps-connectivity`: `UserInfo` gains an optional `password?: boolean` field, surfaced from `/api/auth/me`, indicating whether the account has a password set.
17
+
18
+ ### Patch Changes
19
+
20
+ - 270fabf: Deep-merge room-object diffs so structure stores keep non-energy resources across ticks.
21
+
3
22
  ## 0.5.2
4
23
 
5
24
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "screeps-connectivity",
3
- "version": "0.5.2",
3
+ "version": "0.7.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "repository": {
@@ -16,6 +16,14 @@ export interface RateLimitInfo {
16
16
  reset: number
17
17
  }
18
18
 
19
+ export interface RequestOptions {
20
+ /** Internal: marks the post-auth retry so a second 401 doesn't loop. */
21
+ isRetry?: boolean
22
+ /** Suppress user-facing surfacing of failures (the http:error event carries the
23
+ * flag through). Use for optional endpoints the caller handles gracefully. */
24
+ silent?: boolean
25
+ }
26
+
19
27
  export class HttpClient extends EventTarget {
20
28
  readonly baseUrl: string
21
29
  private readonly authStrategy: AuthStrategy
@@ -79,7 +87,7 @@ export class HttpClient extends EventTarget {
79
87
  this.token = token
80
88
  }
81
89
 
82
- async request<T>(method: string, path: string, body?: Record<string, unknown>, isRetry = false): Promise<T> {
90
+ async request<T>(method: string, path: string, body?: Record<string, unknown>, opts: RequestOptions = {}): Promise<T> {
83
91
  this.logger.log(method, path)
84
92
  const url = new URL(path.startsWith('/') ? path.slice(1) : path, this.baseUrl)
85
93
  const headers: Record<string, string> = {}
@@ -115,9 +123,9 @@ export class HttpClient extends EventTarget {
115
123
 
116
124
  this.updateRateLimit(path, res)
117
125
 
118
- if (res.status === 401 && !isRetry && !this.authenticating) {
126
+ if (res.status === 401 && !opts.isRetry && !this.authenticating) {
119
127
  await this.authenticate()
120
- return this.request<T>(method, path, body, true)
128
+ return this.request<T>(method, path, body, { ...opts, isRetry: true })
121
129
  }
122
130
 
123
131
  // 304: some servers (e.g. private Screeps) send a body with 304 — treat it as success
@@ -125,7 +133,7 @@ export class HttpClient extends EventTarget {
125
133
  let body = ''
126
134
  try { body = await res.text() } catch { /* ignore */ }
127
135
  const error = new Error(`HTTP ${res.status}: ${body}`)
128
- this.emit('http:error', { method, path, status: res.status, error })
136
+ this.emit('http:error', { method, path, status: res.status, error, silent: opts.silent })
129
137
  throw error
130
138
  }
131
139
 
@@ -135,7 +143,7 @@ export class HttpClient extends EventTarget {
135
143
 
136
144
  if (typeof data['error'] === 'string') {
137
145
  const error = new Error(`Screeps API error: ${data['error']}`)
138
- this.emit('http:error', { method, path, status: res.status, error })
146
+ this.emit('http:error', { method, path, status: res.status, error, silent: opts.silent })
139
147
  throw error
140
148
  }
141
149
 
@@ -3,6 +3,8 @@ import type {
3
3
  ApiUserBranchesResponse,
4
4
  ApiUserFindResponse,
5
5
  ApiUserMoneyHistoryResponse,
6
+ ApiUserOverviewResponse,
7
+ ApiUserRoomsResponse,
6
8
  } from '../../types/api.js'
7
9
  import { createUserMessagesEndpoints, type UserMessagesEndpoints } from './user-messages.js'
8
10
 
@@ -30,8 +32,8 @@ export interface UserEndpoints {
30
32
  }
31
33
  console(expression: string, shard?: string | null): Promise<unknown>
32
34
  stats(interval: number): Promise<unknown>
33
- rooms(id: string): Promise<unknown>
34
- overview(interval: number, statName: string): Promise<unknown>
35
+ rooms(id: string): Promise<ApiUserRoomsResponse>
36
+ overview(interval: number, statName: string): Promise<ApiUserOverviewResponse>
35
37
  worldStatus(): Promise<{ ok: number; status: 'normal' | 'lost' | 'empty' }>
36
38
  worldStartRoom(shard?: string | null): Promise<unknown>
37
39
  find(query: { username: string } | { id: string }): Promise<ApiUserFindResponse>
@@ -73,8 +75,11 @@ export function createUserEndpoints(http: HttpClient): UserEndpoints {
73
75
  },
74
76
  console: (expression, shard) => http.request('POST', '/api/user/console', withShard({ expression }, shard)),
75
77
  stats: (interval) => http.request('GET', '/api/user/stats', { interval }),
76
- rooms: (id) => http.request('GET', '/api/user/rooms', { id }),
77
- overview: (interval, statName) => http.request('GET', '/api/user/overview', { interval, statName }),
78
+ // Best-effort dashboard data: not every server implements these, and the
79
+ // Overview page degrades gracefully (zeros / no tiles), so a failure here
80
+ // shouldn't raise a user-facing error toast — mark them silent.
81
+ rooms: (id) => http.request('GET', '/api/user/rooms', { id }, { silent: true }),
82
+ overview: (interval, statName) => http.request('GET', '/api/user/overview', { interval, statName }, { silent: true }),
78
83
  worldStatus: () => http.request('GET', '/api/user/world-status'),
79
84
  worldStartRoom: (shard) => http.request('GET', '/api/user/world-start-room', withShard({}, shard)),
80
85
  find: (query) => http.request('GET', '/api/user/find', query as Record<string, unknown>),
package/src/index.ts CHANGED
@@ -40,7 +40,7 @@ export type {
40
40
 
41
41
  export { fetchServerVersion, fetchAuthModInfo, checkUsername, checkEmail, registerUser, getServerFeature, getScreepsmodAuth } from './http/fetchServerVersion.js'
42
42
  export type { ApiAuthModInfoResponse } from './http/fetchServerVersion.js'
43
- export type { RoomHistoryChunk, ApiRoomDecorationsResponse, ApiRoomDecorationItem, ApiRoomDecorationDef, ApiRoomDecorationGraphic, ApiRoomDecorationActive } from './types/api.js'
43
+ export type { RoomHistoryChunk, ApiRoomDecorationsResponse, ApiRoomDecorationItem, ApiRoomDecorationDef, ApiRoomDecorationGraphic, ApiRoomDecorationActive, ApiUserOverviewResponse, ApiUserOverviewTotals, ApiUserRoomsResponse } from './types/api.js'
44
44
  export { ROOM_DECORATIONS_MOCK } from './mocks/roomDecorations.js'
45
45
 
46
46
  export { badgeToSvg } from './badge/index.js'
@@ -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
@@ -21,11 +21,38 @@ export interface ApiAuthMeResponse {
21
21
  username: string
22
22
  cpu: number
23
23
  gcl: number
24
+ /** Raw accumulated power points; Global Power Level derives from this. Absent on servers without the power system. */
25
+ power?: number
24
26
  credits: number
25
27
  badge: import('./game.js').Badge
26
28
  password: boolean
27
29
  }
28
30
 
31
+ /** Per-stat lifetime totals over the requested interval — the values behind the Overview stat tiles. Individual fields may be absent on servers that don't track that stat. */
32
+ export interface ApiUserOverviewTotals {
33
+ energyControl?: number
34
+ energyHarvested?: number
35
+ energyConstruction?: number
36
+ energyCreeps?: number
37
+ creepsProduced?: number
38
+ creepsLost?: number
39
+ powerProcessed?: number
40
+ }
41
+
42
+ /** Response of GET /api/user/overview. Only `totals` is consumed by the Overview tiles; per-room time series (shards/stats) are omitted until the room band is built. */
43
+ export interface ApiUserOverviewResponse {
44
+ ok: number
45
+ totals?: ApiUserOverviewTotals
46
+ statsMax?: number
47
+ }
48
+
49
+ /** Response of GET /api/user/rooms?id=<userId> — the rooms a user owns. Multishard servers key by shard; single-shard servers may return a flat list. */
50
+ export interface ApiUserRoomsResponse {
51
+ ok: number
52
+ shards?: Record<string, string[]>
53
+ rooms?: string[]
54
+ }
55
+
29
56
  export interface ApiAuthQueryTokenResponse {
30
57
  ok: number
31
58
  token: { full: boolean }
@@ -121,6 +148,7 @@ export interface ApiMapStatsRoomStat {
121
148
  respawnArea: number | null
122
149
  openTime: number | null
123
150
  own?: { user: string; level: number }
151
+ sign?: { user: string; text: string; time: number; datetime: number }
124
152
  safeMode?: boolean
125
153
  [mineral: `minerals${number}`]: { type: string; density: number } | undefined
126
154
  }
@@ -68,6 +68,9 @@ export interface HttpClientEvents {
68
68
  path: string
69
69
  status: number
70
70
  error: Error
71
+ // Set when the request opted out of user-facing error surfacing (e.g. an
72
+ // optional endpoint whose failure the caller handles gracefully).
73
+ silent?: boolean
71
74
  }
72
75
  'http:tokenRefresh': {
73
76
  token: string
package/src/types/game.ts CHANGED
@@ -66,8 +66,12 @@ export interface UserInfo {
66
66
  email: string
67
67
  cpu: number
68
68
  gcl: number
69
+ /** Raw accumulated power points; Global Power Level derives from this. Absent on servers without the power system. */
70
+ power?: number
69
71
  credits: number
70
72
  badge: Badge
73
+ /** 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. */
74
+ password?: boolean
71
75
  }
72
76
 
73
77
  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 = () => {}