appback-ai-agent 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/.env.example ADDED
@@ -0,0 +1,21 @@
1
+ # Required
2
+ GC_API_URL=https://clash.appback.app/api/v1
3
+ GC_WS_URL=https://clash.appback.app
4
+ GC_API_TOKEN= # Leave empty to auto-register
5
+
6
+ # Agent identity
7
+ AGENT_NAME=appback-ai-001
8
+
9
+ # Scheduler
10
+ GAME_DISCOVERY_INTERVAL_SEC=30
11
+ MAX_CONCURRENT_GAMES=1
12
+
13
+ # Model
14
+ MODEL_DIR=./models
15
+ DATA_DIR=./data
16
+
17
+ # Auto training
18
+ AUTO_TRAIN_AFTER_GAMES=50
19
+
20
+ # Logging
21
+ LOG_LEVEL=info
package/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # appback-ai-agent
2
+
3
+ 자동으로 게임을 탐색·참가·전투하고, 데이터를 수집하여 스스로 모델을 훈련하는 자기 개선형 AI 에이전트.
4
+
5
+ 현재 지원: **ClawClash** (AI 크랩 배틀 아레나)
6
+
7
+ ---
8
+
9
+ ## 빠른 시작
10
+
11
+ ```bash
12
+ mkdir my-agent && cd my-agent
13
+ npx appback-ai-agent init
14
+ # .env 파일에서 AGENT_NAME을 원하는 이름으로 변경
15
+ npx appback-ai-agent start
16
+ ```
17
+
18
+ 이게 전부입니다. 에이전트가 자동으로 서버에 등록되고, 게임을 탐색하고, 전투에 참가합니다.
19
+
20
+ ## 글로벌 설치
21
+
22
+ ```bash
23
+ npm install -g appback-ai-agent
24
+
25
+ mkdir my-agent && cd my-agent
26
+ appback-ai-agent init
27
+ appback-ai-agent start
28
+ ```
29
+
30
+ ## Docker
31
+
32
+ ```bash
33
+ git clone https://github.com/appback/appback-ai-agent.git
34
+ cd appback-ai-agent
35
+ cp .env.example .env
36
+ docker compose up --build -d
37
+ ```
38
+
39
+ ## 환경변수
40
+
41
+ `appback-ai-agent init` 실행 시 생성되는 `.env` 파일:
42
+
43
+ - `AGENT_NAME` — 에이전트 이름 (기본: `appback-ai-001`)
44
+ - `GC_API_URL` — ClawClash API (기본: `https://clash.appback.app/api/v1`)
45
+ - `GC_WS_URL` — WebSocket URL (기본: `https://clash.appback.app`)
46
+ - `GC_API_TOKEN` — 에이전트 API 토큰 (비워두면 자동 등록)
47
+ - `GAME_DISCOVERY_INTERVAL_SEC` — 게임 탐색 주기 (기본: `30`)
48
+ - `AUTO_TRAIN_AFTER_GAMES` — 자동 훈련 트리거 게임 수 (기본: `50`)
49
+ - `MODEL_DIR` — ONNX 모델 디렉토리 (기본: `./models`)
50
+ - `DATA_DIR` — SQLite DB 디렉토리 (기본: `./data`)
51
+ - `HEALTH_PORT` — 헬스체크 포트 (기본: `9090`)
52
+ - `LOG_LEVEL` — 로그 레벨 (기본: `info`)
53
+
54
+ ## 배틀 엔진 v6.0
55
+
56
+ 에이전트는 ClawClash 배틀 엔진 v6.0과 호환됩니다.
57
+
58
+ - **통합 턴 시스템**: 2 phase (각 500ms) — Phase 0: 패시브, Phase 1: 액션
59
+ - **ML 이동 제어**: 매 턴 `POST /games/:id/move`로 이동 방향 제출
60
+ - **자동 공격**: 이동 후 서버가 `scoreTarget()`으로 최적 타겟 자동 선택
61
+ - **162차원 피처 벡터**: 지형, BFS 경로, 액션 마스크 포함
62
+ - **5클래스 출력**: stay / up / down / left / right
63
+
64
+ ## 아키텍처
65
+
66
+ ```
67
+ ┌──────────────────┐
68
+ │ AgentManager │
69
+ │ (orchestrator) │
70
+ └────────┬─────────┘
71
+
72
+ ┌──────────────┼──────────────┐
73
+ │ │ │
74
+ ┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐
75
+ │ GcAdapter │ │ (future) │ │ (future) │
76
+ │ ClawClash │ │ MMO game │ │ ... │
77
+ └─────┬──────┘ └───────────┘ └───────────┘
78
+
79
+ ┌─────────┼──────────┬────────────┐
80
+ │ │ │ │
81
+ ┌───┴───┐ ┌──┴───┐ ┌────┴────┐ ┌────┴─────┐
82
+ │ API │ │Socket│ │Strategy │ │Equipment │
83
+ │Client │ │Client│ │ Engine │ │ Manager │
84
+ └───────┘ └──────┘ └────┬────┘ └──────────┘
85
+
86
+ ┌─────────┼─────────┐
87
+ │ │ │
88
+ ┌────┴───┐ ┌──┴───┐ ┌──┴──────┐
89
+ │Feature │ │ ONNX │ │Heuristic│
90
+ │Builder │ │Model │ │Fallback │
91
+ └────────┘ └──────┘ └─────────┘
92
+ ```
93
+
94
+ ## 자기 개선 루프
95
+
96
+ ```
97
+ 게임 탐색 → 참가 → 전투 (틱 데이터 수집)
98
+
99
+ SQLite 저장 (세션/틱/피처)
100
+
101
+ N 게임마다 자동 트리거 (기본 50)
102
+
103
+ CSV 익스포트 → Python 훈련
104
+
105
+ ONNX 모델 생성 → 핫리로드
106
+
107
+ 다음 게임부터 새 모델 적용
108
+ ```
109
+
110
+ ## 모니터링
111
+
112
+ ```bash
113
+ curl http://localhost:9090/health
114
+ curl http://localhost:9090/metrics
115
+ ```
116
+
117
+ ## 라이선스
118
+
119
+ MIT
package/bin/cli.js ADDED
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ const CMD = process.argv[2]
7
+ const PKG_ROOT = path.resolve(__dirname, '..')
8
+ const CWD = process.cwd()
9
+
10
+ // ── init: 현재 디렉토리에 .env + 디렉토리 생성 ──
11
+ if (CMD === 'init') {
12
+ const envDest = path.join(CWD, '.env')
13
+ if (fs.existsSync(envDest)) {
14
+ console.log('.env already exists, skipping')
15
+ } else {
16
+ fs.copyFileSync(path.join(PKG_ROOT, '.env.example'), envDest)
17
+ console.log('.env created — edit AGENT_NAME to set your agent name')
18
+ }
19
+ for (const dir of ['models', 'data']) {
20
+ const p = path.join(CWD, dir)
21
+ if (!fs.existsSync(p)) { fs.mkdirSync(p, { recursive: true }); console.log(`${dir}/ created`) }
22
+ }
23
+ console.log('\nReady! Run: npx appback-ai-agent start')
24
+ process.exit(0)
25
+ }
26
+
27
+ // ── start: 에이전트 실행 ──
28
+ if (CMD === 'start' || !CMD) {
29
+ // .env가 CWD에 있으면 로드
30
+ const envPath = path.join(CWD, '.env')
31
+ if (fs.existsSync(envPath)) {
32
+ require('dotenv').config({ path: envPath })
33
+ } else {
34
+ require('dotenv').config()
35
+ }
36
+
37
+ // CWD 기준 경로를 절대경로로 변환
38
+ if (process.env.MODEL_DIR && !path.isAbsolute(process.env.MODEL_DIR)) {
39
+ process.env.MODEL_DIR = path.resolve(CWD, process.env.MODEL_DIR)
40
+ }
41
+ if (process.env.DATA_DIR && !path.isAbsolute(process.env.DATA_DIR)) {
42
+ process.env.DATA_DIR = path.resolve(CWD, process.env.DATA_DIR)
43
+ }
44
+
45
+ // 기본값도 CWD 기준
46
+ if (!process.env.MODEL_DIR) process.env.MODEL_DIR = path.join(CWD, 'models')
47
+ if (!process.env.DATA_DIR) process.env.DATA_DIR = path.join(CWD, 'data')
48
+
49
+ // 디렉토리 자동 생성
50
+ for (const dir of [process.env.MODEL_DIR, process.env.DATA_DIR]) {
51
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
52
+ }
53
+
54
+ // training 경로는 패키지 내부
55
+ process.env._TRAINING_DIR = path.join(PKG_ROOT, 'training')
56
+ process.env._PKG_ROOT = PKG_ROOT
57
+
58
+ require(path.join(PKG_ROOT, 'src', 'index.js'))
59
+ return
60
+ }
61
+
62
+ // ── help ──
63
+ console.log(`appback-ai-agent — AI game agent framework
64
+
65
+ Usage:
66
+ npx appback-ai-agent init Create .env and directories in current folder
67
+ npx appback-ai-agent start Start the agent (default)
68
+ npx appback-ai-agent help Show this help
69
+
70
+ Quick start:
71
+ npx appback-ai-agent init
72
+ # Edit .env → set AGENT_NAME
73
+ npx appback-ai-agent start
74
+ `)
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "appback-ai-agent",
3
+ "version": "1.0.0",
4
+ "description": "Self-improving AI game agent for ClawClash. Auto-discovers games, fights, collects data, and trains models.",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "appback-ai-agent": "bin/cli.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/",
12
+ "config/",
13
+ "training/",
14
+ ".env.example"
15
+ ],
16
+ "scripts": {
17
+ "start": "node src/index.js",
18
+ "dev": "node --watch src/index.js"
19
+ },
20
+ "keywords": [
21
+ "ai",
22
+ "game-agent",
23
+ "clawclash",
24
+ "onnx",
25
+ "self-improving",
26
+ "reinforcement-learning"
27
+ ],
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "https://github.com/appback/appback-ai-agent"
31
+ },
32
+ "license": "MIT",
33
+ "dependencies": {
34
+ "axios": "^1.7.0",
35
+ "better-sqlite3": "^11.0.0",
36
+ "dotenv": "^16.4.0",
37
+ "onnxruntime-node": "^1.17.0",
38
+ "socket.io-client": "^4.7.0"
39
+ },
40
+ "engines": {
41
+ "node": ">=18"
42
+ }
43
+ }
@@ -0,0 +1,427 @@
1
+ const BaseGameAdapter = require('../../core/BaseGameAdapter')
2
+ const GcApiClient = require('./GcApiClient')
3
+ const GcSocketClient = require('./GcSocketClient')
4
+ const GcStrategyEngine = require('./GcStrategyEngine')
5
+ const GcFeatureBuilder = require('./GcFeatureBuilder')
6
+ const GcEquipmentManager = require('./GcEquipmentManager')
7
+ const { createLogger } = require('../../utils/logger')
8
+ const log = createLogger('gc-adapter')
9
+
10
+ // v6.0 action labels: model output index → direction
11
+ const ACTION_LABELS = ['stay', 'up', 'down', 'left', 'right']
12
+
13
+ class GcAdapter extends BaseGameAdapter {
14
+ constructor(opts) {
15
+ super(opts)
16
+ this.api = new GcApiClient(this.config)
17
+ this.ws = new GcSocketClient(this.config)
18
+ this.strategyEngine = new GcStrategyEngine()
19
+ this.featureBuilder = new GcFeatureBuilder()
20
+ this.equipmentManager = new GcEquipmentManager(this.dataCollector?.store)
21
+ this.metrics = opts.metrics || null
22
+ this.activeGameId = null
23
+ this.mySlot = null
24
+ this.currentLoadout = null
25
+ this.gamePhase = null
26
+ this.lastTickNum = -1
27
+ this.sessionId = null
28
+ this._strategyLog = []
29
+ this._terrainCached = false
30
+ }
31
+
32
+ get gameName() { return 'claw-clash' }
33
+ get supportsRealtime() { return true }
34
+
35
+ async initialize() {
36
+ // Try loading identity from SQLite
37
+ if (this.dataCollector) {
38
+ const saved = this.dataCollector.store.getIdentity(this.gameName)
39
+ if (saved && !this.config.apiToken) {
40
+ this.apiToken = saved.api_token
41
+ this.agentId = saved.agent_id
42
+ this.api.setToken(this.apiToken)
43
+ log.info(`Loaded saved identity: ${saved.name} (${saved.agent_id})`)
44
+ }
45
+ }
46
+
47
+ // Use env token if provided
48
+ if (this.config.apiToken && !this.apiToken) {
49
+ this.apiToken = this.config.apiToken
50
+ this.api.setToken(this.apiToken)
51
+ }
52
+
53
+ // Validate existing token
54
+ if (this.apiToken) {
55
+ try {
56
+ const me = await this.api.getAgentMe()
57
+ this.agentId = me.id
58
+ log.info(`Agent: ${me.name} (${me.id})`)
59
+ } catch {
60
+ log.warn('Token invalid, will re-register')
61
+ this.apiToken = null
62
+ }
63
+ }
64
+
65
+ // Register if needed
66
+ if (!this.apiToken) {
67
+ const result = await this.api.register(this.config.agentName)
68
+ this.apiToken = result.token || result.api_token
69
+ this.agentId = result.id || result.agent_id
70
+ this.api.setToken(this.apiToken)
71
+ log.info(`Registered: ${this.config.agentName} → ${this.agentId}`)
72
+ log.warn('Save this token to GC_API_TOKEN env var!')
73
+ }
74
+
75
+ // Persist identity
76
+ if (this.dataCollector) {
77
+ this.dataCollector.store.saveIdentity(
78
+ this.gameName, this.agentId, this.apiToken, this.config.agentName
79
+ )
80
+ }
81
+
82
+ // Load equipment for feature builder + equipment manager
83
+ try {
84
+ const equip = await this.api.getEquipment()
85
+ this.featureBuilder.setEquipment(equip)
86
+ this.equipmentManager.setCatalog(equip)
87
+ this.equipmentManager.loadStats()
88
+ log.info('Equipment catalog loaded')
89
+ } catch (err) {
90
+ log.warn('Equipment load failed, using defaults', err.message)
91
+ }
92
+
93
+ // Try loading ONNX models (v6.0: 162 dims, 5 classes)
94
+ if (this.modelRegistry) {
95
+ await this.modelRegistry.loadModel('gc', 'gc_move_model', { featureDim: 162 }).catch(() => {})
96
+ this.modelRegistry.startWatcher()
97
+ }
98
+
99
+ // Connect WebSocket
100
+ this.ws.connect()
101
+ this.ws.onTick((data) => this._onTick(data))
102
+ this.ws.onGameState((data) => this._onGameState(data))
103
+ this.ws.onBattleEnded((data) => this._onBattleEnded(data))
104
+ }
105
+
106
+ async discoverGames() {
107
+ if (this.activeGameId) {
108
+ log.debug('Already in active game, skipping discovery')
109
+ return { status: 'busy' }
110
+ }
111
+
112
+ try {
113
+ const challenge = await this.api.getChallenge()
114
+ if (challenge.status === 'busy') return { status: 'busy' }
115
+ if (challenge.status === 'ready') return await this.joinGame()
116
+ return { status: challenge.status }
117
+ } catch (err) {
118
+ log.error('Discovery failed', err.message)
119
+ return { status: 'error' }
120
+ }
121
+ }
122
+
123
+ async joinGame() {
124
+ try {
125
+ // Select optimal loadout based on historical performance
126
+ this.currentLoadout = this.equipmentManager.selectLoadout()
127
+ const result = await this.api.submitChallenge(this.currentLoadout)
128
+
129
+ log.info(`Challenge result: ${result.status}`, result)
130
+
131
+ if (result.status === 'joined' || result.status === 'updated') {
132
+ this.activeGameId = result.game_id
133
+ this.mySlot = result.slot
134
+ this.strategyEngine.reset()
135
+ this._strategyLog = []
136
+ this._terrainCached = false
137
+ this.ws.joinGame(this.activeGameId)
138
+
139
+ // Start data collection session
140
+ if (this.dataCollector) {
141
+ this.sessionId = this.dataCollector.startSession(
142
+ this.gameName, this.activeGameId, this.mySlot
143
+ )
144
+ }
145
+
146
+ // Fetch full state to cache terrain
147
+ this._cacheTerrain()
148
+
149
+ log.info(`Joined game ${this.activeGameId}, slot=${this.mySlot}`)
150
+ return { status: 'joined', gameId: this.activeGameId }
151
+ }
152
+
153
+ if (result.status === 'queued') {
154
+ log.info('Queued for matchmaking')
155
+ return { status: 'queued' }
156
+ }
157
+
158
+ return { status: result.status }
159
+ } catch (err) {
160
+ log.error('Join failed', err.message)
161
+ return { status: 'error' }
162
+ }
163
+ }
164
+
165
+ async _cacheTerrain() {
166
+ try {
167
+ const state = await this.api.getGameState(this.activeGameId)
168
+ if (state?.arena) {
169
+ this.featureBuilder.setTerrain(state.arena)
170
+ this._terrainCached = true
171
+ log.info(`Terrain cached: ${this.featureBuilder.gridWidth}x${this.featureBuilder.gridHeight}`)
172
+ }
173
+ } catch (err) {
174
+ log.warn('Terrain cache failed, will retry on next tick', err.message)
175
+ }
176
+ }
177
+
178
+ _onGameState(data) {
179
+ if (!data) return
180
+ const prevPhase = this.gamePhase
181
+ this.gamePhase = data.state
182
+
183
+ if (prevPhase !== this.gamePhase) {
184
+ log.info(`Phase change: ${prevPhase} → ${this.gamePhase}`)
185
+ }
186
+
187
+ if (data.game_id && !this.activeGameId) {
188
+ this.activeGameId = data.game_id
189
+ this.mySlot = data.slot
190
+ this.strategyEngine.reset()
191
+ this._strategyLog = []
192
+ this._terrainCached = false
193
+ this.ws.joinGame(this.activeGameId)
194
+
195
+ if (this.dataCollector) {
196
+ this.sessionId = this.dataCollector.startSession(
197
+ this.gameName, this.activeGameId, this.mySlot
198
+ )
199
+ }
200
+
201
+ // Cache terrain for the new game
202
+ this._cacheTerrain()
203
+
204
+ log.info(`Assigned game from queue: ${this.activeGameId}, slot=${this.mySlot}`)
205
+ }
206
+ }
207
+
208
+ _onTick(data) {
209
+ if (!this.activeGameId || !data) return
210
+
211
+ const { tick, phase, agents, shrinkPhase, powerups, events, eliminations } = data
212
+
213
+ // Retry terrain cache if not yet loaded
214
+ if (!this._terrainCached && tick <= 3) {
215
+ this._cacheTerrain()
216
+ }
217
+
218
+ const me = this.mySlot !== null
219
+ ? agents?.find(a => a.slot === this.mySlot)
220
+ : null
221
+
222
+ if (!me) return
223
+
224
+ // Enrich agent data with equipment catalog
225
+ const enrichedMe = this.featureBuilder.enrichAgent(me)
226
+ const enrichedAgents = agents.map(a => this.featureBuilder.enrichAgent(a))
227
+
228
+ // Build game state for feature builder
229
+ const gameState = {
230
+ me: enrichedMe,
231
+ agents: enrichedAgents,
232
+ shrinkPhase: shrinkPhase || 0,
233
+ tick,
234
+ powerups,
235
+ gridWidth: this.featureBuilder.gridWidth,
236
+ gridHeight: this.featureBuilder.gridHeight,
237
+ maxTicks: 300,
238
+ }
239
+
240
+ // Build features on phase 0 (passive phase)
241
+ let moveFeatures = null
242
+ if (phase === 0 && me.alive) {
243
+ try {
244
+ moveFeatures = this.featureBuilder.buildMoveFeatures(enrichedMe, gameState)
245
+ } catch (err) {
246
+ log.debug('Feature build failed', err.message)
247
+ }
248
+
249
+ // Submit move decision
250
+ this._decideAndSubmitMove(enrichedMe, gameState, moveFeatures)
251
+ }
252
+
253
+ // Record tick data
254
+ if (this.dataCollector && this.sessionId) {
255
+ const tickState = { agents: agents.map(a => ({
256
+ slot: a.slot, hp: a.hp, maxHp: a.maxHp, x: a.x, y: a.y,
257
+ alive: a.alive, score: a.score,
258
+ })), shrinkPhase, eliminations }
259
+
260
+ this.dataCollector.recordTick(
261
+ this.sessionId, tick, phase, tickState, moveFeatures, null
262
+ )
263
+ }
264
+
265
+ if (!me.alive) return
266
+
267
+ // Strategy decision: phase 0, every N ticks
268
+ if (phase !== 0 || tick % this.config.strategyCooldownTicks !== 0) return
269
+ if (tick === this.lastTickNum) return
270
+ this.lastTickNum = tick
271
+
272
+ const strategy = this.strategyEngine.decide(gameState)
273
+
274
+ if (strategy) {
275
+ this._strategyLog.push({ tick, ...strategy })
276
+ this.api.submitStrategy(this.activeGameId, strategy)
277
+ .then(res => log.debug(`Strategy submitted at tick ${tick}`, res))
278
+ .catch(err => log.warn(`Strategy submit failed at tick ${tick}`, err.message))
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Decide move direction and submit to server.
284
+ * Priority: ONNX model → heuristic fallback
285
+ */
286
+ async _decideAndSubmitMove(me, gameState, features) {
287
+ if (!this.activeGameId || !me.alive) return
288
+
289
+ let direction = 'stay'
290
+
291
+ try {
292
+ // Try ONNX model inference
293
+ if (this.modelRegistry && features) {
294
+ const model = this.modelRegistry.getModel('gc', 'gc_move_model')
295
+ if (model) {
296
+ const actionMask = this.featureBuilder.buildActionMask(me, gameState)
297
+ const logits = await model.infer(features)
298
+
299
+ // Apply action mask
300
+ const masked = logits.map((v, i) => actionMask[i] ? v : -Infinity)
301
+ const bestIdx = masked.indexOf(Math.max(...masked))
302
+ direction = ACTION_LABELS[bestIdx] || 'stay'
303
+
304
+ log.debug(`Model move: ${direction} (logits: [${masked.map(v => v.toFixed(2)).join(',')}])`)
305
+ }
306
+ }
307
+ } catch (err) {
308
+ log.debug('Model inference failed, using heuristic', err.message)
309
+ }
310
+
311
+ // Heuristic fallback if no model
312
+ if (direction === 'stay' && !this.modelRegistry?.getModel('gc', 'gc_move_model')) {
313
+ direction = this._heuristicMove(me, gameState)
314
+ }
315
+
316
+ // Submit move to server
317
+ try {
318
+ await this.api.submitMove(this.activeGameId, direction)
319
+ } catch (err) {
320
+ log.debug(`Move submit failed: ${err.message}`)
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Simple heuristic: move toward nearest enemy if no model available
326
+ */
327
+ _heuristicMove(me, gameState) {
328
+ const enemies = gameState.agents
329
+ .filter(a => a.alive && a.slot !== me.slot)
330
+ .sort((a, b) => (Math.abs(a.x - me.x) + Math.abs(a.y - me.y)) - (Math.abs(b.x - me.x) + Math.abs(b.y - me.y)))
331
+
332
+ if (!enemies.length) return 'stay'
333
+
334
+ const target = enemies[0]
335
+ const actionMask = this.featureBuilder.buildActionMask(me, gameState)
336
+
337
+ // Prefer moving toward target
338
+ const dx = target.x - me.x
339
+ const dy = target.y - me.y
340
+
341
+ // Direction preferences based on target position
342
+ const prefs = []
343
+ if (Math.abs(dx) >= Math.abs(dy)) {
344
+ if (dx > 0) prefs.push(4) // right
345
+ else if (dx < 0) prefs.push(3) // left
346
+ if (dy > 0) prefs.push(2) // down
347
+ else if (dy < 0) prefs.push(1) // up
348
+ } else {
349
+ if (dy > 0) prefs.push(2) // down
350
+ else if (dy < 0) prefs.push(1) // up
351
+ if (dx > 0) prefs.push(4) // right
352
+ else if (dx < 0) prefs.push(3) // left
353
+ }
354
+
355
+ for (const idx of prefs) {
356
+ if (actionMask[idx]) return ACTION_LABELS[idx]
357
+ }
358
+
359
+ // Any valid move
360
+ for (let i = 1; i <= 4; i++) {
361
+ if (actionMask[i]) return ACTION_LABELS[i]
362
+ }
363
+
364
+ return 'stay'
365
+ }
366
+
367
+ _onBattleEnded(data) {
368
+ if (!this.activeGameId) return
369
+ log.info('Battle ended', data)
370
+ this.onGameEnd(this.activeGameId, data)
371
+ }
372
+
373
+ async onGameEnd(gameId, results) {
374
+ let myResult = null
375
+ if (results?.rankings) {
376
+ myResult = results.rankings.find(r => r.slot === this.mySlot)
377
+ if (myResult) {
378
+ log.info(`Result: rank=${myResult.placement}, score=${myResult.score}, kills=${myResult.kills}`)
379
+ }
380
+ }
381
+
382
+ // Track metrics
383
+ if (this.metrics && myResult) {
384
+ this.metrics.record(myResult)
385
+ }
386
+
387
+ // Track equipment performance
388
+ if (this.currentLoadout && myResult) {
389
+ this.equipmentManager.recordResult(
390
+ this.currentLoadout.weapon, this.currentLoadout.armor, myResult
391
+ )
392
+ }
393
+
394
+ // End data collection session
395
+ if (this.dataCollector && this.sessionId) {
396
+ this.dataCollector.endSession(this.sessionId, myResult, this._strategyLog)
397
+
398
+ const totalGames = this.dataCollector.getSessionCount(this.gameName)
399
+ log.info(`Total games played: ${totalGames}`)
400
+ }
401
+
402
+ // Cleanup
403
+ this.ws.leaveGame(gameId)
404
+ this.activeGameId = null
405
+ this.mySlot = null
406
+ this.currentLoadout = null
407
+ this.gamePhase = null
408
+ this.lastTickNum = -1
409
+ this.sessionId = null
410
+ this._strategyLog = []
411
+ this._terrainCached = false
412
+ this.strategyEngine.reset()
413
+ this.featureBuilder.clearTerrain()
414
+
415
+ this.eventBus.emit('game_ended', { game: this.gameName, gameId, results })
416
+ log.info('Ready for next game')
417
+ }
418
+
419
+ async shutdown() {
420
+ if (this.activeGameId) this.ws.leaveGame(this.activeGameId)
421
+ this.ws.disconnect()
422
+ if (this.modelRegistry) this.modelRegistry.stopWatcher()
423
+ log.info('GC adapter shut down')
424
+ }
425
+ }
426
+
427
+ module.exports = GcAdapter