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.
Files changed (75) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/eslint.config.js +54 -0
  3. package/package.json +45 -0
  4. package/src/ScreepsClient.ts +172 -0
  5. package/src/badge/colors.ts +83 -0
  6. package/src/badge/generateSvg.ts +70 -0
  7. package/src/badge/index.ts +1 -0
  8. package/src/badge/paths.ts +385 -0
  9. package/src/cache/Cache.ts +71 -0
  10. package/src/cache/Map2Storage.ts +112 -0
  11. package/src/file-storage.ts +2 -0
  12. package/src/http/HttpClient.ts +160 -0
  13. package/src/http/auth/AuthStrategy.ts +5 -0
  14. package/src/http/auth/GuestAuth.ts +13 -0
  15. package/src/http/auth/PasswordAuth.ts +17 -0
  16. package/src/http/auth/SteamTicketAuth.ts +17 -0
  17. package/src/http/auth/TokenAuth.ts +14 -0
  18. package/src/http/decompress.ts +37 -0
  19. package/src/http/endpoints/auth.ts +23 -0
  20. package/src/http/endpoints/experimental.ts +13 -0
  21. package/src/http/endpoints/game.ts +103 -0
  22. package/src/http/endpoints/leaderboard.ts +16 -0
  23. package/src/http/endpoints/power-creeps.ts +24 -0
  24. package/src/http/endpoints/register.ts +19 -0
  25. package/src/http/endpoints/user-messages.ts +20 -0
  26. package/src/http/endpoints/user.ts +95 -0
  27. package/src/http/fetchServerVersion.ts +151 -0
  28. package/src/index.ts +55 -0
  29. package/src/logger.ts +25 -0
  30. package/src/socket/MessageParser.ts +44 -0
  31. package/src/socket/SocketClient.ts +203 -0
  32. package/src/storage/FileStorage.ts +44 -0
  33. package/src/storage/IndexedDBStorage.ts +77 -0
  34. package/src/storage/NullStorage.ts +8 -0
  35. package/src/storage/StorageAdapter.ts +6 -0
  36. package/src/stores/MapStatsStore.ts +115 -0
  37. package/src/stores/MapStore.ts +254 -0
  38. package/src/stores/NavigationStore.ts +61 -0
  39. package/src/stores/RoomStore.ts +264 -0
  40. package/src/stores/ServerStore.ts +128 -0
  41. package/src/stores/TypedStore.ts +31 -0
  42. package/src/stores/UserStore.ts +189 -0
  43. package/src/subscription/index.ts +18 -0
  44. package/src/types/api.ts +252 -0
  45. package/src/types/events.ts +72 -0
  46. package/src/types/game.ts +160 -0
  47. package/tests/.gitkeep +0 -0
  48. package/tests/ScreepsClient.test.ts +229 -0
  49. package/tests/badge/generateSvg.test.ts +174 -0
  50. package/tests/cache/Cache.test.ts +99 -0
  51. package/tests/cache/Map2Storage.test.ts +130 -0
  52. package/tests/http/HttpClient.test.ts +188 -0
  53. package/tests/http/decompress.test.ts +52 -0
  54. package/tests/http/endpoints/auth.test.ts +126 -0
  55. package/tests/http/endpoints/game.test.ts +210 -0
  56. package/tests/http/endpoints/power-creeps.test.ts +81 -0
  57. package/tests/http/endpoints/user-messages.test.ts +68 -0
  58. package/tests/http/endpoints/user.test.ts +139 -0
  59. package/tests/socket/MessageParser.test.ts +55 -0
  60. package/tests/socket/SocketClient.test.ts +144 -0
  61. package/tests/storage/FileStorage.test.ts +64 -0
  62. package/tests/storage/IndexedDBStorage.test.ts +36 -0
  63. package/tests/storage/NullStorage.test.ts +24 -0
  64. package/tests/stores/MapStatsStore.test.ts +234 -0
  65. package/tests/stores/MapStore.test.ts +537 -0
  66. package/tests/stores/NavigationStore.test.ts +166 -0
  67. package/tests/stores/RoomStore.test.ts +130 -0
  68. package/tests/stores/ServerStore.test.ts +48 -0
  69. package/tests/stores/TypedStore.test.ts +54 -0
  70. package/tests/stores/UserStore.test.ts +136 -0
  71. package/tests/subscription/SubscriptionGroup.test.ts +34 -0
  72. package/tests/types/game.test.ts +42 -0
  73. package/tsconfig.json +17 -0
  74. package/tsup.config.ts +9 -0
  75. package/vitest.config.ts +7 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,40 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ ### Breaking Changes
