screeps-connectivity 0.2.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 +40 -0
- package/eslint.config.js +54 -0
- package/package.json +45 -0
- package/src/ScreepsClient.ts +172 -0
- package/src/badge/colors.ts +83 -0
- package/src/badge/generateSvg.ts +70 -0
- package/src/badge/index.ts +1 -0
- package/src/badge/paths.ts +385 -0
- package/src/cache/Cache.ts +71 -0
- package/src/cache/Map2Storage.ts +112 -0
- package/src/file-storage.ts +2 -0
- package/src/http/HttpClient.ts +160 -0
- package/src/http/auth/AuthStrategy.ts +5 -0
- package/src/http/auth/GuestAuth.ts +13 -0
- package/src/http/auth/PasswordAuth.ts +17 -0
- package/src/http/auth/SteamTicketAuth.ts +17 -0
- package/src/http/auth/TokenAuth.ts +14 -0
- package/src/http/decompress.ts +37 -0
- package/src/http/endpoints/auth.ts +23 -0
- package/src/http/endpoints/experimental.ts +13 -0
- package/src/http/endpoints/game.ts +103 -0
- package/src/http/endpoints/leaderboard.ts +16 -0
- package/src/http/endpoints/power-creeps.ts +24 -0
- package/src/http/endpoints/register.ts +19 -0
- package/src/http/endpoints/user-messages.ts +20 -0
- package/src/http/endpoints/user.ts +95 -0
- package/src/http/fetchServerVersion.ts +151 -0
- package/src/index.ts +55 -0
- package/src/logger.ts +25 -0
- package/src/socket/MessageParser.ts +44 -0
- package/src/socket/SocketClient.ts +203 -0
- package/src/storage/FileStorage.ts +44 -0
- package/src/storage/IndexedDBStorage.ts +77 -0
- package/src/storage/NullStorage.ts +8 -0
- package/src/storage/StorageAdapter.ts +6 -0
- package/src/stores/MapStatsStore.ts +115 -0
- package/src/stores/MapStore.ts +254 -0
- package/src/stores/NavigationStore.ts +61 -0
- package/src/stores/RoomStore.ts +264 -0
- package/src/stores/ServerStore.ts +128 -0
- package/src/stores/TypedStore.ts +31 -0
- package/src/stores/UserStore.ts +189 -0
- package/src/subscription/index.ts +18 -0
- package/src/types/api.ts +252 -0
- package/src/types/events.ts +72 -0
- package/src/types/game.ts +160 -0
- package/tests/.gitkeep +0 -0
- package/tests/ScreepsClient.test.ts +229 -0
- package/tests/badge/generateSvg.test.ts +174 -0
- package/tests/cache/Cache.test.ts +99 -0
- package/tests/cache/Map2Storage.test.ts +130 -0
- package/tests/http/HttpClient.test.ts +188 -0
- package/tests/http/decompress.test.ts +52 -0
- package/tests/http/endpoints/auth.test.ts +126 -0
- package/tests/http/endpoints/game.test.ts +210 -0
- package/tests/http/endpoints/power-creeps.test.ts +81 -0
- package/tests/http/endpoints/user-messages.test.ts +68 -0
- package/tests/http/endpoints/user.test.ts +139 -0
- package/tests/socket/MessageParser.test.ts +55 -0
- package/tests/socket/SocketClient.test.ts +144 -0
- package/tests/storage/FileStorage.test.ts +64 -0
- package/tests/storage/IndexedDBStorage.test.ts +36 -0
- package/tests/storage/NullStorage.test.ts +24 -0
- package/tests/stores/MapStatsStore.test.ts +234 -0
- package/tests/stores/MapStore.test.ts +537 -0
- package/tests/stores/NavigationStore.test.ts +166 -0
- package/tests/stores/RoomStore.test.ts +130 -0
- package/tests/stores/ServerStore.test.ts +48 -0
- package/tests/stores/TypedStore.test.ts +54 -0
- package/tests/stores/UserStore.test.ts +136 -0
- package/tests/subscription/SubscriptionGroup.test.ts +34 -0
- package/tests/types/game.test.ts +42 -0
- package/tsconfig.json +17 -0
- package/tsup.config.ts +9 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
export interface RoomMap2Data {
|
|
2
|
+
w?: [number, number][] | null // player-built walls / ramparts
|
|
3
|
+
r?: [number, number][] | null // roads
|
|
4
|
+
pb?: [number, number][] | null // power banks / power
|
|
5
|
+
p?: [number, number][] | null // portals
|
|
6
|
+
s?: [number, number][] | null // sources
|
|
7
|
+
c?: [number, number][] | null // controllers
|
|
8
|
+
m?: [number, number][] | null // minerals
|
|
9
|
+
k?: [number, number][] | null // source keeper lairs
|
|
10
|
+
[userId: string]: [number, number][] | null | undefined // structures + creeps for that user
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export enum TerrainType {
|
|
14
|
+
Plain = 0,
|
|
15
|
+
Wall = 1,
|
|
16
|
+
Swamp = 2,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class RoomTerrain {
|
|
20
|
+
readonly raw: Uint8Array
|
|
21
|
+
|
|
22
|
+
constructor(data: Uint8Array) {
|
|
23
|
+
this.raw = data
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static fromEncodedString(encoded: string): RoomTerrain {
|
|
27
|
+
const data = new Uint8Array(2500)
|
|
28
|
+
for (let i = 0; i < 2500; i++) {
|
|
29
|
+
const v = parseInt(encoded[i], 10)
|
|
30
|
+
data[i] = v === 3 ? TerrainType.Wall : (v as TerrainType)
|
|
31
|
+
}
|
|
32
|
+
return new RoomTerrain(data)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get(x: number, y: number): TerrainType {
|
|
36
|
+
return this.raw[y * 50 + x] as TerrainType
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface Badge {
|
|
41
|
+
type: number | { path1: string; path2: string }
|
|
42
|
+
color1: string | number
|
|
43
|
+
color2: string | number
|
|
44
|
+
color3: string | number
|
|
45
|
+
param?: number
|
|
46
|
+
flip: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface RoomObject {
|
|
50
|
+
_id: string
|
|
51
|
+
type: string
|
|
52
|
+
room: string
|
|
53
|
+
x: number
|
|
54
|
+
y: number
|
|
55
|
+
|
|
56
|
+
[key: string]: unknown
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type RoomObjectMap = Record<string, RoomObject>
|
|
60
|
+
export type RoomObjectDiff = Record<string, Partial<RoomObject> | null>
|
|
61
|
+
|
|
62
|
+
export interface UserInfo {
|
|
63
|
+
_id: string
|
|
64
|
+
username: string
|
|
65
|
+
email: string
|
|
66
|
+
cpu: number
|
|
67
|
+
gcl: number
|
|
68
|
+
credits: number
|
|
69
|
+
badge: Badge
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface CpuStats {
|
|
73
|
+
cpu: number
|
|
74
|
+
memory: number
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type WorldStatus = 'normal' | 'lost' | 'empty'
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
export interface ConsoleMessage {
|
|
81
|
+
log: string[]
|
|
82
|
+
results: string[]
|
|
83
|
+
error: string[]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface ServerFeature {
|
|
87
|
+
name: string
|
|
88
|
+
version?: string | number
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface ScreepsmodAuthFeature extends ServerFeature {
|
|
92
|
+
name: 'screepsmod-auth'
|
|
93
|
+
version: string
|
|
94
|
+
authTypes: Array<'password' | 'steam' | 'github' | 'gitlab' | string>
|
|
95
|
+
menuData?: Array<{
|
|
96
|
+
section: number
|
|
97
|
+
start: number
|
|
98
|
+
item: { label: string; href: string }
|
|
99
|
+
}>
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface ServerVersion {
|
|
103
|
+
ok: number
|
|
104
|
+
package: number
|
|
105
|
+
protocol: number
|
|
106
|
+
useNativeAuth?: boolean
|
|
107
|
+
users: number
|
|
108
|
+
serverData: {
|
|
109
|
+
historyChunkSize: number
|
|
110
|
+
features: ServerFeature[]
|
|
111
|
+
shards: Array<string | null>
|
|
112
|
+
welcomeText?: string
|
|
113
|
+
socketUpdateThrottle?: number
|
|
114
|
+
customObjectTypes?: unknown
|
|
115
|
+
renderer?: unknown
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface ShardInfo {
|
|
120
|
+
name: string
|
|
121
|
+
lastTicks: number[]
|
|
122
|
+
cpuLimit: number
|
|
123
|
+
rooms: number
|
|
124
|
+
users: number
|
|
125
|
+
tick: number
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface WorldInfo {
|
|
129
|
+
shard: string | null
|
|
130
|
+
width: number
|
|
131
|
+
height: number
|
|
132
|
+
// Inclusive coordinate bounds of valid rooms.
|
|
133
|
+
// Uses the internal system where W0 = x = -1, E0 = x = 0, N0 = y = -1, S0 = y = 0.
|
|
134
|
+
minX: number
|
|
135
|
+
maxX: number
|
|
136
|
+
minY: number
|
|
137
|
+
maxY: number
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface VisualStyle {
|
|
141
|
+
opacity?: number
|
|
142
|
+
fill?: string
|
|
143
|
+
stroke?: string
|
|
144
|
+
strokeWidth?: number
|
|
145
|
+
color?: string
|
|
146
|
+
backgroundColor?: string
|
|
147
|
+
backgroundPadding?: number
|
|
148
|
+
align?: 'center' | 'left' | 'right'
|
|
149
|
+
lineStyle?: 'dashed' | 'dotted' | 'solid'
|
|
150
|
+
width?: number
|
|
151
|
+
radius?: number
|
|
152
|
+
font?: string | number
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export type RoomVisualEntry =
|
|
156
|
+
| { t: 't'; x: number; y: number; text: string; s: VisualStyle }
|
|
157
|
+
| { t: 'c'; x: number; y: number; s: VisualStyle }
|
|
158
|
+
| { t: 'r'; x: number; y: number; w: number; h: number; s: VisualStyle }
|
|
159
|
+
| { t: 'p'; points: [number, number][]; s: VisualStyle }
|
|
160
|
+
| { t: 'l'; x1: number; y1: number; x2: number; y2: number; s: VisualStyle }
|
package/tests/.gitkeep
ADDED
|
File without changes
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { ScreepsClient } from '../src/ScreepsClient.js'
|
|
3
|
+
import { TokenAuth } from '../src/http/auth/TokenAuth.js'
|
|
4
|
+
|
|
5
|
+
class MockWS {
|
|
6
|
+
static instances: MockWS[] = []
|
|
7
|
+
onopen: (() => void) | null = null
|
|
8
|
+
onclose: (() => void) | null = null
|
|
9
|
+
onerror: ((e: unknown) => void) | null = null
|
|
10
|
+
onmessage: ((e: MessageEvent) => void) | null = null
|
|
11
|
+
sent: string[] = []
|
|
12
|
+
constructor() { MockWS.instances.push(this) }
|
|
13
|
+
send(d: string) { this.sent.push(d) }
|
|
14
|
+
close() {}
|
|
15
|
+
simulateOpen() { this.onopen?.() }
|
|
16
|
+
simulateMessage(d: string) { this.onmessage?.({ data: d } as MessageEvent) }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
MockWS.instances = []
|
|
21
|
+
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => {
|
|
22
|
+
const path = new URL(url).pathname
|
|
23
|
+
|
|
24
|
+
if (path === '/api/user/world-status') {
|
|
25
|
+
return Promise.resolve(
|
|
26
|
+
new Response(JSON.stringify({ ok: 1, status: 'normal' }), {
|
|
27
|
+
headers: { 'content-type': 'application/json' },
|
|
28
|
+
})
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return Promise.resolve(
|
|
33
|
+
new Response(JSON.stringify({ ok: 1, token: 'authed', _id: 'uid1', username: 'user', serverData: { features: [], shards: [] } }), {
|
|
34
|
+
headers: { 'content-type': 'application/json' },
|
|
35
|
+
})
|
|
36
|
+
)
|
|
37
|
+
}))
|
|
38
|
+
})
|
|
39
|
+
afterEach(() => { vi.unstubAllGlobals() })
|
|
40
|
+
|
|
41
|
+
describe('ScreepsClient', () => {
|
|
42
|
+
it('exposes http, socket, and stores properties', () => {
|
|
43
|
+
const client = new ScreepsClient({
|
|
44
|
+
url: 'http://test.local',
|
|
45
|
+
auth: new TokenAuth({ token: 'tok' }),
|
|
46
|
+
storage: null,
|
|
47
|
+
WebSocket: MockWS as unknown as typeof WebSocket,
|
|
48
|
+
})
|
|
49
|
+
expect(client.http).toBeDefined()
|
|
50
|
+
expect(client.socket).toBeDefined()
|
|
51
|
+
expect(client.stores.room).toBeDefined()
|
|
52
|
+
expect(client.stores.user).toBeDefined()
|
|
53
|
+
expect(client.stores.server).toBeDefined()
|
|
54
|
+
expect(client.stores.map).toBeDefined()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('connect() authenticates then opens WebSocket', async () => {
|
|
58
|
+
const client = new ScreepsClient({
|
|
59
|
+
url: 'http://test.local',
|
|
60
|
+
auth: new TokenAuth({ token: 'tok' }),
|
|
61
|
+
storage: null,
|
|
62
|
+
WebSocket: MockWS as unknown as typeof WebSocket,
|
|
63
|
+
})
|
|
64
|
+
const connectPromise = client.connect()
|
|
65
|
+
await new Promise(r => setTimeout(r, 0))
|
|
66
|
+
const ws = MockWS.instances[0]
|
|
67
|
+
ws.simulateOpen()
|
|
68
|
+
ws.simulateMessage('auth ok tok')
|
|
69
|
+
await connectPromise
|
|
70
|
+
expect(client.isConnected).toBe(true)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('isConnected is false before connect()', () => {
|
|
74
|
+
const client = new ScreepsClient({
|
|
75
|
+
url: 'http://test.local',
|
|
76
|
+
auth: new TokenAuth({ token: 'tok' }),
|
|
77
|
+
storage: null,
|
|
78
|
+
WebSocket: MockWS as unknown as typeof WebSocket,
|
|
79
|
+
})
|
|
80
|
+
expect(client.isConnected).toBe(false)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('connect() fetches user info, world status, and server version', async () => {
|
|
84
|
+
const fetchMock = fetch as unknown as ReturnType<typeof vi.fn>
|
|
85
|
+
const client = new ScreepsClient({
|
|
86
|
+
url: 'http://test.local',
|
|
87
|
+
auth: new TokenAuth({ token: 'tok' }),
|
|
88
|
+
storage: null,
|
|
89
|
+
WebSocket: MockWS as unknown as typeof WebSocket,
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const connectPromise = client.connect()
|
|
93
|
+
await new Promise(r => setTimeout(r, 0))
|
|
94
|
+
const ws = MockWS.instances[0]
|
|
95
|
+
ws.simulateOpen()
|
|
96
|
+
ws.simulateMessage('auth ok tok')
|
|
97
|
+
await connectPromise
|
|
98
|
+
|
|
99
|
+
const paths = fetchMock.mock.calls.map(([url]) => new URL(url as string).pathname)
|
|
100
|
+
expect(paths).toContain('/api/auth/me')
|
|
101
|
+
expect(paths).toContain('/api/user/world-status')
|
|
102
|
+
expect(paths).toContain('/api/version')
|
|
103
|
+
expect(client.stores.user.worldStatusValue).toBe('normal')
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('ScreepsClient — token sync', () => {
|
|
108
|
+
it('rotates SocketClient token when an HTTP response carries x-token', async () => {
|
|
109
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
|
110
|
+
new Response(JSON.stringify({ ok: 1 }), {
|
|
111
|
+
headers: { 'content-type': 'application/json', 'x-token': 'http-rotated-token' },
|
|
112
|
+
})
|
|
113
|
+
))
|
|
114
|
+
|
|
115
|
+
const client = new ScreepsClient({
|
|
116
|
+
url: 'http://test.local',
|
|
117
|
+
auth: new TokenAuth({ token: 'initial' }),
|
|
118
|
+
storage: null,
|
|
119
|
+
WebSocket: MockWS as unknown as typeof WebSocket,
|
|
120
|
+
tokenRefresh: false,
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const socketSetToken = vi.spyOn(client.socket, 'setToken')
|
|
124
|
+
await client.http.request('GET', '/api/auth/me')
|
|
125
|
+
|
|
126
|
+
expect(socketSetToken).toHaveBeenCalledWith('http-rotated-token')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('rotates HttpClient token when WS auth response carries a new token', async () => {
|
|
130
|
+
const client = new ScreepsClient({
|
|
131
|
+
url: 'http://test.local',
|
|
132
|
+
auth: new TokenAuth({ token: 'initial' }),
|
|
133
|
+
storage: null,
|
|
134
|
+
WebSocket: MockWS as unknown as typeof WebSocket,
|
|
135
|
+
tokenRefresh: false,
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
const httpSetToken = vi.spyOn(client.http, 'setToken')
|
|
139
|
+
|
|
140
|
+
const connectPromise = client.connect()
|
|
141
|
+
await new Promise(r => setTimeout(r, 0))
|
|
142
|
+
const ws = MockWS.instances[0]
|
|
143
|
+
ws.simulateOpen()
|
|
144
|
+
ws.simulateMessage('auth ok ws-rotated-token')
|
|
145
|
+
await connectPromise
|
|
146
|
+
|
|
147
|
+
expect(httpSetToken).toHaveBeenCalledWith('ws-rotated-token')
|
|
148
|
+
expect(client.http.token).toBe('ws-rotated-token')
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
describe('ScreepsClient — idle token refresh', () => {
|
|
153
|
+
beforeEach(() => { vi.useFakeTimers() })
|
|
154
|
+
afterEach(() => { vi.useRealTimers() })
|
|
155
|
+
|
|
156
|
+
async function buildConnected(opts: { tokenRefresh?: { intervalMs?: number } | false } = {}) {
|
|
157
|
+
const client = new ScreepsClient({
|
|
158
|
+
url: 'http://test.local',
|
|
159
|
+
auth: new TokenAuth({ token: 'tok' }),
|
|
160
|
+
storage: null,
|
|
161
|
+
WebSocket: MockWS as unknown as typeof WebSocket,
|
|
162
|
+
tokenRefresh: opts.tokenRefresh,
|
|
163
|
+
})
|
|
164
|
+
const connectPromise = client.connect()
|
|
165
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
166
|
+
const ws = MockWS.instances[0]
|
|
167
|
+
ws.simulateOpen()
|
|
168
|
+
ws.simulateMessage('auth ok tok')
|
|
169
|
+
await connectPromise
|
|
170
|
+
return client
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
it('issues a world-status call after intervalMs of HTTP idleness', async () => {
|
|
174
|
+
const client = await buildConnected({ tokenRefresh: { intervalMs: 1_000 } })
|
|
175
|
+
const fetchMock = fetch as unknown as ReturnType<typeof vi.fn>
|
|
176
|
+
fetchMock.mockClear()
|
|
177
|
+
|
|
178
|
+
// Idle for 1.5s — exceeds 1s interval, refresh should fire on next tick (every 500ms).
|
|
179
|
+
await vi.advanceTimersByTimeAsync(1_500)
|
|
180
|
+
|
|
181
|
+
const paths = fetchMock.mock.calls.map(([url]) => new URL(url as string).pathname)
|
|
182
|
+
expect(paths).toContain('/api/user/world-status')
|
|
183
|
+
|
|
184
|
+
client.disconnect()
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('does NOT issue an auth/me call while HTTP traffic resets the idle clock', async () => {
|
|
188
|
+
const client = await buildConnected({ tokenRefresh: { intervalMs: 1_000 } })
|
|
189
|
+
const fetchMock = fetch as unknown as ReturnType<typeof vi.fn>
|
|
190
|
+
fetchMock.mockClear()
|
|
191
|
+
|
|
192
|
+
// Make a request every 400ms, well below the 1s threshold.
|
|
193
|
+
for (let i = 0; i < 5; i++) {
|
|
194
|
+
await client.http.request('GET', '/api/version')
|
|
195
|
+
await vi.advanceTimersByTimeAsync(400)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const paths = fetchMock.mock.calls.map(([url]) => new URL(url as string).pathname)
|
|
199
|
+
expect(paths).not.toContain('/api/auth/me')
|
|
200
|
+
|
|
201
|
+
client.disconnect()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('does not start the refresh timer when tokenRefresh is false', async () => {
|
|
205
|
+
const client = await buildConnected({ tokenRefresh: false })
|
|
206
|
+
const fetchMock = fetch as unknown as ReturnType<typeof vi.fn>
|
|
207
|
+
fetchMock.mockClear()
|
|
208
|
+
|
|
209
|
+
await vi.advanceTimersByTimeAsync(60_000)
|
|
210
|
+
|
|
211
|
+
const paths = fetchMock.mock.calls.map(([url]) => new URL(url as string).pathname)
|
|
212
|
+
expect(paths).not.toContain('/api/auth/me')
|
|
213
|
+
|
|
214
|
+
client.disconnect()
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('stops the refresh timer on disconnect()', async () => {
|
|
218
|
+
const client = await buildConnected({ tokenRefresh: { intervalMs: 1_000 } })
|
|
219
|
+
client.disconnect()
|
|
220
|
+
|
|
221
|
+
const fetchMock = fetch as unknown as ReturnType<typeof vi.fn>
|
|
222
|
+
fetchMock.mockClear()
|
|
223
|
+
|
|
224
|
+
await vi.advanceTimersByTimeAsync(5_000)
|
|
225
|
+
|
|
226
|
+
const paths = fetchMock.mock.calls.map(([url]) => new URL(url as string).pathname)
|
|
227
|
+
expect(paths).not.toContain('/api/auth/me')
|
|
228
|
+
})
|
|
229
|
+
})
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { badgeToSvg } from '../../src/badge/generateSvg.js'
|
|
3
|
+
import type { Badge } from '../../src/types/game.js'
|
|
4
|
+
|
|
5
|
+
describe('badgeToSvg', () => {
|
|
6
|
+
it('generates an SVG for a numeric badge type', () => {
|
|
7
|
+
const badge: Badge = {
|
|
8
|
+
type: 24,
|
|
9
|
+
color1: '#000077',
|
|
10
|
+
color2: '#5555dd',
|
|
11
|
+
color3: '#9999ff',
|
|
12
|
+
param: 0,
|
|
13
|
+
flip: false,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const svg = badgeToSvg(badge)
|
|
17
|
+
expect(svg).toContain('<svg')
|
|
18
|
+
expect(svg).toContain('</svg>')
|
|
19
|
+
expect(svg).toContain('fill="#000077"')
|
|
20
|
+
expect(svg).toContain('fill="#5555dd"')
|
|
21
|
+
expect(svg).toContain('fill="#9999ff"')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('generates an SVG for a custom path badge type', () => {
|
|
25
|
+
const badge: Badge = {
|
|
26
|
+
type: {
|
|
27
|
+
path1: 'M 0 0 L 50 100 L 100 0 Z',
|
|
28
|
+
path2: 'M 0 100 L 50 0 L 100 100 Z',
|
|
29
|
+
},
|
|
30
|
+
color1: '#ff0000',
|
|
31
|
+
color2: '#00ff00',
|
|
32
|
+
color3: '#0000ff',
|
|
33
|
+
flip: false,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const svg = badgeToSvg(badge)
|
|
37
|
+
expect(svg).toContain('d="M 0 0 L 50 100 L 100 0 Z"')
|
|
38
|
+
expect(svg).toContain('d="M 0 100 L 50 0 L 100 100 Z"')
|
|
39
|
+
expect(svg).toContain('fill="#ff0000"')
|
|
40
|
+
expect(svg).toContain('fill="#00ff00"')
|
|
41
|
+
expect(svg).toContain('fill="#0000ff"')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('resolves numeric color indices', () => {
|
|
45
|
+
const badge: Badge = {
|
|
46
|
+
type: 1,
|
|
47
|
+
color1: 0,
|
|
48
|
+
color2: 20,
|
|
49
|
+
color3: 40,
|
|
50
|
+
param: 0,
|
|
51
|
+
flip: false,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const svg = badgeToSvg(badge)
|
|
55
|
+
expect(svg).toContain('fill="#cccccc"')
|
|
56
|
+
expect(svg).toContain('fill="#808080"')
|
|
57
|
+
expect(svg).toContain('fill="#4d4d4d"')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('applies rotation when flip is true for badges with flip property', () => {
|
|
61
|
+
const badge: Badge = {
|
|
62
|
+
type: 3,
|
|
63
|
+
color1: '#ffffff',
|
|
64
|
+
color2: '#000000',
|
|
65
|
+
color3: '#ff0000',
|
|
66
|
+
param: 0,
|
|
67
|
+
flip: true,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const svg = badgeToSvg(badge)
|
|
71
|
+
expect(svg).toContain('rotate(180 50 50)')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('does not rotate when flip is false', () => {
|
|
75
|
+
const badge: Badge = {
|
|
76
|
+
type: 3,
|
|
77
|
+
color1: '#ffffff',
|
|
78
|
+
color2: '#000000',
|
|
79
|
+
color3: '#ff0000',
|
|
80
|
+
param: 0,
|
|
81
|
+
flip: false,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const svg = badgeToSvg(badge)
|
|
85
|
+
expect(svg).toContain('rotate(0 50 50)')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('always renders a black circular border and uses a generous clip radius', () => {
|
|
89
|
+
const badge: Badge = {
|
|
90
|
+
type: 1,
|
|
91
|
+
color1: '#ffffff',
|
|
92
|
+
color2: '#000000',
|
|
93
|
+
color3: '#ff0000',
|
|
94
|
+
param: 0,
|
|
95
|
+
flip: false,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const svg = badgeToSvg(badge)
|
|
99
|
+
expect(svg).toContain('stroke="#000"')
|
|
100
|
+
expect(svg).toContain('r="47.5"')
|
|
101
|
+
expect(svg).toContain('r="50"')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('handles badges without path2', () => {
|
|
105
|
+
const badge: Badge = {
|
|
106
|
+
type: 4,
|
|
107
|
+
color1: '#ffffff',
|
|
108
|
+
color2: '#000000',
|
|
109
|
+
color3: '#ff0000',
|
|
110
|
+
param: -100,
|
|
111
|
+
flip: false,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const svg = badgeToSvg(badge)
|
|
115
|
+
// type 4 with param -100 produces empty path2
|
|
116
|
+
expect(svg).toContain('<svg')
|
|
117
|
+
expect(svg).toContain('</svg>')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('falls back to black for missing colors', () => {
|
|
121
|
+
const badge: Badge = {
|
|
122
|
+
type: 1,
|
|
123
|
+
color1: undefined as unknown as string,
|
|
124
|
+
color2: undefined as unknown as string,
|
|
125
|
+
color3: undefined as unknown as string,
|
|
126
|
+
flip: false,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const svg = badgeToSvg(badge)
|
|
130
|
+
expect(svg).toContain('fill="#000000"')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('generates all 24 badge types without throwing', () => {
|
|
134
|
+
for (let type = 1; type <= 24; type++) {
|
|
135
|
+
const badge: Badge = {
|
|
136
|
+
type,
|
|
137
|
+
color1: '#111111',
|
|
138
|
+
color2: '#222222',
|
|
139
|
+
color3: '#333333',
|
|
140
|
+
param: 0,
|
|
141
|
+
flip: false,
|
|
142
|
+
}
|
|
143
|
+
expect(() => badgeToSvg(badge)).not.toThrow()
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('generates all 24 badge types with param=-100 without throwing', () => {
|
|
148
|
+
for (let type = 1; type <= 24; type++) {
|
|
149
|
+
const badge: Badge = {
|
|
150
|
+
type,
|
|
151
|
+
color1: '#111111',
|
|
152
|
+
color2: '#222222',
|
|
153
|
+
color3: '#333333',
|
|
154
|
+
param: -100,
|
|
155
|
+
flip: false,
|
|
156
|
+
}
|
|
157
|
+
expect(() => badgeToSvg(badge)).not.toThrow()
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('generates all 24 badge types with param=100 without throwing', () => {
|
|
162
|
+
for (let type = 1; type <= 24; type++) {
|
|
163
|
+
const badge: Badge = {
|
|
164
|
+
type,
|
|
165
|
+
color1: '#111111',
|
|
166
|
+
color2: '#222222',
|
|
167
|
+
color3: '#333333',
|
|
168
|
+
param: 100,
|
|
169
|
+
flip: false,
|
|
170
|
+
}
|
|
171
|
+
expect(() => badgeToSvg(badge)).not.toThrow()
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
})
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { Cache } from '../../src/cache/Cache.js'
|
|
3
|
+
import { NullStorage } from '../../src/storage/NullStorage.js'
|
|
4
|
+
|
|
5
|
+
describe('Cache — memory tier', () => {
|
|
6
|
+
it('stores and retrieves a value', () => {
|
|
7
|
+
const cache = new Cache('ns', null)
|
|
8
|
+
cache.set('key', { x: 1 })
|
|
9
|
+
expect(cache.get('key')).toEqual({ x: 1 })
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('returns undefined for missing key', () => {
|
|
13
|
+
const cache = new Cache('ns', null)
|
|
14
|
+
expect(cache.get('missing')).toBeUndefined()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('expires entries after TTL', async () => {
|
|
18
|
+
const cache = new Cache('ns', null)
|
|
19
|
+
cache.set('key', 'val', 1)
|
|
20
|
+
await new Promise(r => setTimeout(r, 10))
|
|
21
|
+
expect(cache.get('key')).toBeUndefined()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('does not expire entries without TTL', async () => {
|
|
25
|
+
const cache = new Cache('ns', null)
|
|
26
|
+
cache.set('key', 'val')
|
|
27
|
+
await new Promise(r => setTimeout(r, 10))
|
|
28
|
+
expect(cache.get('key')).toBe('val')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('namespaces are isolated', () => {
|
|
32
|
+
const c1 = new Cache('ns1', null)
|
|
33
|
+
const c2 = new Cache('ns2', null)
|
|
34
|
+
c1.set('key', 'from-ns1')
|
|
35
|
+
expect(c2.get('key')).toBeUndefined()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('delete removes entry', () => {
|
|
39
|
+
const cache = new Cache('ns', null)
|
|
40
|
+
cache.set('key', 'val')
|
|
41
|
+
cache.delete('key')
|
|
42
|
+
expect(cache.get('key')).toBeUndefined()
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('Cache — persistent tier', () => {
|
|
47
|
+
it('delegates getPersistent with namespaced key', async () => {
|
|
48
|
+
const storage = new NullStorage()
|
|
49
|
+
const spy = vi.spyOn(storage, 'get').mockResolvedValue(null)
|
|
50
|
+
const cache = new Cache('myns', storage)
|
|
51
|
+
await cache.getPersistent('terrain/W7N7')
|
|
52
|
+
expect(spy).toHaveBeenCalledWith('myns/terrain/W7N7')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('delegates setPersistent with namespaced key', async () => {
|
|
56
|
+
const storage = new NullStorage()
|
|
57
|
+
const spy = vi.spyOn(storage, 'set').mockResolvedValue()
|
|
58
|
+
const cache = new Cache('myns', storage)
|
|
59
|
+
const data = new Uint8Array([1, 2, 3])
|
|
60
|
+
await cache.setPersistent('terrain/W7N7', data)
|
|
61
|
+
expect(spy).toHaveBeenCalledWith('myns/terrain/W7N7', data)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('returns null when storage is null', async () => {
|
|
65
|
+
const cache = new Cache('ns', null)
|
|
66
|
+
expect(await cache.getPersistent('key')).toBeNull()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('setPersistent is a no-op when storage is null', async () => {
|
|
70
|
+
const cache = new Cache('ns', null)
|
|
71
|
+
await expect(cache.setPersistent('key', new Uint8Array([1]))).resolves.toBeUndefined()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('deletePersistent delegates to adapter', async () => {
|
|
75
|
+
const adapter = new NullStorage()
|
|
76
|
+
const spy = vi.spyOn(adapter, 'delete').mockResolvedValue(undefined)
|
|
77
|
+
const cache = new Cache('myns', adapter)
|
|
78
|
+
await cache.deletePersistent('terrain/W7N7')
|
|
79
|
+
expect(spy).toHaveBeenCalledWith('myns/terrain/W7N7')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('clearPersistent delegates to adapter', async () => {
|
|
83
|
+
const adapter = new NullStorage()
|
|
84
|
+
const spy = vi.spyOn(adapter, 'clear').mockResolvedValue(undefined)
|
|
85
|
+
const cache = new Cache('myns', adapter)
|
|
86
|
+
await cache.clearPersistent()
|
|
87
|
+
expect(spy).toHaveBeenCalledOnce()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('deletePersistent is a no-op when storage is null', async () => {
|
|
91
|
+
const cache = new Cache('myns', null)
|
|
92
|
+
await expect(cache.deletePersistent('key')).resolves.toBeUndefined()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('clearPersistent is a no-op when storage is null', async () => {
|
|
96
|
+
const cache = new Cache('myns', null)
|
|
97
|
+
await expect(cache.clearPersistent()).resolves.toBeUndefined()
|
|
98
|
+
})
|
|
99
|
+
})
|