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 +19 -0
- package/package.json +1 -1
- package/src/http/HttpClient.ts +13 -5
- package/src/http/endpoints/user.ts +9 -4
- package/src/index.ts +1 -1
- package/src/stores/MapStatsStore.ts +15 -0
- package/src/stores/RoomStore.ts +28 -1
- package/src/types/api.ts +28 -0
- package/src/types/events.ts +3 -0
- package/src/types/game.ts +4 -0
- package/tests/stores/MapStatsStore.test.ts +96 -0
- package/tests/stores/RoomStore.test.ts +97 -0
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
package/src/http/HttpClient.ts
CHANGED
|
@@ -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>,
|
|
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<
|
|
34
|
-
overview(interval: number, statName: string): Promise<
|
|
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
|
-
|
|
77
|
-
|
|
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
|
}
|
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
|
@@ -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
|
}
|
package/src/types/events.ts
CHANGED
|
@@ -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 = () => {}
|