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
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.
|
package/eslint.config.js
ADDED
|
@@ -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'
|