screeps-connectivity 0.10.0 → 0.12.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.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8fd1a08: Fix Steam login failing with "auth failed" on brand-new accounts (e.g. xxscreeps). Some servers hand back a provisional token for a first-time OAuth signup that can't authenticate the websocket until a username is chosen; the login flow now detects this via `/api/auth/me` and prompts for a username before connecting. Adds `fetchAuthMeWithToken` and `completeProviderRegistration` to `screeps-connectivity`.
8
+ - b26940a: xxscreeps-mod-client now publishes an `xxscreeps-mod-client` server feature at `/api/version` reflecting `.screepsrc.yaml`'s `backend.allowGuestAccess`, `backend.allowEmailRegistration`, and `backend.steamApiKey`. screeps-client reads this via the new `getXxscreepsModClientFeature` helper (screeps-connectivity) to show or hide the Guest, "Create account", and "Login with Steam" options to match what the server actually allows, instead of guessing.
9
+
10
+ ### Patch Changes
11
+
12
+ - 594073a: Fix cache/storage namespace collision when two distinct game worlds are hosted under the same domain via a path (e.g. Screeps World vs Screeps Season on screeps.com) — terrain and other cached data no longer bleed between them.
13
+ - 15d0c1f: Show a dismissable popup (reload/logout) instead of silently bouncing to the login screen when an already-connected session hits a fatal socket error or disconnect. Add a similar popup for 429 rate-limit responses from official servers, with a button to open the server's "disable rate limiting" link (opens the OS browser in the desktop app). Fix the map view not reloading terrain when switching shards — room names collide across shards, so the renderer's terrain cache was serving stale terrain from the previously viewed shard. Also removes the 5-minute sessionStorage cache on `fetchServerVersion` so the pre-login welcome screen always reflects the server's current `/api/version`.
14
+
15
+ ## 0.11.0
16
+
17
+ ### Minor Changes
18
+
19
+ - 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.
20
+
21
+ 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.
22
+
3
23
  ## 0.10.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.10.0",
