steam-game-server-mcp 1.0.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/README.md +75 -0
- package/dist/config/servers.d.ts +188 -0
- package/dist/config/servers.js +106 -0
- package/dist/game-server/cache.d.ts +9 -0
- package/dist/game-server/cache.js +22 -0
- package/dist/game-server/dedup.d.ts +6 -0
- package/dist/game-server/dedup.js +27 -0
- package/dist/game-server/normalize.d.ts +24 -0
- package/dist/game-server/normalize.js +38 -0
- package/dist/game-server/query.d.ts +8 -0
- package/dist/game-server/query.js +36 -0
- package/dist/game-server/types.d.ts +27 -0
- package/dist/game-server/types.js +4 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +179 -0
- package/dist/rcon/client.d.ts +8 -0
- package/dist/rcon/client.js +47 -0
- package/dist/steam-api/client.d.ts +42 -0
- package/dist/steam-api/client.js +72 -0
- package/dist/steam-api/endpoints.d.ts +17 -0
- package/dist/steam-api/endpoints.js +36 -0
- package/dist/steam-api/types.d.ts +45 -0
- package/dist/steam-api/types.js +4 -0
- package/dist/tools/admin-tools.d.ts +63 -0
- package/dist/tools/admin-tools.js +118 -0
- package/dist/tools/inventory-tools.d.ts +86 -0
- package/dist/tools/inventory-tools.js +135 -0
- package/dist/tools/server-tools.d.ts +32 -0
- package/dist/tools/server-tools.js +172 -0
- package/dist/tools/steam-tools.d.ts +67 -0
- package/dist/tools/steam-tools.js +70 -0
- package/package.json +42 -0
package/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Steam & Game Server MCP
|
|
2
|
+
|
|
3
|
+
Steam 프로필, 게임 라이브러리, 동시 접속자, 게임 서버 정보를 조회하는 MCP(Model Context Protocol) 서버입니다.
|
|
4
|
+
|
|
5
|
+
## 설치
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## 환경 설정
|
|
12
|
+
|
|
13
|
+
`.env` 파일을 생성하고 다음 변수를 설정하세요:
|
|
14
|
+
|
|
15
|
+
```env
|
|
16
|
+
STEAM_API_KEY=your_api_key_here
|
|
17
|
+
STEAM_ID=your_steam_id_here
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
- **STEAM_API_KEY**: [Steam API 키](https://steamcommunity.com/dev/apikey) 발급
|
|
21
|
+
- **STEAM_ID**: 17자리 Steam ID (선택)
|
|
22
|
+
|
|
23
|
+
## 빌드 및 실행
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# 빌드
|
|
27
|
+
npm run build
|
|
28
|
+
|
|
29
|
+
# 개발 모드 (tsx)
|
|
30
|
+
npm run dev
|
|
31
|
+
|
|
32
|
+
# 프로덕션
|
|
33
|
+
npm start
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Cursor MCP 설정
|
|
37
|
+
|
|
38
|
+
### npx 방식 (권장)
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{
|
|
42
|
+
"mcpServers": {
|
|
43
|
+
"steam-game-server": {
|
|
44
|
+
"command": "npx",
|
|
45
|
+
"args": ["steam-game-server-mcp@latest"],
|
|
46
|
+
"env": {
|
|
47
|
+
"STEAM_API_KEY": "your_key",
|
|
48
|
+
"STEAM_ID": "your_steam_id",
|
|
49
|
+
"STEAM_MCP_SERVERS_PATH": "C:/path/to/your/servers.json"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
- `npx steam-game-server-mcp@latest` → npm publish 후 사용 가능
|
|
57
|
+
- 로컬 개발: `npx .` 또는 `node dist/index.js`
|
|
58
|
+
|
|
59
|
+
## 제공 도구
|
|
60
|
+
|
|
61
|
+
| 도구 | 설명 |
|
|
62
|
+
|------|------|
|
|
63
|
+
| `steam_resolve_vanity_url` | 커스텀 URL → SteamID 변환 |
|
|
64
|
+
| `steam_get_player_summary` | 프로필 요약 (닉네임, 아바타, 상태) |
|
|
65
|
+
| `steam_get_owned_games` | 보유 게임 목록 |
|
|
66
|
+
| `steam_get_recently_played` | 최근 플레이 게임 |
|
|
67
|
+
| `steam_get_current_players` | 앱별 동시 접속자 수 |
|
|
68
|
+
| `steam_get_servers_at_address` | IP로 게임 서버 조회 |
|
|
69
|
+
| `steam_get_app_news` | 앱 뉴스/패치 노트 |
|
|
70
|
+
|
|
71
|
+
## 문서
|
|
72
|
+
|
|
73
|
+
- [상세 명세서 (V1)](./docs/STEAM_GAME_SERVER_MCP_SPEC.md) - Steam API
|
|
74
|
+
- [상세 설계 명세서 (V2)](./docs/STEAM_GAME_SERVER_MCP_SPEC_V2.md) - Game Server, RCON, Monitoring
|
|
75
|
+
- [.cursor 설정 가이드](./.cursor/docs/SETUP_GUIDE.md) - Cursor AI 규칙 및 가이드
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* servers.json 로더 및 Zod 스키마 검증
|
|
3
|
+
* STEAM_MCP_SERVERS_PATH 또는 ./servers.json 경로 사용
|
|
4
|
+
*/
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
/** 서버 단일 스키마 (Inventory 도구에서 재사용) */
|
|
7
|
+
export declare const ServerSchema: z.ZodObject<{
|
|
8
|
+
id: z.ZodString;
|
|
9
|
+
name: z.ZodString;
|
|
10
|
+
type: z.ZodString;
|
|
11
|
+
host: z.ZodString;
|
|
12
|
+
port: z.ZodNumber;
|
|
13
|
+
query: z.ZodOptional<z.ZodObject<{
|
|
14
|
+
enabled: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
|
|
15
|
+
}, "strip", z.ZodTypeAny, {
|
|
16
|
+
enabled: boolean;
|
|
17
|
+
}, {
|
|
18
|
+
enabled?: boolean | undefined;
|
|
19
|
+
}>>;
|
|
20
|
+
rcon: z.ZodEffects<z.ZodOptional<z.ZodObject<{
|
|
21
|
+
enabled: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
|
|
22
|
+
port: z.ZodOptional<z.ZodNumber>;
|
|
23
|
+
passwordEnv: z.ZodOptional<z.ZodString>;
|
|
24
|
+
}, "strip", z.ZodTypeAny, {
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
port?: number | undefined;
|
|
27
|
+
passwordEnv?: string | undefined;
|
|
28
|
+
}, {
|
|
29
|
+
port?: number | undefined;
|
|
30
|
+
enabled?: boolean | undefined;
|
|
31
|
+
passwordEnv?: string | undefined;
|
|
32
|
+
}>>, {
|
|
33
|
+
enabled: boolean;
|
|
34
|
+
port?: number | undefined;
|
|
35
|
+
passwordEnv?: string | undefined;
|
|
36
|
+
} | undefined, {
|
|
37
|
+
port?: number | undefined;
|
|
38
|
+
enabled?: boolean | undefined;
|
|
39
|
+
passwordEnv?: string | undefined;
|
|
40
|
+
} | undefined>;
|
|
41
|
+
logPath: z.ZodOptional<z.ZodString>;
|
|
42
|
+
}, "strip", z.ZodTypeAny, {
|
|
43
|
+
type: string;
|
|
44
|
+
id: string;
|
|
45
|
+
name: string;
|
|
46
|
+
host: string;
|
|
47
|
+
port: number;
|
|
48
|
+
query?: {
|
|
49
|
+
enabled: boolean;
|
|
50
|
+
} | undefined;
|
|
51
|
+
rcon?: {
|
|
52
|
+
enabled: boolean;
|
|
53
|
+
port?: number | undefined;
|
|
54
|
+
passwordEnv?: string | undefined;
|
|
55
|
+
} | undefined;
|
|
56
|
+
logPath?: string | undefined;
|
|
57
|
+
}, {
|
|
58
|
+
type: string;
|
|
59
|
+
id: string;
|
|
60
|
+
name: string;
|
|
61
|
+
host: string;
|
|
62
|
+
port: number;
|
|
63
|
+
query?: {
|
|
64
|
+
enabled?: boolean | undefined;
|
|
65
|
+
} | undefined;
|
|
66
|
+
rcon?: {
|
|
67
|
+
port?: number | undefined;
|
|
68
|
+
enabled?: boolean | undefined;
|
|
69
|
+
passwordEnv?: string | undefined;
|
|
70
|
+
} | undefined;
|
|
71
|
+
logPath?: string | undefined;
|
|
72
|
+
}>;
|
|
73
|
+
/** 전체 설정 스키마 (Inventory 도구에서 재사용) */
|
|
74
|
+
export declare const ServersConfigSchema: z.ZodObject<{
|
|
75
|
+
servers: z.ZodArray<z.ZodObject<{
|
|
76
|
+
id: z.ZodString;
|
|
77
|
+
name: z.ZodString;
|
|
78
|
+
type: z.ZodString;
|
|
79
|
+
host: z.ZodString;
|
|
80
|
+
port: z.ZodNumber;
|
|
81
|
+
query: z.ZodOptional<z.ZodObject<{
|
|
82
|
+
enabled: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
|
|
83
|
+
}, "strip", z.ZodTypeAny, {
|
|
84
|
+
enabled: boolean;
|
|
85
|
+
}, {
|
|
86
|
+
enabled?: boolean | undefined;
|
|
87
|
+
}>>;
|
|
88
|
+
rcon: z.ZodEffects<z.ZodOptional<z.ZodObject<{
|
|
89
|
+
enabled: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
|
|
90
|
+
port: z.ZodOptional<z.ZodNumber>;
|
|
91
|
+
passwordEnv: z.ZodOptional<z.ZodString>;
|
|
92
|
+
}, "strip", z.ZodTypeAny, {
|
|
93
|
+
enabled: boolean;
|
|
94
|
+
port?: number | undefined;
|
|
95
|
+
passwordEnv?: string | undefined;
|
|
96
|
+
}, {
|
|
97
|
+
port?: number | undefined;
|
|
98
|
+
enabled?: boolean | undefined;
|
|
99
|
+
passwordEnv?: string | undefined;
|
|
100
|
+
}>>, {
|
|
101
|
+
enabled: boolean;
|
|
102
|
+
port?: number | undefined;
|
|
103
|
+
passwordEnv?: string | undefined;
|
|
104
|
+
} | undefined, {
|
|
105
|
+
port?: number | undefined;
|
|
106
|
+
enabled?: boolean | undefined;
|
|
107
|
+
passwordEnv?: string | undefined;
|
|
108
|
+
} | undefined>;
|
|
109
|
+
logPath: z.ZodOptional<z.ZodString>;
|
|
110
|
+
}, "strip", z.ZodTypeAny, {
|
|
111
|
+
type: string;
|
|
112
|
+
id: string;
|
|
113
|
+
name: string;
|
|
114
|
+
host: string;
|
|
115
|
+
port: number;
|
|
116
|
+
query?: {
|
|
117
|
+
enabled: boolean;
|
|
118
|
+
} | undefined;
|
|
119
|
+
rcon?: {
|
|
120
|
+
enabled: boolean;
|
|
121
|
+
port?: number | undefined;
|
|
122
|
+
passwordEnv?: string | undefined;
|
|
123
|
+
} | undefined;
|
|
124
|
+
logPath?: string | undefined;
|
|
125
|
+
}, {
|
|
126
|
+
type: string;
|
|
127
|
+
id: string;
|
|
128
|
+
name: string;
|
|
129
|
+
host: string;
|
|
130
|
+
port: number;
|
|
131
|
+
query?: {
|
|
132
|
+
enabled?: boolean | undefined;
|
|
133
|
+
} | undefined;
|
|
134
|
+
rcon?: {
|
|
135
|
+
port?: number | undefined;
|
|
136
|
+
enabled?: boolean | undefined;
|
|
137
|
+
passwordEnv?: string | undefined;
|
|
138
|
+
} | undefined;
|
|
139
|
+
logPath?: string | undefined;
|
|
140
|
+
}>, "many">;
|
|
141
|
+
}, "strip", z.ZodTypeAny, {
|
|
142
|
+
servers: {
|
|
143
|
+
type: string;
|
|
144
|
+
id: string;
|
|
145
|
+
name: string;
|
|
146
|
+
host: string;
|
|
147
|
+
port: number;
|
|
148
|
+
query?: {
|
|
149
|
+
enabled: boolean;
|
|
150
|
+
} | undefined;
|
|
151
|
+
rcon?: {
|
|
152
|
+
enabled: boolean;
|
|
153
|
+
port?: number | undefined;
|
|
154
|
+
passwordEnv?: string | undefined;
|
|
155
|
+
} | undefined;
|
|
156
|
+
logPath?: string | undefined;
|
|
157
|
+
}[];
|
|
158
|
+
}, {
|
|
159
|
+
servers: {
|
|
160
|
+
type: string;
|
|
161
|
+
id: string;
|
|
162
|
+
name: string;
|
|
163
|
+
host: string;
|
|
164
|
+
port: number;
|
|
165
|
+
query?: {
|
|
166
|
+
enabled?: boolean | undefined;
|
|
167
|
+
} | undefined;
|
|
168
|
+
rcon?: {
|
|
169
|
+
port?: number | undefined;
|
|
170
|
+
enabled?: boolean | undefined;
|
|
171
|
+
passwordEnv?: string | undefined;
|
|
172
|
+
} | undefined;
|
|
173
|
+
logPath?: string | undefined;
|
|
174
|
+
}[];
|
|
175
|
+
}>;
|
|
176
|
+
export type ServerConfig = z.infer<typeof ServerSchema>;
|
|
177
|
+
export type ServersConfig = z.infer<typeof ServersConfigSchema>;
|
|
178
|
+
/** servers.json 로드 및 검증 */
|
|
179
|
+
export declare function loadServersConfig(): ServersConfig;
|
|
180
|
+
/** serverId로 서버 설정 조회 */
|
|
181
|
+
export declare function getServerConfig(serverId: string): ServerConfig;
|
|
182
|
+
/** query가 활성화된 서버 목록 */
|
|
183
|
+
export declare function getQueryableServers(): ServerConfig[];
|
|
184
|
+
/**
|
|
185
|
+
* servers.json 원자적 쓰기 (temp 파일 + rename)
|
|
186
|
+
* 명세 §15.2.3: 장애 시 기존 servers.json 유지
|
|
187
|
+
*/
|
|
188
|
+
export declare function writeServersConfig(config: ServersConfig): void;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* servers.json 로더 및 Zod 스키마 검증
|
|
3
|
+
* STEAM_MCP_SERVERS_PATH 또는 ./servers.json 경로 사용
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync, existsSync, writeFileSync, renameSync, unlinkSync } from "node:fs";
|
|
6
|
+
import { resolve } from "node:path";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
/** 서버 단일 스키마 (Inventory 도구에서 재사용) */
|
|
9
|
+
export const ServerSchema = z.object({
|
|
10
|
+
id: z.string().min(1, "서버 ID는 필수입니다"),
|
|
11
|
+
name: z.string().min(1, "서버 이름은 필수입니다"),
|
|
12
|
+
type: z.string().min(1, "게임 타입은 필수입니다"),
|
|
13
|
+
host: z.string().min(1, "호스트는 필수입니다"),
|
|
14
|
+
port: z.number().int().min(1).max(65535),
|
|
15
|
+
query: z
|
|
16
|
+
.object({
|
|
17
|
+
enabled: z.boolean().optional().default(true),
|
|
18
|
+
})
|
|
19
|
+
.optional(),
|
|
20
|
+
rcon: z
|
|
21
|
+
.object({
|
|
22
|
+
enabled: z.boolean().optional().default(false),
|
|
23
|
+
port: z.number().int().min(1).max(65535).optional(),
|
|
24
|
+
passwordEnv: z.string().optional(),
|
|
25
|
+
})
|
|
26
|
+
.optional()
|
|
27
|
+
.refine((r) => {
|
|
28
|
+
if (!r || !r.enabled)
|
|
29
|
+
return true;
|
|
30
|
+
return r.port != null && r.passwordEnv != null && r.passwordEnv.length > 0;
|
|
31
|
+
}, { message: "rcon.enabled가 true일 때 port와 passwordEnv가 필요합니다" }),
|
|
32
|
+
logPath: z.string().optional(),
|
|
33
|
+
});
|
|
34
|
+
/** 전체 설정 스키마 (Inventory 도구에서 재사용) */
|
|
35
|
+
export const ServersConfigSchema = z.object({
|
|
36
|
+
servers: z.array(ServerSchema),
|
|
37
|
+
});
|
|
38
|
+
/** servers.json 파일 경로 반환 (환경변수 우선) */
|
|
39
|
+
function getServersPath() {
|
|
40
|
+
const envPath = process.env.STEAM_MCP_SERVERS_PATH;
|
|
41
|
+
if (envPath)
|
|
42
|
+
return resolve(envPath);
|
|
43
|
+
return resolve(process.cwd(), "servers.json");
|
|
44
|
+
}
|
|
45
|
+
/** servers.json 로드 및 검증 */
|
|
46
|
+
export function loadServersConfig() {
|
|
47
|
+
const path = getServersPath();
|
|
48
|
+
if (!existsSync(path)) {
|
|
49
|
+
throw new Error("servers.json을 찾을 수 없습니다.");
|
|
50
|
+
}
|
|
51
|
+
const raw = readFileSync(path, "utf-8");
|
|
52
|
+
let parsed;
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(raw);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new Error("servers.json 형식이 올바르지 않습니다: JSON 파싱 실패");
|
|
58
|
+
}
|
|
59
|
+
const result = ServersConfigSchema.safeParse(parsed);
|
|
60
|
+
if (!result.success) {
|
|
61
|
+
const details = result.error.errors.map((e) => `${e.path.join(".")}: ${e.message}`).join("; ");
|
|
62
|
+
throw new Error(`servers.json 형식이 올바르지 않습니다: ${details}`);
|
|
63
|
+
}
|
|
64
|
+
return result.data;
|
|
65
|
+
}
|
|
66
|
+
/** serverId로 서버 설정 조회 */
|
|
67
|
+
export function getServerConfig(serverId) {
|
|
68
|
+
const config = loadServersConfig();
|
|
69
|
+
const server = config.servers.find((s) => s.id === serverId);
|
|
70
|
+
if (!server) {
|
|
71
|
+
throw new Error("등록되지 않은 서버 ID입니다.");
|
|
72
|
+
}
|
|
73
|
+
return server;
|
|
74
|
+
}
|
|
75
|
+
/** query가 활성화된 서버 목록 */
|
|
76
|
+
export function getQueryableServers() {
|
|
77
|
+
const config = loadServersConfig();
|
|
78
|
+
return config.servers.filter((s) => s.query?.enabled !== false);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* servers.json 원자적 쓰기 (temp 파일 + rename)
|
|
82
|
+
* 명세 §15.2.3: 장애 시 기존 servers.json 유지
|
|
83
|
+
*/
|
|
84
|
+
export function writeServersConfig(config) {
|
|
85
|
+
const path = getServersPath();
|
|
86
|
+
const tmpPath = path.replace(/\.json$/, ".tmp.json");
|
|
87
|
+
try {
|
|
88
|
+
const content = JSON.stringify(config, null, 2);
|
|
89
|
+
writeFileSync(tmpPath, content, "utf-8");
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
throw new Error("servers.json을 임시 파일에 저장하는 데 실패했습니다. 디스크 공간과 권한을 확인하세요.");
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
renameSync(tmpPath, path);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
try {
|
|
99
|
+
unlinkSync(tmpPath);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* tmp 정리 실패 무시 */
|
|
103
|
+
}
|
|
104
|
+
throw new Error("servers.json 업데이트에 실패했습니다. 파일 시스템 권한 또는 잠금 상태를 확인하세요.");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 서버 쿼리 캐시 - TTL 30초 (명세 §6.2)
|
|
3
|
+
*/
|
|
4
|
+
import type { ServerState } from "./types.js";
|
|
5
|
+
/** 캐시 TTL (ms) */
|
|
6
|
+
export declare const CACHE_TTL = 30000;
|
|
7
|
+
export declare function getCached(serverId: string): ServerState | null;
|
|
8
|
+
export declare function setCache(serverId: string, data: ServerState): void;
|
|
9
|
+
export declare function invalidateCache(serverId: string): void;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 서버 쿼리 캐시 - TTL 30초 (명세 §6.2)
|
|
3
|
+
*/
|
|
4
|
+
/** 캐시 TTL (ms) */
|
|
5
|
+
export const CACHE_TTL = 30_000;
|
|
6
|
+
const cache = new Map();
|
|
7
|
+
export function getCached(serverId) {
|
|
8
|
+
const cached = cache.get(serverId);
|
|
9
|
+
if (!cached)
|
|
10
|
+
return null;
|
|
11
|
+
if (Date.now() - cached.ts >= CACHE_TTL) {
|
|
12
|
+
cache.delete(serverId);
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
return cached.data;
|
|
16
|
+
}
|
|
17
|
+
export function setCache(serverId, data) {
|
|
18
|
+
cache.set(serverId, { data, ts: Date.now() });
|
|
19
|
+
}
|
|
20
|
+
export function invalidateCache(serverId) {
|
|
21
|
+
cache.delete(serverId);
|
|
22
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 동시 쿼리 Dedup - pendingQueries Map (명세 §6.3)
|
|
3
|
+
*/
|
|
4
|
+
import { queryServer } from "./query.js";
|
|
5
|
+
import { getCached, setCache } from "./cache.js";
|
|
6
|
+
const pendingQueries = new Map();
|
|
7
|
+
/** 캐시 + Dedup 적용 쿼리 */
|
|
8
|
+
export async function queryWithDedup(serverId) {
|
|
9
|
+
const cached = getCached(serverId);
|
|
10
|
+
if (cached)
|
|
11
|
+
return cached;
|
|
12
|
+
const existing = pendingQueries.get(serverId);
|
|
13
|
+
if (existing)
|
|
14
|
+
return existing;
|
|
15
|
+
const promise = queryServer(serverId)
|
|
16
|
+
.then((result) => {
|
|
17
|
+
setCache(serverId, result);
|
|
18
|
+
pendingQueries.delete(serverId);
|
|
19
|
+
return result;
|
|
20
|
+
})
|
|
21
|
+
.catch((err) => {
|
|
22
|
+
pendingQueries.delete(serverId);
|
|
23
|
+
throw err;
|
|
24
|
+
});
|
|
25
|
+
pendingQueries.set(serverId, promise);
|
|
26
|
+
return promise;
|
|
27
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gamedig raw 결과 → ServerState 정규화 (명세 §5.3)
|
|
3
|
+
*/
|
|
4
|
+
import type { ServerState, LatencyCategory } from "./types.js";
|
|
5
|
+
/** gamedig v5 결과 타입 (Results + 확장) */
|
|
6
|
+
export interface GamedigResult {
|
|
7
|
+
name?: string;
|
|
8
|
+
map?: string;
|
|
9
|
+
players?: Array<{
|
|
10
|
+
name?: string;
|
|
11
|
+
score?: number;
|
|
12
|
+
time?: number;
|
|
13
|
+
raw?: Record<string, unknown>;
|
|
14
|
+
}>;
|
|
15
|
+
numplayers?: number;
|
|
16
|
+
maxplayers?: number;
|
|
17
|
+
ping?: number;
|
|
18
|
+
type?: string;
|
|
19
|
+
raw?: Record<string, string | number | unknown>;
|
|
20
|
+
}
|
|
21
|
+
/** ping → LatencyCategory (명세 §5.2) */
|
|
22
|
+
export declare function getLatencyCategory(ping: number): LatencyCategory;
|
|
23
|
+
/** gamedig raw → ServerState 변환 */
|
|
24
|
+
export declare function normalize(raw: GamedigResult, serverId: string): ServerState;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gamedig raw 결과 → ServerState 정규화 (명세 §5.3)
|
|
3
|
+
*/
|
|
4
|
+
/** ping → LatencyCategory (명세 §5.2) */
|
|
5
|
+
export function getLatencyCategory(ping) {
|
|
6
|
+
if (ping < 0)
|
|
7
|
+
return "CRITICAL";
|
|
8
|
+
if (ping <= 100)
|
|
9
|
+
return "GOOD";
|
|
10
|
+
if (ping <= 200)
|
|
11
|
+
return "NORMAL";
|
|
12
|
+
if (ping <= 300)
|
|
13
|
+
return "HIGH";
|
|
14
|
+
return "CRITICAL";
|
|
15
|
+
}
|
|
16
|
+
/** gamedig raw → ServerState 변환 */
|
|
17
|
+
export function normalize(raw, serverId) {
|
|
18
|
+
const ping = raw.ping ?? -1;
|
|
19
|
+
const playerList = raw.players?.map((p) => ({
|
|
20
|
+
name: p.name ?? "Unknown",
|
|
21
|
+
score: p.score,
|
|
22
|
+
time: p.time,
|
|
23
|
+
}));
|
|
24
|
+
const playerCount = raw.players?.length ?? raw.numplayers ?? 0;
|
|
25
|
+
return {
|
|
26
|
+
id: serverId,
|
|
27
|
+
name: (raw.name ?? "Unknown").trim(),
|
|
28
|
+
map: raw.map ?? "Unknown",
|
|
29
|
+
players: playerCount,
|
|
30
|
+
maxPlayers: raw.maxplayers ?? 0,
|
|
31
|
+
ping,
|
|
32
|
+
game: raw.raw?.game ?? raw.type ?? "unknown",
|
|
33
|
+
latencyCategory: getLatencyCategory(ping),
|
|
34
|
+
playerList: playerList?.length ? playerList : undefined,
|
|
35
|
+
rules: raw.raw,
|
|
36
|
+
queriedAt: new Date().toISOString(),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gamedig 쿼리 + timeout 래핑 (명세 §6.4)
|
|
3
|
+
*/
|
|
4
|
+
import type { ServerState } from "./types.js";
|
|
5
|
+
/** 쿼리 타임아웃 (ms) */
|
|
6
|
+
export declare const QUERY_TIMEOUT = 5000;
|
|
7
|
+
/** 단일 서버 쿼리 (캐시/딥 없음) */
|
|
8
|
+
export declare function queryServer(serverId: string): Promise<ServerState>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gamedig 쿼리 + timeout 래핑 (명세 §6.4)
|
|
3
|
+
*/
|
|
4
|
+
import { GameDig } from "gamedig";
|
|
5
|
+
import { getServerConfig } from "../config/servers.js";
|
|
6
|
+
import { normalize } from "./normalize.js";
|
|
7
|
+
/** 쿼리 타임아웃 (ms) */
|
|
8
|
+
export const QUERY_TIMEOUT = 5_000;
|
|
9
|
+
function timeout(ms) {
|
|
10
|
+
return new Promise((_, reject) => setTimeout(() => reject(new Error("서버 응답 시간 초과")), ms));
|
|
11
|
+
}
|
|
12
|
+
/** 단일 서버 쿼리 (캐시/딥 없음) */
|
|
13
|
+
export async function queryServer(serverId) {
|
|
14
|
+
const config = getServerConfig(serverId);
|
|
15
|
+
if (config.query?.enabled === false) {
|
|
16
|
+
throw new Error("해당 서버는 쿼리가 비활성화되어 있습니다.");
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const raw = await Promise.race([
|
|
20
|
+
GameDig.query({
|
|
21
|
+
type: config.type,
|
|
22
|
+
host: config.host,
|
|
23
|
+
port: config.port,
|
|
24
|
+
}),
|
|
25
|
+
timeout(QUERY_TIMEOUT),
|
|
26
|
+
]);
|
|
27
|
+
return normalize(raw, serverId);
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
const message = err instanceof Error ? err.message : "게임 서버 쿼리 실패";
|
|
31
|
+
if (message.includes("timeout") || message.includes("시간 초과")) {
|
|
32
|
+
throw new Error("서버 응답 시간 초과");
|
|
33
|
+
}
|
|
34
|
+
throw new Error(`게임 서버 쿼리 실패: ${message}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 게임 서버 데이터 모델 (명세 §5)
|
|
3
|
+
*/
|
|
4
|
+
/** ping 기반 지연 카테고리 */
|
|
5
|
+
export type LatencyCategory = "GOOD" | "NORMAL" | "HIGH" | "CRITICAL";
|
|
6
|
+
/** 서버 상태 (정규화된 형태) */
|
|
7
|
+
export interface ServerState {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
map: string;
|
|
11
|
+
players: number;
|
|
12
|
+
maxPlayers: number;
|
|
13
|
+
ping: number;
|
|
14
|
+
game: string;
|
|
15
|
+
latencyCategory: LatencyCategory;
|
|
16
|
+
playerList?: ServerPlayer[];
|
|
17
|
+
rules?: Record<string, string>;
|
|
18
|
+
queriedAt: string;
|
|
19
|
+
}
|
|
20
|
+
/** 플레이어 정보 */
|
|
21
|
+
export interface ServerPlayer {
|
|
22
|
+
name: string;
|
|
23
|
+
score?: number;
|
|
24
|
+
time?: number;
|
|
25
|
+
}
|
|
26
|
+
/** HealthStatus (server_health, server_diagnose) */
|
|
27
|
+
export type HealthStatus = "GOOD" | "WARNING" | "HIGH_LOAD" | "CRITICAL";
|