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 +20 -0
- package/package.json +1 -1
- package/src/ScreepsClient.ts +20 -30
- package/src/http/HttpClient.ts +1 -1
- package/src/http/auth/AuthStrategy.ts +2 -0
- package/src/http/auth/TokenAuth.ts +1 -0
- package/src/http/endpoints/leaderboard.ts +5 -3
- package/src/http/endpoints/user.ts +4 -10
- package/src/index.ts +5 -1
- package/src/stores/MapStatsStore.ts +53 -14
- package/src/types/api.ts +43 -3
- package/src/types/game.ts +9 -0
- package/tests/ScreepsClient.test.ts +62 -14
- package/tests/http/HttpClient.test.ts +25 -2
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
package/src/ScreepsClient.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
*
|
|
39
|
-
*
|
|
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,
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
this.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
},
|
|
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]
|
|
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]
|
|
155
|
+
this.logger.log('[screeps:client] world status refresh failed', err)
|
|
166
156
|
} finally {
|
|
167
157
|
this.refreshInFlight = false
|
|
168
158
|
}
|
package/src/http/HttpClient.ts
CHANGED
|
@@ -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<
|
|
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
|
-
|
|
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<
|
|
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:
|
|
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:
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 —
|
|
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:
|
|
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('
|
|
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
|
-
|
|
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('
|
|
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
|
-
//
|
|
193
|
-
for (let i = 0; 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(
|
|
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).
|
|
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/
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
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' }) })
|