3
+ "version": "0.12.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "repository": {
@@ -64,7 +64,12 @@ export class ScreepsClient {
64
64
  constructor(opts: ScreepsClientOptions) {
65
65
  let namespace: string
66
66
  try {
67
- namespace = new URL(opts.url).hostname
67
+ const parsed = new URL(opts.url)
68
+ const path = parsed.pathname.replace(/\/+$/, '')
69
+ // Hostname alone isn't enough: some servers host multiple distinct worlds under one
70
+ // domain via a path (e.g. Screeps World at screeps.com vs Screeps Season at
71
+ // screeps.com/season) — including the path keeps their caches from colliding.
72
+ namespace = path ? `${parsed.hostname}${path}` : parsed.hostname
68
73
  } catch {
69
74
  throw new TypeError(`ScreepsClient: invalid url "${opts.url}"`)
70
75
  }
@@ -83,7 +88,7 @@ export class ScreepsClient {
83
88
  user: new UserStore(this.http, this.socket, this.cache, this.logger.child('user')),
84
89
  server: new ServerStore(this.http, this.socket, this.cache, this.logger.child('server')),
85
90
  map: new MapStore(this.socket, map2Storage, { maxSubscriptions: opts.map2?.maxSubscriptions ?? 500 }, this.logger.child('map')),
86
- mapStats: new MapStatsStore(this.http, 100, 500, this.logger.child('mapStats')),
91
+ mapStats: new MapStatsStore(this.http, 100, 100, this.logger.child('mapStats')),
87
92
  navigation: new NavigationStore(50, this.logger.child('navigation')),
88
93
  }
89
94
 
@@ -1,6 +1,6 @@
1
1
  import type { ServerVersion } from '../types/game.js'
2
- import type { ScreepsmodAuthFeature, ServerFeature } from '../types/game.js'
3
- import type { ApiAuthModInfoResponse, ApiRegisterCheckResponse } from '../types/api.js'
2
+ import type { ScreepsmodAuthFeature, ServerFeature, XxscreepsModClientFeature } from '../types/game.js'
3
+ import type { ApiAuthMeResponse, ApiAuthModInfoResponse, ApiRegisterCheckResponse } from '../types/api.js'
4
4
  import { getFetch } from './fetchFn.js'
5
5
 
6
6
  export type { ApiAuthModInfoResponse }
@@ -49,19 +49,12 @@ function baseUrl(url: string): string {
49
49
 
50
50
  /**
51
51
  * Fetch `/api/version` from a Screeps server without authentication.
52
- * The result is cached in `sessionStorage` for 5 minutes (per server hostname).
53
52
  * Useful for pre-login UI: showing the welcome text and detecting installed mods.
54
53
  */
55
54
  export async function fetchServerVersion(url: string): Promise<ServerVersion> {
56
- const key = sessionKey('version', url)
57
- const cached = readFromSession<ServerVersion>(key)
58
- if (cached) return cached
59
-
60
55
  const res = await getFetch()(`${baseUrl(url)}api/version`)
61
56
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
62
- const data = await res.json() as ServerVersion
63
- writeToSession(key, data)
64
- return data
57
+ return res.json() as Promise<ServerVersion>
65
58
  }
66
59
 
67
60
  /**
@@ -123,6 +116,42 @@ export async function registerUser(
123
116
  return res.json() as Promise<{ ok: number; error?: string }>
124
117
  }
125
118
 
119
+ /**
120
+ * Fetch `/api/auth/me` using a bare token, without going through a full `ScreepsClient`.
121
+ * Used right after an OAuth popup (Steam, GitHub, ...) hands back a token, to check whether
122
+ * the account still needs to finish registration — servers like xxscreeps issue a provisional
123
+ * token for brand-new provider signups that has no `username` yet until one is chosen.
124
+ */
125
+ export async function fetchAuthMeWithToken(url: string, token: string): Promise<ApiAuthMeResponse | null> {
126
+ const res = await getFetch()(`${baseUrl(url)}api/auth/me`, {
127
+ headers: { 'X-Token': token, 'X-Username': token },
128
+ })
129
+ if (!res.ok) return null
130
+ return res.json() as Promise<ApiAuthMeResponse>
131
+ }
132
+
133
+ /**
134
+ * Finish an OAuth provider signup (Steam, GitHub, ...) by choosing a username for the
135
+ * provisional account. Requires the provisional token handed back from the OAuth popup.
136
+ * Returns the upgraded token (a real, non-expiring-in-2-minutes user token) taken from the
137
+ * `X-Token` response header — the provisional token cannot authenticate anything else.
138
+ */
139
+ export async function completeProviderRegistration(
140
+ url: string,
141
+ token: string,
142
+ username: string,
143
+ email?: string,
144
+ ): Promise<{ token: string }> {
145
+ const res = await getFetch()(`${baseUrl(url)}api/register/set-username`, {
146
+ method: 'POST',
147
+ headers: { 'Content-Type': 'application/json', 'X-Token': token, 'X-Username': token },
148
+ body: JSON.stringify({ username, ...(email ? { email } : {}) }),
149
+ })
150
+ const data = await res.json() as { ok?: number; error?: string }
151
+ if (!res.ok || data.error) throw new Error(data.error ?? `HTTP ${res.status}`)
152
+ return { token: res.headers.get('x-token') ?? token }
153
+ }
154
+
126
155
  /**
127
156
  * Extract a specific feature entry from a `ServerVersion` response by name.
128
157
  * Returns `undefined` if the feature is not present.
@@ -150,3 +179,17 @@ export function getServerFeature<T extends ServerFeature = ServerFeature>(
150
179
  export function getScreepsmodAuth(version: ServerVersion): ScreepsmodAuthFeature | undefined {
151
180
  return getServerFeature<ScreepsmodAuthFeature>(version, 'screepsmod-auth')
152
181
  }
182
+
183
+ /**
184
+ * Returns the `xxscreeps-mod-client` feature entry if present, otherwise `undefined`.
185
+ * This feature reflects the server's `.screepsrc.yaml` `backend` settings
186
+ * (`allowGuestAccess`, `allowEmailRegistration`, `steamApiKey`), letting the client
187
+ * gate guest/registration/Steam UI without an extra request.
188
+ *
189
+ * @example
190
+ * const caps = getXxscreepsModClientFeature(version)
191
+ * if (caps && !caps.allowGuestAccess) hideGuestButton()
192
+ */
193
+ export function getXxscreepsModClientFeature(version: ServerVersion): XxscreepsModClientFeature | undefined {
194
+ return getServerFeature<XxscreepsModClientFeature>(version, 'xxscreeps-mod-client')
195
+ }
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ export type {
32
32
  ServerVersion,
33
33
  ServerFeature,
34
34
  ScreepsmodAuthFeature,
35
+ XxscreepsModClientFeature,
35
36
  ShardInfo,
36
37
  WorldInfo,
37
38
  Badge,
@@ -40,9 +41,9 @@ export type {
40
41
  MapVisualEntry,
41
42
  } from './types/game.js'
42
43
 
43
- export { fetchServerVersion, fetchAuthModInfo, checkUsername, checkEmail, registerUser, getServerFeature, getScreepsmodAuth } from './http/fetchServerVersion.js'
44
+ export { fetchServerVersion, fetchAuthModInfo, checkUsername, checkEmail, registerUser, fetchAuthMeWithToken, completeProviderRegistration, getServerFeature, getScreepsmodAuth, getXxscreepsModClientFeature } from './http/fetchServerVersion.js'
44
45
  export type { ApiAuthModInfoResponse } from './http/fetchServerVersion.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'
46
+ export type { ApiAuthMeResponse, 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'
46
47
  export { ROOM_DECORATIONS_MOCK } from './mocks/roomDecorations.js'
47
48
 
48
49
  export { badgeToSvg } from './badge/index.js'
@@ -60,3 +61,6 @@ export type { UserStore } from './stores/UserStore.js'
60
61
  export type { ServerStore } from './stores/ServerStore.js'
61
62
  export type { MapStore, Map2Subscription, MapStoreOptions } from './stores/MapStore.js'
62
63
  export type { NavigationStore, NavigationState, NavigationStoreEvents } from './stores/NavigationStore.js'
64
+ export { MapStatName, MapStatPrefix, MapStatInterval, mapStat } from './stores/MapStatsStore.js'
65
+ export type { MapStatsRoomData, MapStatsStoreEvents, TerrainColors } from './stores/MapStatsStore.js'
66
+
@@ -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 {
package/src/types/game.ts CHANGED
@@ -113,6 +113,18 @@ export interface ScreepsmodAuthFeature extends ServerFeature {
113
113
  }>
114
114
  }
115
115
 
116
+ /**
117
+ * Published by `xxscreeps-mod-client` at `/api/version`, reflecting the
118
+ * `backend` settings from the server's `.screepsrc.yaml`.
119
+ */
120
+ export interface XxscreepsModClientFeature extends ServerFeature {
121
+ name: 'xxscreeps-mod-client'
122
+ version: number
123
+ allowGuestAccess: boolean
124
+ allowEmailRegistration: boolean
125
+ steamLogin: boolean
126
+ }
127
+
116
128
  export interface ServerVersion {
117
129
  ok: number
118
130
  package: number
@@ -1,6 +1,15 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
2
  import { ScreepsClient } from '../src/ScreepsClient.js'
3
3
  import { TokenAuth } from '../src/http/auth/TokenAuth.js'
4
+ import type { StorageAdapter } from '../src/storage/StorageAdapter.js'
5
+
6
+ class MemoryStorage implements StorageAdapter {
7
+ readonly data = new Map<string, Uint8Array>()
8
+ async get(key: string): Promise<Uint8Array | null> { return this.data.get(key) ?? null }
9
+ async set(key: string, value: Uint8Array): Promise<void> { this.data.set(key, value) }
10
+ async delete(key: string): Promise<void> { this.data.delete(key) }
11
+ async clear(): Promise<void> { this.data.clear() }
12
+ }
4
13
 
5
14
  class MockWS {
6
15
  static instances: MockWS[] = []
@@ -38,6 +47,49 @@ beforeEach(() => {
38
47
  })
39
48
  afterEach(() => { vi.unstubAllGlobals() })
40
49
 
50
+ describe('ScreepsClient — cache namespace', () => {
51
+ it('does not collide between two worlds hosted under the same hostname (e.g. Screeps World vs Season)', async () => {
52
+ vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => {
53
+ const { pathname } = new URL(url)
54
+ const digit = pathname.startsWith('/season/') ? '1' : '0'
55
+ return Promise.resolve(
56
+ new Response(JSON.stringify({ ok: 1, terrain: [{ _id: 'id', room: 'W5N5', terrain: digit.repeat(2500), type: 'terrain' }] }), {
57
+ headers: { 'content-type': 'application/json' },
58
+ })
59
+ )
60
+ }))
61
+
62
+ const storage = new MemoryStorage()
63
+ const worldClient = new ScreepsClient({
64
+ url: 'https://test.local',
65
+ auth: new TokenAuth({ token: 'tok' }),
66
+ storage,
67
+ WebSocket: MockWS as unknown as typeof WebSocket,
68
+ })
69
+ const seasonClient = new ScreepsClient({
70
+ url: 'https://test.local/season',
71
+ auth: new TokenAuth({ token: 'tok' }),
72
+ storage,
73
+ WebSocket: MockWS as unknown as typeof WebSocket,
74
+ })
75
+
76
+ const worldTerrain = await worldClient.stores.room.terrain('W5N5', 'shard0')
77
+ const seasonTerrain = await seasonClient.stores.room.terrain('W5N5', 'shard0')
78
+
79
+ // Same room name + shard on both worlds must resolve to distinct terrain and
80
+ // distinct persisted cache keys — the path (/season) has to disambiguate them
81
+ // since hostname alone is identical.
82
+ expect(worldTerrain.get(0, 0)).toBe(0)
83
+ expect(seasonTerrain.get(0, 0)).toBe(1)
84
+ expect([...storage.data.keys()]).toEqual([
85
+ 'test.local/terrain/shard0/W5N5',
86
+ 'test.local/season/terrain/shard0/W5N5',
87
+ ])
88
+
89
+ vi.unstubAllGlobals()
90
+ })
91
+ })
92
+
41
93
  describe('ScreepsClient', () => {
42
94
  it('exposes http, socket, and stores properties', () => {
43
95
  const client = new ScreepsClient({