6
+
7
+ - **`RoomStore.subscribeMap2()` removed** — use `client.stores.map.subscribeMap2()` instead.
8
+ - **`RoomStore.map2data()` removed** — use `client.stores.map.map2data()` instead.
9
+ - **`room:map2update` event moved from `RoomStore` to `MapStore`** — update `store.on('room:map2update', ...)` calls to use `client.stores.map.on('room:map2update', ...)`. The payload now includes a `source: 'live' | 'cache'` field.
10
+
11
+ ### New Features
12
+
13
+ #### `MapStore` (`client.stores.map`)
14
+
15
+ - `subscribeMap2(room, shard)` returns a `Map2Subscription` with `status()`, `cachedData()`, and `onStatusChange()`.
16
+ - Configurable subscription limit via `ScreepsClientOptions.map2.maxSubscriptions` (default 500). Rooms beyond the limit are placed on a FIFO waitlist and promoted automatically as slots free.
17
+ - Diff detection: identical successive server messages do not emit `room:map2update`. Dedup uses a canonical JSON hash cached on the active entry, so each incoming message is canonicalized only once (not once per side).
18
+ - `room:map2update` event now carries `source: 'live' | 'cache'`. On subscribe, cached data is emitted immediately (microtask) with `source: 'cache'` so subscribers can render stale state before the first live tick arrives.
19
+ - `room:map2state` event emitted when a room transitions between `'pending'` and `'active'`, including on WebSocket reconnect.
20
+ - Persistent two-tier cache via `Map2Storage` (memory + IndexedDB). Up to `map2.maxCacheEntries` rooms cached with LRU eviction (default 10 000).
21
+ - Automatic reconnect handling: all active and pending subscriptions re-emit `room:map2state` after reconnect, and the per-room dedup hash is reset so the first live `room:map2update` after every reconnect is guaranteed to fire (even when the resent payload is identical to the last one seen).
22
+
23
+ #### `NavigationStore` (`client.stores.navigation`)
24
+
25
+ - `navigateTo(room, shard)` — append to bounded history (default 50 entries).
26
+ - `back()` / `forward()` — move within history; return `false` at boundaries.
27
+ - `canBack()` / `canForward()` — synchronous state queries for enabling/disabling UI buttons.
28
+ - `current()` — snapshot of current room, shard, index, and history.
29
+ - `navigation:change` event emitted on every navigation action.
30
+
31
+ #### `ScreepsClientOptions`
32
+
33
+ - New `map2` option: `{ maxSubscriptions?: number; maxCacheEntries?: number }`.
34
+ - New `tokenRefresh` option: `{ intervalMs?: number } | false` (default `{ intervalMs: 30_000 }`). Issues a lightweight `auth/me` request after `intervalMs` of HTTP idleness to keep the session token alive; any real HTTP traffic resets the idle clock. Pass `false` to disable.
35
+
36
+ #### Token lifecycle
37
+
38
+ - `HttpClient` and `SocketClient` token are now kept in sync. `HttpClient` rotations (via `x-token` header) propagate to `SocketClient` via the new `socket.setToken()` method, and WS auth-token rotations propagate back via the new `socket:tokenRefresh` event. Previously the two could drift, causing the WS to attempt reconnects with stale tokens.
39
+ - New public methods `HttpClient.setToken(token)` and `SocketClient.setToken(token)`.
40
+ - New event `socket:tokenRefresh` emitted from `SocketClient` when the `auth ok` reply contains a token. `ScreepsClient` listens to both `http:tokenRefresh` and `socket:tokenRefresh` and forwards rotations to the other transport automatically.
@@ -0,0 +1,54 @@
1
+ import globals from 'globals';
2
+ import js from '@eslint/js'
3
+ import tsParser from '@typescript-eslint/parser'
4
+ import tsPlugin from '@typescript-eslint/eslint-plugin'
5
+
6
+ export default [
7
+ {
8
+ ignores: ['node_modules/**', 'dist/**', '.gitkeep'],
9
+ },
10
+ js.configs.recommended,
11
+ {
12
+ files: ['src/**/*.ts'],
13
+ languageOptions: {
14
+ parser: tsParser,
15
+ globals: {
16
+ ...globals.browser,
17
+ ...globals.node,
18
+ RequestInit: 'readonly',
19
+ ResponseInit: 'readonly',
20
+ NodeJS: 'readonly',
21
+ },
22
+ },
23
+ plugins: { '@typescript-eslint': tsPlugin },
24
+ rules: {
25
+ ...tsPlugin.configs['recommended'].rules,
26
+ '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
27
+ },
28
+ },
29
+ {
30
+ files: ['tests/**/*.ts'],
31
+ languageOptions: {
32
+ parser: tsParser,
33
+ globals: {
34
+ ...globals.browser,
35
+ ...globals.node,
36
+ RequestInit: 'readonly',
37
+ ResponseInit: 'readonly',
38
+ NodeJS: 'readonly',
39
+ describe: 'readonly',
40
+ it: 'readonly',
41
+ expect: 'readonly',
42
+ beforeEach: 'readonly',
43
+ afterEach: 'readonly',
44
+ beforeAll: 'readonly',
45
+ afterAll: 'readonly',
46
+ },
47
+ },
48
+ plugins: { '@typescript-eslint': tsPlugin },
49
+ rules: {
50
+ ...tsPlugin.configs['recommended'].rules,
51
+ '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
52
+ },
53
+ },
54
+ ]
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "screeps-connectivity",
3
+ "version": "0.2.0",
4
+ "license": "ISC",
5
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/bastianh/screeps-client.git"
9
+ },
10
+ "publishConfig": {
11
+ "registry": "https://registry.npmjs.org"
12
+ },
13
+ "exports": {
14
+ ".": {
15
+ "development": "./src/index.ts",
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.js",
18
+ "require": "./dist/index.cjs"
19
+ },
20
+ "./file-storage": {
21
+ "development": "./src/file-storage.ts",
22
+ "types": "./dist/file-storage.d.ts",
23
+ "import": "./dist/file-storage.js",
24
+ "require": "./dist/file-storage.cjs"
25
+ }
26
+ },
27
+ "scripts": {
28
+ "build": "tsup",
29
+ "test": "vitest run",
30
+ "test:watch": "vitest",
31
+ "lint": "eslint src tests"
32
+ },
33
+ "devDependencies": {
34
+ "@eslint/js": "^10.0.1",
35
+ "@types/node": "^25.7.0",
36
+ "@typescript-eslint/eslint-plugin": "^8.0.0",
37
+ "@typescript-eslint/parser": "^8.0.0",
38
+ "eslint": "^9.0.0",
39
+ "fake-indexeddb": "^6.0.0",
40
+ "globals": "^17.6.0",
41
+ "tsup": "^8.0.0",
42
+ "typescript": "^5.5.0",
43
+ "vitest": "^4.1.6"
44
+ }
45
+ }
@@ -0,0 +1,172 @@
1
+ import { HttpClient } from './http/HttpClient.js'
2
+ import { SocketClient } from './socket/SocketClient.js'
3
+ import { Cache } from './cache/Cache.js'
4
+ import { RoomStore } from './stores/RoomStore.js'
5
+ import { UserStore } from './stores/UserStore.js'
6
+ import { ServerStore } from './stores/ServerStore.js'
7
+ import { MapStore } from './stores/MapStore.js'
8
+ import { MapStatsStore } from './stores/MapStatsStore.js'
9
+ import { NavigationStore } from './stores/NavigationStore.js'
10
+ import { Map2Storage } from './cache/Map2Storage.js'
11
+ import { Logger } from './logger.js'
12
+ import type { LogFn } from './logger.js'
13
+ import type { AuthStrategy } from './http/auth/AuthStrategy.js'
14
+ import type { StorageAdapter } from './storage/StorageAdapter.js'
15
+ import type { Subscription } from './subscription/index.js'
16
+
17
+ type WsConstructor = typeof globalThis.WebSocket
18
+
19
+ export interface TokenRefreshOptions {
20
+ /** Milliseconds of idleness before issuing a keep-alive request. Default 30_000. */
21
+ intervalMs?: number
22
+ }
23
+
24
+ export interface ScreepsClientOptions {
25
+ url: string
26
+ auth: AuthStrategy
27
+ storage?: StorageAdapter | null
28
+ WebSocket?: WsConstructor
29
+ debug?: boolean | LogFn
30
+ /** Required when the server is started with `SERVER_PASSWORD`. Sent as `X-Server-Password` on every HTTP request. */
31
+ serverPassword?: string
32
+ map2?: {
33
+ maxSubscriptions?: number
34
+ maxCacheEntries?: number
35
+ }
36
+ /**
37
+ * Idle keep-alive that refreshes the auth token via a lightweight `auth/me` call when no
38
+ * authenticated traffic has happened for `intervalMs`. Default `{ intervalMs: 30_000 }`.
39
+ * Pass `false` to disable.
40
+ */
41
+ tokenRefresh?: TokenRefreshOptions | false
42
+ }
43
+
44
+ export class ScreepsClient {
45
+ readonly http: HttpClient
46
+ readonly socket: SocketClient
47
+ readonly stores: {
48
+ readonly room: RoomStore
49
+ readonly user: UserStore
50
+ readonly server: ServerStore
51
+ readonly map: MapStore
52
+ readonly mapStats: MapStatsStore
53
+ readonly navigation: NavigationStore
54
+ }
55
+ private readonly cache: Cache
56
+ private readonly logger: Logger
57
+ private readonly tokenRefreshIntervalMs: number | null
58
+ private readonly tokenSyncSubs: Subscription[] = []
59
+ private tokenRefreshTimer: ReturnType<typeof setInterval> | null = null
60
+ private lastHttpActivity = 0
61
+ private refreshInFlight = false
62
+
63
+ constructor(opts: ScreepsClientOptions) {
64
+ let namespace: string
65
+ try {
66
+ namespace = new URL(opts.url).hostname
67
+ } catch {
68
+ throw new TypeError(`ScreepsClient: invalid url "${opts.url}"`)
69
+ }
70
+ this.logger = Logger.create(opts.debug)
71
+ this.logger.log(`[screeps:client] init ${opts.url}`)
72
+ this.cache = new Cache(namespace, opts.storage ?? null)
73
+ this.http = new HttpClient({ url: opts.url, auth: opts.auth, logger: this.logger.child('http'), serverPassword: opts.serverPassword })
74
+ this.socket = new SocketClient({ url: opts.url, WebSocket: opts.WebSocket, logger: this.logger.child('socket') })
75
+ const map2Storage = new Map2Storage({
76
+ adapter: opts.storage ?? null,
77
+ namespace,
78
+ maxEntries: opts.map2?.maxCacheEntries ?? 10000,
79
+ })
80
+ this.stores = {
81
+ room: new RoomStore(this.http, this.socket, this.cache, this.logger.child('room')),
82
+ user: new UserStore(this.http, this.socket, this.cache, this.logger.child('user')),
83
+ server: new ServerStore(this.http, this.socket, this.cache, this.logger.child('server')),
84
+ map: new MapStore(this.socket, map2Storage, { maxSubscriptions: opts.map2?.maxSubscriptions ?? 500 }, this.logger.child('map')),
85
+ mapStats: new MapStatsStore(this.http, 100, 500, this.logger.child('mapStats')),
86
+ navigation: new NavigationStore(50, this.logger.child('navigation')),
87
+ }
88
+
89
+ this.tokenRefreshIntervalMs = opts.tokenRefresh === false
90
+ ? null
91
+ : (opts.tokenRefresh?.intervalMs ?? 30_000)
92
+
93
+ this.wireTokenSync()
94
+ }
95
+
96
+ private wireTokenSync(): void {
97
+ // HTTP rotates token via X-Token → propagate to WS so a later reconnect uses the fresh one.
98
+ this.tokenSyncSubs.push(this.http.on('http:tokenRefresh', ({ token }) => {
99
+ this.socket.setToken(token)
100
+ }))
101
+ // WS issues a new token on auth → keep HTTP side in sync.
102
+ this.tokenSyncSubs.push(this.socket.on('socket:tokenRefresh', (data) => {
103
+ const detail = data as { token: string }
104
+ this.http.setToken(detail.token)
105
+ }))
106
+ // Any successful HTTP response counts as activity — defers the next idle refresh.
107
+ this.tokenSyncSubs.push(this.http.on('http:success', () => {
108
+ this.lastHttpActivity = Date.now()
109
+ }))
110
+ }
111
+
112
+ get isConnected(): boolean {
113
+ return this.socket.isConnected
114
+ }
115
+
116
+ async connect(): Promise<void> {
117
+ this.logger.log('[screeps:client] connect')
118
+ await this.http.authenticate()
119
+ await this.socket.connect(this.http.token!)
120
+ await Promise.all([
121
+ this.stores.user.me(),
122
+ this.stores.user.worldStatus(),
123
+ this.stores.server.version(),
124
+ ])
125
+ this.startTokenRefresh()
126
+ }
127
+
128
+ disconnect(): void {
129
+ this.logger.log('[screeps:client] disconnect')
130
+ this.stopTokenRefresh()
131
+ this.socket.disconnect()
132
+ }
133
+
134
+ private startTokenRefresh(): void {
135
+ if (this.tokenRefreshIntervalMs === null) return
136
+ if (this.tokenRefreshTimer !== null) return
137
+ const intervalMs = this.tokenRefreshIntervalMs
138
+ this.lastHttpActivity = Date.now()
139
+ // Tick at half the interval so we react within intervalMs of idleness.
140
+ const tickMs = Math.max(1_000, Math.floor(intervalMs / 2))
141
+ this.tokenRefreshTimer = setInterval(() => {
142
+ const idleFor = Date.now() - this.lastHttpActivity
143
+ if (idleFor < intervalMs) return
144
+ void this.refreshTokenNow()
145
+ }, tickMs)
146
+ }
147
+
148
+ private stopTokenRefresh(): void {
149
+ if (this.tokenRefreshTimer !== null) {
150
+ clearInterval(this.tokenRefreshTimer)
151
+ this.tokenRefreshTimer = null
152
+ }
153
+ }
154
+
155
+ private async refreshTokenNow(): Promise<void> {
156
+ if (this.refreshInFlight) return
157
+ this.refreshInFlight = true
158
+ try {
159
+ this.logger.log('[screeps:client] token refresh (idle)')
160
+ await this.stores.user.refreshWorldStatus()
161
+ } catch (err) {
162
+ this.logger.log('[screeps:client] token refresh failed', err)
163
+ } finally {
164
+ this.refreshInFlight = false
165
+ }
166
+ }
167
+
168
+ async clearCache(): Promise<void> {
169
+ this.logger.log('[screeps:client] clearCache')
170
+ await this.cache.clearAll()
171
+ }
172
+ }
@@ -0,0 +1,83 @@
1
+ function pad(v: number): string {
2
+ const hex = Math.round(v).toString(16)
3
+ return hex.length < 2 ? '0' + hex : hex
4
+ }
5
+
6
+ function hsl2rgb(H: number, S: number, L: number): string {
7
+ const C = (1 - Math.abs(2 * L - 1)) * S
8
+ const H_ = H / 60
9
+ const X = C * (1 - Math.abs((H_ % 2) - 1))
10
+
11
+ let R1 = 0
12
+ let G1 = 0
13
+ let B1 = 0
14
+
15
+ if (!Number.isNaN(H) && H !== null && H !== undefined) {
16
+ if (H_ >= 0 && H_ < 1) {
17
+ R1 = C
18
+ G1 = X
19
+ B1 = 0
20
+ } else if (H_ >= 1 && H_ < 2) {
21
+ R1 = X
22
+ G1 = C
23
+ B1 = 0
24
+ } else if (H_ >= 2 && H_ < 3) {
25
+ R1 = 0
26
+ G1 = C
27
+ B1 = X
28
+ } else if (H_ >= 3 && H_ < 4) {
29
+ R1 = 0
30
+ G1 = X
31
+ B1 = C
32
+ } else if (H_ >= 4 && H_ < 5) {
33
+ R1 = X
34
+ G1 = 0
35
+ B1 = C
36
+ } else if (H_ >= 5 && H_ < 6) {
37
+ R1 = C
38
+ G1 = 0
39
+ B1 = X
40
+ }
41
+ }
42
+
43
+ const m = L - C / 2
44
+ const R = (R1 + m) * 255
45
+ const G = (G1 + m) * 255
46
+ const B = (B1 + m) * 255
47
+
48
+ return '#' + pad(R) + pad(G) + pad(B)
49
+ }
50
+
51
+ export interface ColorEntry {
52
+ index: number
53
+ rgb: string
54
+ }
55
+
56
+ function buildPalette(): ColorEntry[] {
57
+ const colors: ColorEntry[] = []
58
+ let index = 0
59
+
60
+ colors.push({ index: index++, rgb: hsl2rgb(0, 0, 0.8) })
61
+ for (let i = 0; i < 19; i++) {
62
+ colors.push({ index: index++, rgb: hsl2rgb(i * 360 / 19, 0.6, 0.8) })
63
+ }
64
+
65
+ colors.push({ index: index++, rgb: hsl2rgb(0, 0, 0.5) })
66
+ for (let i = 0; i < 19; i++) {
67
+ colors.push({ index: index++, rgb: hsl2rgb(i * 360 / 19, 0.7, 0.5) })
68
+ }
69
+
70
+ colors.push({ index: index++, rgb: hsl2rgb(0, 0, 0.3) })
71
+ for (let i = 0; i < 19; i++) {
72
+ colors.push({ index: index++, rgb: hsl2rgb(i * 360 / 19, 0.4, 0.3) })
73
+ }
74
+
75
+ colors.push({ index: index++, rgb: hsl2rgb(0, 0, 0.1) })
76
+ for (let i = 0; i < 19; i++) {
77
+ colors.push({ index: index++, rgb: hsl2rgb(i * 360 / 19, 0.5, 0.1) })
78
+ }
79
+
80
+ return colors
81
+ }
82
+
83
+ export const BadgeColors = buildPalette()
@@ -0,0 +1,70 @@
1
+ import type { Badge } from '../types/game.js'
2
+ import { BadgePaths } from './paths.js'
3
+ import { BadgeColors } from './colors.js'
4
+
5
+ function resolveColor(value: string | number | undefined): string {
6
+ if (typeof value === 'string') return value
7
+ if (typeof value === 'number') {
8
+ const entry = BadgeColors[value]
9
+ return entry ? entry.rgb : '#000000'
10
+ }
11
+ return '#000000'
12
+ }
13
+
14
+ export function badgeToSvg(badge: Badge): string {
15
+ const color1 = resolveColor(badge.color1)
16
+ const color2 = resolveColor(badge.color2)
17
+ const color3 = resolveColor(badge.color3)
18
+
19
+ const param = badge.param ?? 0
20
+
21
+ if (typeof badge.type === 'number') {
22
+ const def = BadgePaths[badge.type]
23
+ if (def) {
24
+ def.calc(param)
25
+ }
26
+ }
27
+
28
+ let rotate = 0
29
+ if (badge.flip && typeof badge.type === 'number') {
30
+ const def = BadgePaths[badge.type]
31
+ if (def?.flip === 'rotate180') rotate = 180
32
+ if (def?.flip === 'rotate90') rotate = 90
33
+ if (def?.flip === 'rotate45') rotate = 45
34
+ }
35
+
36
+ let path1: string
37
+ let path2: string
38
+
39
+ if (typeof badge.type === 'number') {
40
+ const def = BadgePaths[badge.type]
41
+ path1 = def?.path1 ?? ''
42
+ path2 = def?.path2 ?? ''
43
+ } else {
44
+ path1 = badge.type.path1
45
+ path2 = badge.type.path2
46
+ }
47
+
48
+ // Clip exactly at the visible circle edge (matches the black border stroke).
49
+ const clipRadius = 50
50
+
51
+ let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 100 100" shape-rendering="geometricPrecision">`
52
+ svg += `<defs><clipPath id="clip"><circle cx="50" cy="50" r="${clipRadius}"/></clipPath></defs>`
53
+ svg += `<g transform="rotate(${rotate} 50 50)">`
54
+ svg += `<rect x="0" y="0" width="100" height="100" fill="${color1}" clip-path="url(#clip)"/>`
55
+
56
+ if (path1) {
57
+ svg += `<path d="${path1}" fill="${color2}" clip-path="url(#clip)"/>`
58
+ }
59
+
60
+ if (path2) {
61
+ svg += `<path d="${path2}" fill="${color3}" clip-path="url(#clip)"/>`
62
+ }
63
+
64
+ // Black circular border
65
+ svg += `<circle cx="50" cy="50" r="47.5" fill="transparent" stroke="#000" stroke-width="5"/>`
66
+
67
+ svg += `</g></svg>`
68
+
69
+ return svg
70
+ }
@@ -0,0 +1 @@
1
+ export { badgeToSvg } from './generateSvg.js'