screeps-connectivity 0.9.0 → 0.11.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,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - cc7f5be: Add `MapStatName`, `MapStatPrefix`, `MapStatInterval` const objects and `mapStat()` helper for typed map-stats API access; add `TerrainColors` interface and decoration fields to `MapStatsRoomData`; expose `MapStatsStoreEvents` and `statName` in room events.
8
+
9
+ Client: world map shard selector, "out of borders" black overlay, reveal-when-ready terrain/stats sync, mineral overlay on demand, room terrain decorations from player themes.
10
+
11
+ ## 0.10.0
12
+
13
+ ### Minor Changes
14
+
15
+ - 1539f52: Add NotifyPrefs type and notifyPrefs field on UserInfo for email notification preference support.
16
+ - de39cf0: Expose public-profile data through the API: `power` on the `user.find` response, an optional `id` argument on `user.stats`, and a typed `leaderboard.find` response.
17
+ - 4b8f9d9: Add `setFetch()` to `screeps-connectivity` so consumers can supply a custom fetch implementation (e.g. Tauri's HTTP plugin) without patching `window.fetch`. `screeps-client` uses this to enable CORS-free HTTP in the standalone Tauri desktop app without affecting the browser runtime.
18
+
19
+ ### Patch Changes
20
+
21
+ - 882bea5: Fix `TokenAuth` always using its static token — server-issued `X-Token` headers, WebSocket token rotation, and the idle keep-alive timer are now skipped when the auth strategy sets `supportsTokenRefresh: false`.
22
+
3
23
  ## 0.9.0
4
24
 
5
25
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "screeps-connectivity",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "repository": {
@@ -18,7 +18,7 @@ import type { ApiRoomDecorationsResponse } from './types/api.js'
18
18
  type WsConstructor = typeof globalThis.WebSocket
19
19
 
20
20
  export interface TokenRefreshOptions {
21
- /** Milliseconds of idleness before issuing a keep-alive request. Default 30_000. */
21
+ /** Interval in milliseconds between world-status polls. Default 30_000. */
22
22
  intervalMs?: number
23
23
  }
24
24
 
@@ -35,9 +35,8 @@ export interface ScreepsClientOptions {
35
35
  maxCacheEntries?: number
36
36
  }
37
37
  /**
38
- * Idle keep-alive that refreshes the auth token via a lightweight `auth/me` call when no
39
- * authenticated traffic has happened for `intervalMs`. Default `{ intervalMs: 30_000 }`.
40
- * Pass `false` to disable.
38
+ * Polls `/api/user/world-status` on a fixed interval to keep world status current.
39
+ * Default `{ intervalMs: 30_000 }`. Pass `false` to disable.
41
40
  */
42
41
  tokenRefresh?: TokenRefreshOptions | false
43
42
  /** Override the /api/game/room-decorations response with static data (useful for dev/testing when the server doesn't support the endpoint). */
@@ -60,7 +59,6 @@ export class ScreepsClient {
60
59
  private readonly tokenRefreshIntervalMs: number | null
61
60
  private readonly tokenSyncSubs: Subscription[] = []
62
61
  private tokenRefreshTimer: ReturnType<typeof setInterval> | null = null
63
- private lastHttpActivity = 0
64
62
  private refreshInFlight = false
65
63
 
66
64
  constructor(opts: ScreepsClientOptions) {
@@ -85,7 +83,7 @@ export class ScreepsClient {
85
83
  user: new UserStore(this.http, this.socket, this.cache, this.logger.child('user')),
86
84
  server: new ServerStore(this.http, this.socket, this.cache, this.logger.child('server')),
87
85
  map: new MapStore(this.socket, map2Storage, { maxSubscriptions: opts.map2?.maxSubscriptions ?? 500 }, this.logger.child('map')),
88
- mapStats: new MapStatsStore(this.http, 100, 500, this.logger.child('mapStats')),
86
+ mapStats: new MapStatsStore(this.http, 100, 100, this.logger.child('mapStats')),
89
87
  navigation: new NavigationStore(50, this.logger.child('navigation')),
90
88
  }
91
89
 
@@ -93,23 +91,21 @@ export class ScreepsClient {
93
91
  ? null
94
92
  : (opts.tokenRefresh?.intervalMs ?? 30_000)
95
93
 
96
- this.wireTokenSync()
94
+ this.wireTokenSync(opts.auth.supportsTokenRefresh ?? true)
97
95
  }
98
96
 
99
- private wireTokenSync(): void {
100
- // HTTP rotates token via X-Token → propagate to WS so a later reconnect uses the fresh one.
101
- this.tokenSyncSubs.push(this.http.on('http:tokenRefresh', ({ token }) => {
102
- this.socket.setToken(token)
103
- }))
104
- // WS issues a new token on auth → keep HTTP side in sync.
105
- this.tokenSyncSubs.push(this.socket.on('socket:tokenRefresh', (data) => {
106
- const detail = data as { token: string }
107
- this.http.setToken(detail.token)
108
- }))
109
- // Any successful HTTP response counts as activity — defers the next idle refresh.
110
- this.tokenSyncSubs.push(this.http.on('http:success', () => {
111
- this.lastHttpActivity = Date.now()
112
- }))
97
+ private wireTokenSync(supportsRefresh: boolean): void {
98
+ if (supportsRefresh) {
99
+ // HTTP rotates token via X-Token → propagate to WS so a later reconnect uses the fresh one.
100
+ this.tokenSyncSubs.push(this.http.on('http:tokenRefresh', ({ token }) => {
101
+ this.socket.setToken(token)
102
+ }))
103
+ // WS issues a new token on auth keep HTTP side in sync.
104
+ this.tokenSyncSubs.push(this.socket.on('socket:tokenRefresh', (data) => {
105
+ const detail = data as { token: string }
106
+ this.http.setToken(detail.token)
107
+ }))
108
+ }
113
109
  }
114
110
 
115
111
  get isConnected(): boolean {
@@ -137,15 +133,9 @@ export class ScreepsClient {
137
133
  private startTokenRefresh(): void {
138
134
  if (this.tokenRefreshIntervalMs === null) return
139
135
  if (this.tokenRefreshTimer !== null) return
140
- const intervalMs = this.tokenRefreshIntervalMs
141
- this.lastHttpActivity = Date.now()
142
- // Tick at half the interval so we react within intervalMs of idleness.
143
- const tickMs = Math.max(1_000, Math.floor(intervalMs / 2))
144
136
  this.tokenRefreshTimer = setInterval(() => {
145
- const idleFor = Date.now() - this.lastHttpActivity
146
- if (idleFor < intervalMs) return
147
137
  void this.refreshTokenNow()
148
- }, tickMs)
138
+ }, this.tokenRefreshIntervalMs)
149
139
  }
150
140
 
151
141
  private stopTokenRefresh(): void {
@@ -159,10 +149,10 @@ export class ScreepsClient {
159
149
  if (this.refreshInFlight) return
160
150
  this.refreshInFlight = true
161
151
  try {
162
- this.logger.log('[screeps:client] token refresh (idle)')
152
+ this.logger.log('[screeps:client] world status refresh')
163
153
  await this.stores.user.refreshWorldStatus()
164
154
  } catch (err) {
165
- this.logger.log('[screeps:client] token refresh failed', err)
155
+ this.logger.log('[screeps:client] world status refresh failed', err)
166
156
  } finally {
167
157
  this.refreshInFlight = false
168
158
  }
@@ -117,7 +117,7 @@ export class HttpClient extends EventTarget {
117
117
  const res = await getFetch()(url.toString(), init)
118
118
 
119
119
  const newToken = res.headers.get('x-token')
120
- if (newToken) {
120
+ if (newToken && (this.authStrategy.supportsTokenRefresh ?? true)) {
121
121
  this.token = newToken
122
122
  this.emit('http:tokenRefresh', { token: newToken })
123
123
  }
@@ -2,4 +2,6 @@ import type { HttpClient } from '../HttpClient.js'
2
2
 
3
3
  export interface AuthStrategy {
4
4
  authenticate(http: HttpClient): Promise<string>
5
+ /** False means the token is static and must never be replaced by server-issued tokens. Default: true. */
6
+ readonly supportsTokenRefresh?: boolean
5
7
  }
@@ -2,6 +2,7 @@ import type { AuthStrategy } from './AuthStrategy.js'
2
2
  import type { HttpClient } from '../HttpClient.js'
3
3
 
4
4
  export class TokenAuth implements AuthStrategy {
5
+ readonly supportsTokenRefresh = false
5
6
  private readonly token: string
6
7
 
7
8
  constructor(opts: { token: string }) {
@@ -1,16 +1,18 @@
1
1
  import type { HttpClient } from '../HttpClient.js'
2
- import type { ApiLeaderboardListResponse, ApiLeaderboardSeasonsResponse } from '../../types/api.js'
2
+ import type { ApiLeaderboardFindResponse, ApiLeaderboardListResponse, ApiLeaderboardSeasonsResponse } from '../../types/api.js'
3
3
 
4
4
  export interface LeaderboardEndpoints {
5
5
  list(limit?: number, mode?: 'world' | 'power', offset?: number, season?: string): Promise<ApiLeaderboardListResponse>
6
- find(username: string, mode?: string, season?: string): Promise<unknown>
6
+ find(username: string, mode?: string, season?: string): Promise<ApiLeaderboardFindResponse>
7
7
  seasons(): Promise<ApiLeaderboardSeasonsResponse>
8
8
  }
9
9
 
10
10
  export function createLeaderboardEndpoints(http: HttpClient): LeaderboardEndpoints {
11
11
  return {
12
12
  list: (limit = 10, mode = 'world', offset = 0, season) => http.request('GET', '/api/leaderboard/list', { limit, mode, offset, season }),
13
- find: (username, mode = 'world', season = '') => http.request('GET', '/api/leaderboard/find', { username, mode, season }),
13
+ // Best-effort: ranks feed the public profile and not every server implements
14
+ // them, so a miss shouldn't raise a user-facing toast.
15
+ find: (username, mode = 'world', season = '') => http.request('GET', '/api/leaderboard/find', { username, mode, season }, { silent: true }),
14
16
  seasons: () => http.request('GET', '/api/leaderboard/seasons'),
15
17
  }
16
18
  }
@@ -5,17 +5,11 @@ import type {
5
5
  ApiUserMoneyHistoryResponse,
6
6
  ApiUserOverviewResponse,
7
7
  ApiUserRoomsResponse,
8
+ ApiUserStatsResponse,
8
9
  } from '../../types/api.js'
10
+ import type { NotifyPrefs } from '../../types/game.js'
9
11
  import { createUserMessagesEndpoints, type UserMessagesEndpoints } from './user-messages.js'
10
12
 
11
- export interface NotifyPrefs {
12
- disabled: boolean
13
- disabledOnMessages: boolean
14
- sendOnline: boolean
15
- interval: number
16
- errorsInterval: number
17
- }
18
-
19
13
  export interface UserEndpoints {
20
14
  branches(): Promise<ApiUserBranchesResponse>
21
15
  code: {
@@ -31,7 +25,7 @@ export interface UserEndpoints {
31
25
  }
32
26
  }
33
27
  console(expression: string, shard?: string | null): Promise<unknown>
34
- stats(interval: number): Promise<unknown>
28
+ stats(interval: number, id?: string): Promise<ApiUserStatsResponse>
35
29
  rooms(id: string): Promise<ApiUserRoomsResponse>
36
30
  overview(interval: number, statName: string): Promise<ApiUserOverviewResponse>
37
31
  worldStatus(): Promise<{ ok: number; status: 'normal' | 'lost' | 'empty' }>
@@ -74,7 +68,7 @@ export function createUserEndpoints(http: HttpClient): UserEndpoints {
74
68
  },
75
69
  },
76
70
  console: (expression, shard) => http.request('POST', '/api/user/console', withShard({ expression }, shard)),
77
- stats: (interval) => http.request('GET', '/api/user/stats', { interval }),
71
+ stats: (interval, id) => http.request('GET', '/api/user/stats', id != null ? { interval, id } : { interval }, { silent: true }),
78
72
  // Best-effort dashboard data: not every server implements these, and the
79
73
  // Overview page degrades gracefully (zeros / no tiles), so a failure here
80
74
  // shouldn't raise a user-facing error toast — mark them silent.
package/src/index.ts CHANGED
@@ -25,6 +25,7 @@ export type {
25
25
  RoomObjectDiff,
26
26
  RoomMap2Data,
27
27
  UserInfo,
28
+ NotifyPrefs,
28
29
  CpuStats,
29
30
  WorldStatus,
30
31
  ConsoleMessage,
@@ -41,7 +42,7 @@ export type {
41
42
 
42
43
  export { fetchServerVersion, fetchAuthModInfo, checkUsername, checkEmail, registerUser, getServerFeature, getScreepsmodAuth } from './http/fetchServerVersion.js'
43
44
  export type { ApiAuthModInfoResponse } from './http/fetchServerVersion.js'
44
- export type { RoomHistoryChunk, ApiRoomDecorationsResponse, ApiRoomDecorationItem, ApiRoomDecorationDef, ApiRoomDecorationGraphic, ApiRoomDecorationActive, ApiUserOverviewResponse, ApiUserOverviewTotals, ApiUserRoomsResponse, ApiUserMoneyHistoryResponse, ApiMarketOrder, ApiMarketOrdersIndexResponse, ApiMarketOrdersResponse, ApiMarketMyOrdersResponse, ApiMarketStat, ApiMarketStatsResponse, ApiPowerCreep, ApiPowerCreepsListResponse } from './types/api.js'
45
+ export type { RoomHistoryChunk, ApiRoomDecorationsResponse, ApiRoomDecorationItem, ApiRoomDecorationDef, ApiRoomDecorationGraphic, ApiRoomDecorationActive, ApiUserOverviewResponse, ApiUserOverviewTotals, ApiUserRoomsResponse, ApiUserStatsResponse, ApiLeaderboardFindResponse, ApiUserMoneyHistoryResponse, ApiMarketOrder, ApiMarketOrdersIndexResponse, ApiMarketOrdersResponse, ApiMarketMyOrdersResponse, ApiMarketStat, ApiMarketStatsResponse, ApiPowerCreep, ApiPowerCreepsListResponse, ApiUserMessage, ApiUserMessagesListResponse, ApiUserMessagesIndexEntry, ApiUserMessagesIndexResponse, ApiUserMessagesUnreadCountResponse, ApiUserFindResponse } from './types/api.js'
45
46
  export { ROOM_DECORATIONS_MOCK } from './mocks/roomDecorations.js'
46
47
 
47
48
  export { badgeToSvg } from './badge/index.js'
@@ -59,3 +60,6 @@ export type { UserStore } from './stores/UserStore.js'
59
60
  export type { ServerStore } from './stores/ServerStore.js'
60
61
  export type { MapStore, Map2Subscription, MapStoreOptions } from './stores/MapStore.js'
61
62
  export type { NavigationStore, NavigationState, NavigationStoreEvents } from './stores/NavigationStore.js'
63
+ export { MapStatName, MapStatPrefix, MapStatInterval, mapStat } from './stores/MapStatsStore.js'
64
+ export type { MapStatsRoomData, MapStatsStoreEvents, TerrainColors } from './stores/MapStatsStore.js'
65
+
@@ -3,6 +3,41 @@ import type { Logger } from '../logger.js'
3
3
  import type { HttpClient } from '../http/HttpClient.js'
4
4
  import type { ApiMapStatsRoomStat, ApiMapStatsBadge } from '../types/api.js'
5
5
 
6
+ /** Fixed stat names for the map-stats API (no interval parameter). */
7
+ export const MapStatName = {
8
+ owner: 'owner0',
9
+ minerals: 'minerals0',
10
+ power: 'power0',
11
+ } as const
12
+
13
+ /** Stat name prefixes that take an interval suffix — combine with {@link MapStatInterval} via {@link mapStat}. */
14
+ export const MapStatPrefix = {
15
+ energyControl: 'energyControl',
16
+ energyHarvested: 'energyHarvested',
17
+ energyConstruction: 'energyConstruction',
18
+ energyCreeps: 'energyCreeps',
19
+ creepsProduced: 'creepsProduced',
20
+ creepsLost: 'creepsLost',
21
+ powerProcessed: 'powerProcessed',
22
+ } as const
23
+
24
+ /** Tick-bucket intervals supported by the Screeps API for parameterised stats. */
25
+ export const MapStatInterval = {
26
+ hour1: 8,
27
+ hours24: 180,
28
+ days7: 1440,
29
+ } as const
30
+
31
+ /** Build a parameterised stat name, e.g. `mapStat(MapStatPrefix.energyControl, MapStatInterval.hours24)` → `"energyControl180"`. */
32
+ export const mapStat = (prefix: string, interval: number): string => `${prefix}${interval}`
33
+
34
+ /** Custom terrain palette extracted from a room's active world-map decoration. */
35
+ export interface TerrainColors {
36
+ plain?: string // CSS color string, e.g. "#68DFFF"
37
+ swamp?: string
38
+ road?: string
39
+ }
40
+
6
41
  export interface MapStatsRoomData {
7
42
  own?: { user: string; level: number }
8
43
  mineral?: string
@@ -16,10 +51,12 @@ export interface MapStatsRoomData {
16
51
  * response's user map and may be absent if the signer isn't included there.
17
52
  */
18
53
  sign?: { user: string; text: string; datetime: number; username?: string; badge?: ApiMapStatsBadge }
54
+ /** Custom terrain colors from an active world-map decoration, if any. */
55
+ terrainColors?: TerrainColors
19
56
  }
20
57
 
21
58
  export interface MapStatsStoreEvents {
22
- 'mapStats:room': { room: string; shard: string | null; stat: MapStatsRoomData }
59
+ 'mapStats:room': { room: string; shard: string | null; stat: MapStatsRoomData; statName: string }
23
60
  }
24
61
 
25
62
  interface PendingBatch {
@@ -79,15 +116,16 @@ export class MapStatsStore extends TypedStore<MapStatsStoreEvents> {
79
116
 
80
117
  const userMap = res.users ?? {}
81
118
 
119
+ const shardKey = batch.shard === 'shard0' ? null : batch.shard
82
120
  for (const [room, stat] of Object.entries(res.stats)) {
83
121
  const data = this.buildData(stat, userMap)
84
- this.emit('mapStats:room', { room, shard: batch.shard === 'shard0' ? null : batch.shard, stat: data })
122
+ this.emit('mapStats:room', { room, shard: shardKey, stat: data, statName: batch.statName })
85
123
  }
86
124
 
87
125
  // Emit empty data for rooms that don't exist on server
88
126
  for (const room of allRooms) {
89
127
  if (!res.stats[room]) {
90
- this.emit('mapStats:room', { room, shard: batch.shard === 'shard0' ? null : batch.shard, stat: {} })
128
+ this.emit('mapStats:room', { room, shard: shardKey, stat: {}, statName: batch.statName })
91
129
  }
92
130
  }
93
131
  } catch (err) {
@@ -97,19 +135,19 @@ export class MapStatsStore extends TypedStore<MapStatsStoreEvents> {
97
135
  }
98
136
 
99
137
  private buildData(stat: ApiMapStatsRoomStat, userMap: Record<string, { username: string; badge: ApiMapStatsBadge }>): MapStatsRoomData {
100
- let mineral: string | undefined
101
- let density: number | undefined
102
- for (let i = 0; i < 3; i++) {
103
- const mineralKey = `minerals${i}` as `minerals${number}`
104
- const mineralData = stat[mineralKey]
105
- if (mineralData) {
106
- mineral = mineralData.type
107
- density = mineralData.density
108
- break
109
- }
110
- }
138
+ const mineral = stat.minerals0?.type
139
+ const density = stat.minerals0?.density
111
140
  const ownerId = stat.own?.user
112
141
  const signUserId = stat.sign?.user
142
+
143
+ // Find the terrain-theme decoration (world=true + floor/swamp color properties).
144
+ const terrainDeco = stat.decorations?.find(d => d.active.world && (d.active.floorBackgroundColor || d.active.swampColor))
145
+ const terrainColors: TerrainColors | undefined = terrainDeco ? {
146
+ plain: terrainDeco.active.floorBackgroundColor,
147
+ swamp: terrainDeco.active.swampColor,
148
+ road: terrainDeco.active.roadsColor,
149
+ } : undefined
150
+
113
151
  return {
114
152
  own: stat.own,
115
153
  mineral,
@@ -127,6 +165,7 @@ export class MapStatsStore extends TypedStore<MapStatsStoreEvents> {
127
165
  badge: signUserId ? userMap[signUserId]?.badge : undefined,
128
166
  }
129
167
  : undefined,
168
+ terrainColors,
130
169
  }
131
170
  }
132
171
  }
package/src/types/api.ts CHANGED
@@ -142,6 +142,25 @@ export interface ApiLeaderboardSeasonsResponse {
142
142
  seasons: Array<{ _id: string; name: string; date: string }>
143
143
  }
144
144
 
145
+ export interface ApiDecorationActive {
146
+ world?: boolean
147
+ tileScale?: number | string
148
+ // terrain theme
149
+ floorBackgroundColor?: string
150
+ floorForegroundColor?: string
151
+ floorForegroundAlpha?: string | number
152
+ swampColor?: string
153
+ swampStrokeColor?: string
154
+ roadsColor?: string
155
+ roadsBrightness?: number | string
156
+ // room overlay (not used on world map yet)
157
+ foregroundColor?: string
158
+ foregroundAlpha?: number | string
159
+ backgroundColor?: string
160
+ strokeColor?: string
161
+ [key: string]: unknown
162
+ }
163
+
145
164
  export interface ApiMapStatsRoomStat {
146
165
  status: string
147
166
  novice: number | null
@@ -150,7 +169,8 @@ export interface ApiMapStatsRoomStat {
150
169
  own?: { user: string; level: number }
151
170
  sign?: { user: string; text: string; time: number; datetime: number }
152
171
  safeMode?: boolean
153
- [mineral: `minerals${number}`]: { type: string; density: number } | undefined
172
+ minerals0?: { type: string; density: number }
173
+ decorations?: Array<{ _id: string; user: string; decoration: string; active: ApiDecorationActive }>
154
174
  }
155
175
 
156
176
  export interface ApiMapStatsBadge {
@@ -239,9 +259,28 @@ export interface ApiUserFindResponse {
239
259
  username: string
240
260
  badge: import('./game.js').Badge
241
261
  gcl: number
262
+ // Lifetime power (GPL) — present in the public lookup alongside gcl.
263
+ power?: number
242
264
  }
243
265
  }
244
266
 
267
+ /** Response of GET /api/user/stats?id=&interval=. `stats` maps a metric to per-tick buckets; consumers sum the bucket values over the interval. */
268
+ export interface ApiUserStatsResponse {
269
+ ok: number
270
+ stats?: Record<string, Array<{ value: number; endTime?: number }>>
271
+ statsMax?: Record<string, number>
272
+ }
273
+
274
+ /** Response of GET /api/leaderboard/find?username=&mode=&season=. Servers return either a flat list of per-season records or a single season-scoped record at the top level. */
275
+ export interface ApiLeaderboardFindResponse {
276
+ ok: number
277
+ list?: Array<{ season: string; rank: number; score: number; user: string }>
278
+ rank?: number
279
+ score?: number
280
+ season?: string
281
+ user?: string
282
+ }
283
+
245
284
  export interface ApiUserMoneyHistoryResponse {
246
285
  ok: number
247
286
  page: number
@@ -318,6 +357,7 @@ export interface ApiUserMessage {
318
357
  respondent: string
319
358
  user: string
320
359
  text: string
360
+ type?: 'in' | 'out'
321
361
  unread: boolean
322
362
  }
323
363
 
@@ -329,12 +369,12 @@ export interface ApiUserMessagesListResponse {
329
369
  export interface ApiUserMessagesIndexEntry {
330
370
  _id: string
331
371
  message: ApiUserMessage
332
- user: { _id: string; username: string; badge: import('./game.js').Badge }
333
372
  }
334
373
 
335
374
  export interface ApiUserMessagesIndexResponse {
336
375
  ok: number
337
- list: ApiUserMessagesIndexEntry[]
376
+ messages: ApiUserMessagesIndexEntry[]
377
+ users: Record<string, { _id: string; username: string; badge: import('./game.js').Badge }>
338
378
  }
339
379
 
340
380
  export interface ApiUserMessagesUnreadCountResponse {
package/src/types/game.ts CHANGED
@@ -60,6 +60,14 @@ export interface RoomObject {
60
60
  export type RoomObjectMap = Record<string, RoomObject>
61
61
  export type RoomObjectDiff = Record<string, Partial<RoomObject> | null>
62
62
 
63
+ export interface NotifyPrefs {
64
+ disabled: boolean
65
+ disabledOnMessages: boolean
66
+ sendOnline: boolean
67
+ interval: number
68
+ errorsInterval: number
69
+ }
70
+
63
71
  export interface UserInfo {
64
72
  _id: string
65
73
  username: string
@@ -72,6 +80,7 @@ export interface UserInfo {
72
80
  badge: Badge
73
81
  /** 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
82
  password?: boolean
83
+ notifyPrefs?: Partial<NotifyPrefs>
75
84
  }
76
85
 
77
86
  export interface CpuStats {
@@ -104,6 +104,9 @@ describe('ScreepsClient', () => {
104
104
  })
105
105
  })
106
106
 
107
+ // Dynamic-token auth strategy (supportsTokenRefresh defaults to true) used for token-rotation tests.
108
+ const dynamicAuth = (initial = 'initial') => ({ authenticate: async () => initial })
109
+
107
110
  describe('ScreepsClient — token sync', () => {
108
111
  it('rotates SocketClient token when an HTTP response carries x-token', async () => {
109
112
  vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
@@ -114,7 +117,7 @@ describe('ScreepsClient — token sync', () => {
114
117
 
115
118
  const client = new ScreepsClient({
116
119
  url: 'http://test.local',
117
- auth: new TokenAuth({ token: 'initial' }),
120
+ auth: dynamicAuth(),
118
121
  storage: null,
119
122
  WebSocket: MockWS as unknown as typeof WebSocket,
120
123
  tokenRefresh: false,
@@ -129,7 +132,7 @@ describe('ScreepsClient — token sync', () => {
129
132
  it('rotates HttpClient token when WS auth response carries a new token', async () => {
130
133
  const client = new ScreepsClient({
131
134
  url: 'http://test.local',
132
- auth: new TokenAuth({ token: 'initial' }),
135
+ auth: dynamicAuth(),
133
136
  storage: null,
134
137
  WebSocket: MockWS as unknown as typeof WebSocket,
135
138
  tokenRefresh: false,
@@ -147,16 +150,36 @@ describe('ScreepsClient — token sync', () => {
147
150
  expect(httpSetToken).toHaveBeenCalledWith('ws-rotated-token')
148
151
  expect(client.http.token).toBe('ws-rotated-token')
149
152
  })
153
+
154
+ it('does NOT sync tokens when using TokenAuth (static token)', async () => {
155
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
156
+ new Response(JSON.stringify({ ok: 1 }), {
157
+ headers: { 'content-type': 'application/json', 'x-token': 'server-issued' },
158
+ })
159
+ ))
160
+
161
+ const client = new ScreepsClient({
162
+ url: 'http://test.local',
163
+ auth: new TokenAuth({ token: 'my-static-token' }),
164
+ storage: null,
165
+ WebSocket: MockWS as unknown as typeof WebSocket,
166
+ })
167
+
168
+ const socketSetToken = vi.spyOn(client.socket, 'setToken')
169
+ await client.http.request('GET', '/api/auth/me')
170
+
171
+ expect(socketSetToken).not.toHaveBeenCalled()
172
+ })
150
173
  })
151
174
 
152
- describe('ScreepsClient — idle token refresh', () => {
175
+ describe('ScreepsClient — world status polling', () => {
153
176
  beforeEach(() => { vi.useFakeTimers() })
154
177
  afterEach(() => { vi.useRealTimers() })
155
178
 
156
179
  async function buildConnected(opts: { tokenRefresh?: { intervalMs?: number } | false } = {}) {
157
180
  const client = new ScreepsClient({
158
181
  url: 'http://test.local',
159
- auth: new TokenAuth({ token: 'tok' }),
182
+ auth: dynamicAuth('tok'),
160
183
  storage: null,
161
184
  WebSocket: MockWS as unknown as typeof WebSocket,
162
185
  tokenRefresh: opts.tokenRefresh,
@@ -170,13 +193,12 @@ describe('ScreepsClient — idle token refresh', () => {
170
193
  return client
171
194
  }
172
195
 
173
- it('issues a world-status call after intervalMs of HTTP idleness', async () => {
196
+ it('polls world-status on a fixed interval', async () => {
174
197
  const client = await buildConnected({ tokenRefresh: { intervalMs: 1_000 } })
175
198
  const fetchMock = fetch as unknown as ReturnType<typeof vi.fn>
176
199
  fetchMock.mockClear()
177
200
 
178
- // Idle for 1.5s — exceeds 1s interval, refresh should fire on next tick (every 500ms).
179
- await vi.advanceTimersByTimeAsync(1_500)
201
+ await vi.advanceTimersByTimeAsync(1_000)
180
202
 
181
203
  const paths = fetchMock.mock.calls.map(([url]) => new URL(url as string).pathname)
182
204
  expect(paths).toContain('/api/user/world-status')
@@ -184,19 +206,19 @@ describe('ScreepsClient — idle token refresh', () => {
184
206
  client.disconnect()
185
207
  })
186
208
 
187
- it('does NOT issue an auth/me call while HTTP traffic resets the idle clock', async () => {
209
+ it('polls world-status even while other HTTP traffic is ongoing', async () => {
188
210
  const client = await buildConnected({ tokenRefresh: { intervalMs: 1_000 } })
189
211
  const fetchMock = fetch as unknown as ReturnType<typeof vi.fn>
190
212
  fetchMock.mockClear()
191
213
 
192
- // Make a request every 400ms, well below the 1s threshold.
193
- for (let i = 0; i < 5; i++) {
214
+ // Continuous HTTP traffic every 200ms should not suppress the fixed poll.
215
+ for (let i = 0; i < 6; i++) {
194
216
  await client.http.request('GET', '/api/version')
195
- await vi.advanceTimersByTimeAsync(400)
217
+ await vi.advanceTimersByTimeAsync(200)
196
218
  }
197
219
 
198
220
  const paths = fetchMock.mock.calls.map(([url]) => new URL(url as string).pathname)
199
- expect(paths).not.toContain('/api/auth/me')
221
+ expect(paths).toContain('/api/user/world-status')
200
222
 
201
223
  client.disconnect()
202
224
  })
@@ -209,11 +231,37 @@ describe('ScreepsClient — idle token refresh', () => {
209
231
  await vi.advanceTimersByTimeAsync(60_000)
210
232
 
211
233
  const paths = fetchMock.mock.calls.map(([url]) => new URL(url as string).pathname)
212
- expect(paths).not.toContain('/api/auth/me')
234
+ expect(paths).not.toContain('/api/user/world-status')
213
235
 
214
236
  client.disconnect()
215
237
  })
216
238
 
239
+ it('still polls world-status when using TokenAuth (token is never replaced)', async () => {
240
+ const client = new ScreepsClient({
241
+ url: 'http://test.local',
242
+ auth: new TokenAuth({ token: 'tok' }),
243
+ storage: null,
244
+ WebSocket: MockWS as unknown as typeof WebSocket,
245
+ tokenRefresh: { intervalMs: 1_000 },
246
+ })
247
+ const connectPromise = client.connect()
248
+ await vi.advanceTimersByTimeAsync(0)
249
+ const ws = MockWS.instances[MockWS.instances.length - 1]
250
+ ws.simulateOpen()
251
+ ws.simulateMessage('auth ok tok')
252
+ await connectPromise
253
+
254
+ const fetchMock = fetch as unknown as ReturnType<typeof vi.fn>
255
+ fetchMock.mockClear()
256
+ await vi.advanceTimersByTimeAsync(1_500)
257
+
258
+ const paths = fetchMock.mock.calls.map(([url]) => new URL(url as string).pathname)
259
+ expect(paths).toContain('/api/user/world-status')
260
+ // Token must not have changed despite the response
261
+ expect(client.http.token).toBe('tok')
262
+ client.disconnect()
263
+ })
264
+
217
265
  it('stops the refresh timer on disconnect()', async () => {
218
266
  const client = await buildConnected({ tokenRefresh: { intervalMs: 1_000 } })
219
267
  client.disconnect()
@@ -224,6 +272,6 @@ describe('ScreepsClient — idle token refresh', () => {
224
272
  await vi.advanceTimersByTimeAsync(5_000)
225
273
 
226
274
  const paths = fetchMock.mock.calls.map(([url]) => new URL(url as string).pathname)
227
- expect(paths).not.toContain('/api/auth/me')
275
+ expect(paths).not.toContain('/api/user/world-status')
228
276
  })
229
277
  })
@@ -81,12 +81,23 @@ describe('HttpClient', () => {
81
81
  fetchMock.mockResolvedValue(mockResponse({ ok: 1 }, {
82
82
  headers: { 'content-type': 'application/json', 'x-token': 'refreshed' },
83
83
  }))
84
- const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 'old' }) })
84
+ // Use a plain object (supportsTokenRefresh defaults to true) to verify rotation works for dynamic-token strategies
85
+ const http = new HttpClient({ url: 'http://test.local', auth: { authenticate: async () => 'old' } })
85
86
  http.token = 'old'
86
87
  await http.request('GET', '/api/version')
87
88
  expect(http.token).toBe('refreshed')
88
89
  })
89
90
 
91
+ it('does NOT update token from x-token header when using TokenAuth (static token)', async () => {
92
+ fetchMock.mockResolvedValue(mockResponse({ ok: 1 }, {
93
+ headers: { 'content-type': 'application/json', 'x-token': 'server-issued' },
94
+ }))
95
+ const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 'my-static-token' }) })
96
+ http.token = 'my-static-token'
97
+ await http.request('GET', '/api/version')
98
+ expect(http.token).toBe('my-static-token')
99
+ })
100
+
90
101
  it('retries once on 401 after re-authenticating', async () => {
91
102
  let calls = 0
92
103
  fetchMock.mockImplementation(() => {
@@ -136,13 +147,25 @@ describe('HttpClient', () => {
136
147
  fetchMock.mockResolvedValue(mockResponse({ ok: 1 }, {
137
148
  headers: { 'content-type': 'application/json', 'x-token': 'new-tok' },
138
149
  }))
139
- const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 'old' }) })
150
+ // Use a plain object (supportsTokenRefresh defaults to true) to verify the event fires for dynamic-token strategies
151
+ const http = new HttpClient({ url: 'http://test.local', auth: { authenticate: async () => 'old' } })
140
152
  const handler = vi.fn()
141
153
  http.on('http:tokenRefresh', handler)
142
154
  await http.request('GET', '/api/version')
143
155
  expect(handler).toHaveBeenCalledWith({ token: 'new-tok' })
144
156
  })
145
157
 
158
+ it('does NOT emit http:tokenRefresh when using TokenAuth (static token)', async () => {
159
+ fetchMock.mockResolvedValue(mockResponse({ ok: 1 }, {
160
+ headers: { 'content-type': 'application/json', 'x-token': 'server-issued' },
161
+ }))
162
+ const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 'my-static-token' }) })
163
+ const handler = vi.fn()
164
+ http.on('http:tokenRefresh', handler)
165
+ await http.request('GET', '/api/version')
166
+ expect(handler).not.toHaveBeenCalled()
167
+ })
168
+
146
169
  it('emits http:success on 200 response', async () => {
147
170
  fetchMock.mockResolvedValue(mockResponse({ ok: 1 }))
148
171
  const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 't' }) })