screeps-connectivity 0.8.1 → 0.10.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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 1539f52: Add NotifyPrefs type and notifyPrefs field on UserInfo for email notification preference support.
8
+ - 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.
9
+ - 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.
10
+
11
+ ### Patch Changes
12
+
13
+ - 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`.
14
+
15
+ ## 0.9.0
16
+
17
+ ### Minor Changes
18
+
19
+ - e73c85f: 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.
20
+
3
21
  ## 0.8.1
4
22
 
5
23
  ### Patch Changes
package/README.md ADDED
@@ -0,0 +1,182 @@
1
+ # screeps-connectivity
2
+
3
+ TypeScript library for connecting to [Screeps](https://screeps.com) servers. Handles HTTP, WebSocket, authentication, data stores, caching, and persistent storage — with **zero production dependencies**.
4
+
5
+ Works in the browser and Node.js. Ships as ESM + CJS via tsup.
6
+
7
+ ---
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ npm install screeps-connectivity
13
+ ```
14
+
15
+ `FileStorage` (Node.js only) is a separate entry point so browser bundles stay clean:
16
+
17
+ ```ts
18
+ import { FileStorage } from 'screeps-connectivity/file-storage'
19
+ ```
20
+
21
+ ---
22
+
23
+ ## Quick start
24
+
25
+ ```ts
26
+ import { ScreepsClient, TokenAuth, IndexedDBStorage } from 'screeps-connectivity'
27
+
28
+ const client = new ScreepsClient({
29
+ url: 'https://screeps.com',
30
+ auth: new TokenAuth({ token: 'your-auth-token' }),
31
+ storage: new IndexedDBStorage('my-app'),
32
+ })
33
+
34
+ // Subscribe before connecting — fires once the eager fetch completes
35
+ client.stores.user.on('user:me', (info) => {
36
+ console.log('Connected as', info.username)
37
+ })
38
+
39
+ await client.connect()
40
+
41
+ // Load terrain
42
+ const terrain = await client.stores.room.terrain('W7N7', 'shard0')
43
+
44
+ // Subscribe to live room updates
45
+ const roomSub = client.stores.room.subscribe('W7N7', 'shard0')
46
+ client.stores.room.on('room:update', ({ gameTime, objects }) => {
47
+ console.log('Tick', gameTime, '— objects:', Object.keys(objects).length)
48
+ })
49
+
50
+ // Cleanup
51
+ roomSub.dispose()
52
+ client.disconnect()
53
+ ```
54
+
55
+ ---
56
+
57
+ ## Features
58
+
59
+ ### Authentication
60
+
61
+ Three built-in strategies, or implement your own via the `AuthStrategy` interface:
62
+
63
+ ```ts
64
+ // Pre-issued token (official server, private servers with token auth)
65
+ new TokenAuth({ token: 'your-token' })
66
+
67
+ // Email + password — exchanged for a token on connect
68
+ new PasswordAuth({ email: 'user@example.com', password: 'secret' })
69
+
70
+ // Read-only guest access (xxscreeps-compatible private servers only)
71
+ new GuestAuth()
72
+ ```
73
+
74
+ Screeps servers rotate the session token on every request. `ScreepsClient` tracks the latest token across both HTTP and WebSocket transports, keeps them in sync, and issues an idle keep-alive if no traffic has been seen for 30 seconds.
75
+
76
+ ### Stores (event-based data layer)
77
+
78
+ All stores extend `EventTarget`. `store.on(type, handler)` returns a `Subscription` with a `dispose()` method. `SubscriptionGroup` composes multiple subscriptions for batch teardown.
79
+
80
+ | Store | `client.stores.*` | What it manages |
81
+ |---|---|---|
82
+ | `UserStore` | `user` | Identity, CPU stats, console output, code change events |
83
+ | `ServerStore` | `server` | Server version, shard list, world bounds, connection lifecycle |
84
+ | `RoomStore` | `room` | Terrain (binary), live object state + diffs, subscriptions |
85
+ | `MapStore` | `map` | `roomMap2` mini-map data, diff detection, LRU cache |
86
+ | `NavigationStore` | `navigation` | Bounded room-navigation history with back/forward |
87
+
88
+ ### Room subscriptions
89
+
90
+ ```ts
91
+ // Terrain — Uint8Array(2500), one byte per tile (values 0–3)
92
+ const terrain = await client.stores.room.terrain('W7N7', 'shard0')
93
+
94
+ // Live objects — first message is full state; subsequent messages are diffs
95
+ const sub = client.stores.room.subscribe('W7N7', 'shard0')
96
+ client.stores.room.on('room:update', ({ gameTime, objects, diff }) => { /* … */ })
97
+ ```
98
+
99
+ ### World map subscriptions
100
+
101
+ ```ts
102
+ // Subscribe up to 500 concurrent roomMap2 channels (configurable)
103
+ // Excess rooms queue on a FIFO waitlist and are promoted as slots free
104
+ client.stores.map.subscribe('W7N7', 'shard0')
105
+ client.stores.map.on('map:roomMap2', ({ roomName, data }) => { /* … */ })
106
+ ```
107
+
108
+ ### World bounds
109
+
110
+ ```ts
111
+ // Detects which quadrants (E/W, N/S) actually contain rooms
112
+ // Works for standard servers, private servers, and single-quadrant maps
113
+ const info = await client.stores.server.worldInfo()
114
+ // → { width, height, minX, maxX, minY, maxY }
115
+ ```
116
+
117
+ ### Storage adapters
118
+
119
+ | Adapter | Environment | Notes |
120
+ |---|---|---|
121
+ | `IndexedDBStorage` | Browser | Persists terrain and map cache across reloads |
122
+ | `FileStorage` | Node.js | Import from `screeps-connectivity/file-storage` |
123
+ | `NullStorage` | Any | Disables persistence (in-memory only) |
124
+
125
+ Implement `StorageAdapter` (`get`, `set`, `delete`, `keys`) to plug in any backend.
126
+
127
+ ### Pre-login server info
128
+
129
+ ```ts
130
+ import { fetchServerVersion, getScreepsmodAuth } from 'screeps-connectivity'
131
+
132
+ const version = await fetchServerVersion('http://my-server:21025')
133
+ const auth = getScreepsmodAuth(version)
134
+ // auth?.authTypes → ['password', 'steam']
135
+ ```
136
+
137
+ ---
138
+
139
+ ## Architecture
140
+
141
+ ```
142
+ ScreepsClient — facade; wires everything together
143
+ ├─ HttpClient — fetch wrapper, auth headers, rate limiting, gzip
144
+ │ └─ endpoints/ — auth · game · user · leaderboard · experimental
145
+ └─ SocketClient — WebSocket lifecycle, exponential-backoff reconnect
146
+ └─ MessageParser — plain-text commands + JSON-array messages, gzip
147
+ DataStores — UserStore · ServerStore · RoomStore · MapStore · NavigationStore
148
+ Cache — two-tier: in-memory Map + optional StorageAdapter
149
+ StorageAdapter — binary interface (Uint8Array)
150
+ ```
151
+
152
+ ---
153
+
154
+ ## Node.js usage
155
+
156
+ Pass a custom `WebSocket` constructor for Node 18/20 compatibility:
157
+
158
+ ```ts
159
+ import { WebSocket } from 'ws'
160
+ import { ScreepsClient, TokenAuth, FileStorage } from 'screeps-connectivity'
161
+ // FileStorage is a separate entry point:
162
+ // import { FileStorage } from 'screeps-connectivity/file-storage'
163
+
164
+ const client = new ScreepsClient({
165
+ url: 'https://screeps.com',
166
+ auth: new TokenAuth({ token: 'your-token' }),
167
+ storage: new FileStorage('./screeps-cache'),
168
+ WebSocket,
169
+ })
170
+ ```
171
+
172
+ ---
173
+
174
+ ## API reference
175
+
176
+ Full documentation — all stores, events, options, and types — is in [`docs/screeps-connectivity.md`](../docs/screeps-connectivity.md).
177
+
178
+ ---
179
+
180
+ ## License
181
+
182
+ ISC
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "screeps-connectivity",
3
- "version": "0.8.1",
3
+ "version": "0.10.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) {
@@ -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
  }
@@ -1,4 +1,5 @@
1
1
  import { decompressGzip } from './decompress.js'
2
+ import { getFetch } from './fetchFn.js'
2
3
  import { Logger } from '../logger.js'
3
4
  import type { AuthStrategy } from './auth/AuthStrategy.js'
4
5
  import { createAuthEndpoints, type AuthEndpoints } from './endpoints/auth.js'
@@ -113,10 +114,10 @@ export class HttpClient extends EventTarget {
113
114
  init.body = JSON.stringify(body)
114
115
  }
115
116
 
116
- const res = await fetch(url.toString(), init)
117
+ const res = await getFetch()(url.toString(), init)
117
118
 
118
119
  const newToken = res.headers.get('x-token')
119
- if (newToken) {
120
+ if (newToken && (this.authStrategy.supportsTokenRefresh ?? true)) {
120
121
  this.token = newToken
121
122
  this.emit('http:tokenRefresh', { token: newToken })
122
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.
@@ -0,0 +1,17 @@
1
+ let _fetch: typeof globalThis.fetch | undefined
2
+
3
+ /**
4
+ * Override the fetch implementation used by all screeps-connectivity HTTP calls.
5
+ * Call this once at app startup when the platform requires a custom transport
6
+ * (e.g. Tauri desktop, where the native WKWebView fetch cannot reach cross-origin
7
+ * Screeps servers and the Tauri HTTP plugin must be used instead).
8
+ *
9
+ * When not called, screeps-connectivity uses `globalThis.fetch`.
10
+ */
11
+ export function setFetch(fetchFn: typeof globalThis.fetch): void {
12
+ _fetch = fetchFn
13
+ }
14
+
15
+ export function getFetch(): typeof globalThis.fetch {
16
+ return _fetch ?? globalThis.fetch
17
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ServerVersion } from '../types/game.js'
2
2
  import type { ScreepsmodAuthFeature, ServerFeature } from '../types/game.js'
3
3
  import type { ApiAuthModInfoResponse, ApiRegisterCheckResponse } from '../types/api.js'
4
+ import { getFetch } from './fetchFn.js'
4
5
 
5
6
  export type { ApiAuthModInfoResponse }
6
7
 
@@ -56,7 +57,7 @@ export async function fetchServerVersion(url: string): Promise<ServerVersion> {
56
57
  const cached = readFromSession<ServerVersion>(key)
57
58
  if (cached) return cached
58
59
 
59
- const res = await fetch(`${baseUrl(url)}api/version`)
60
+ const res = await getFetch()(`${baseUrl(url)}api/version`)
60
61
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
61
62
  const data = await res.json() as ServerVersion
62
63
  writeToSession(key, data)
@@ -73,7 +74,7 @@ export async function fetchAuthModInfo(url: string): Promise<ApiAuthModInfoRespo
73
74
  const cached = readFromSession<ApiAuthModInfoResponse>(key)
74
75
  if (cached) return cached
75
76
 
76
- const res = await fetch(`${baseUrl(url)}api/authmod`)
77
+ const res = await getFetch()(`${baseUrl(url)}api/authmod`)
77
78
  if (!res.ok) return null
78
79
  const data = await res.json() as ApiAuthModInfoResponse
79
80
  if (!data.ok) return null
@@ -87,7 +88,7 @@ export async function fetchAuthModInfo(url: string): Promise<ApiAuthModInfoRespo
87
88
  */
88
89
  export async function checkUsername(url: string, username: string): Promise<ApiRegisterCheckResponse> {
89
90
  const params = new URLSearchParams({ username })
90
- const res = await fetch(`${baseUrl(url)}api/register/check-username?${params}`)
91
+ const res = await getFetch()(`${baseUrl(url)}api/register/check-username?${params}`)
91
92
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
92
93
  return res.json() as Promise<ApiRegisterCheckResponse>
93
94
  }
@@ -98,7 +99,7 @@ export async function checkUsername(url: string, username: string): Promise<ApiR
98
99
  */
99
100
  export async function checkEmail(url: string, email: string): Promise<ApiRegisterCheckResponse> {
100
101
  const params = new URLSearchParams({ email })
101
- const res = await fetch(`${baseUrl(url)}api/register/check-email?${params}`)
102
+ const res = await getFetch()(`${baseUrl(url)}api/register/check-email?${params}`)
102
103
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
103
104
  return res.json() as Promise<ApiRegisterCheckResponse>
104
105
  }
@@ -113,7 +114,7 @@ export async function registerUser(
113
114
  email: string,
114
115
  password: string,
115
116
  ): Promise<{ ok: number; error?: string }> {
116
- const res = await fetch(`${baseUrl(url)}api/register/submit`, {
117
+ const res = await getFetch()(`${baseUrl(url)}api/register/submit`, {
117
118
  method: 'POST',
118
119
  headers: { 'Content-Type': 'application/json' },
119
120
  body: JSON.stringify({ username, email, password }),
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { ScreepsClient } from './ScreepsClient.js'
2
+ export { setFetch } from './http/fetchFn.js'
2
3
  export type { ScreepsClientOptions } from './ScreepsClient.js'
3
4
 
4
5
  export { Logger } from './logger.js'
@@ -24,6 +25,7 @@ export type {
24
25
  RoomObjectDiff,
25
26
  RoomMap2Data,
26
27
  UserInfo,
28
+ NotifyPrefs,
27
29
  CpuStats,
28
30
  WorldStatus,
29
31
  ConsoleMessage,
@@ -40,7 +42,7 @@ export type {
40
42
 
41
43
  export { fetchServerVersion, fetchAuthModInfo, checkUsername, checkEmail, registerUser, getServerFeature, getScreepsmodAuth } from './http/fetchServerVersion.js'
42
44
  export type { ApiAuthModInfoResponse } from './http/fetchServerVersion.js'
43
- 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'
44
46
  export { ROOM_DECORATIONS_MOCK } from './mocks/roomDecorations.js'
45
47
 
46
48
  export { badgeToSvg } from './badge/index.js'
package/src/types/api.ts CHANGED
@@ -239,9 +239,28 @@ export interface ApiUserFindResponse {
239
239
  username: string
240
240
  badge: import('./game.js').Badge
241
241
  gcl: number
242
+ // Lifetime power (GPL) — present in the public lookup alongside gcl.
243
+ power?: number
242
244
  }
243
245
  }
244
246
 
247
+ /** Response of GET /api/user/stats?id=&interval=. `stats` maps a metric to per-tick buckets; consumers sum the bucket values over the interval. */
248
+ export interface ApiUserStatsResponse {
249
+ ok: number
250
+ stats?: Record<string, Array<{ value: number; endTime?: number }>>
251
+ statsMax?: Record<string, number>
252
+ }
253
+
254
+ /** 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. */
255
+ export interface ApiLeaderboardFindResponse {
256
+ ok: number
257
+ list?: Array<{ season: string; rank: number; score: number; user: string }>
258
+ rank?: number
259
+ score?: number
260
+ season?: string
261
+ user?: string
262
+ }
263
+
245
264
  export interface ApiUserMoneyHistoryResponse {
246
265
  ok: number
247
266
  page: number
@@ -318,6 +337,7 @@ export interface ApiUserMessage {
318
337
  respondent: string
319
338
  user: string
320
339
  text: string
340
+ type?: 'in' | 'out'
321
341
  unread: boolean
322
342
  }
323
343
 
@@ -329,12 +349,12 @@ export interface ApiUserMessagesListResponse {
329
349
  export interface ApiUserMessagesIndexEntry {
330
350
  _id: string
331
351
  message: ApiUserMessage
332
- user: { _id: string; username: string; badge: import('./game.js').Badge }
333
352
  }
334
353
 
335
354
  export interface ApiUserMessagesIndexResponse {
336
355
  ok: number
337
- list: ApiUserMessagesIndexEntry[]
356
+ messages: ApiUserMessagesIndexEntry[]
357
+ users: Record<string, { _id: string; username: string; badge: import('./game.js').Badge }>
338
358
  }
339
359
 
340
360
  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' }) })