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 +21 -0
- package/README.md +119 -0
- package/bin/cli.js +74 -0
- package/package.json +43 -0
- package/src/adapters/gc/GcAdapter.js +427 -0
- package/src/adapters/gc/GcApiClient.js +69 -0
- package/src/adapters/gc/GcEquipmentManager.js +144 -0
- package/src/adapters/gc/GcFeatureBuilder.js +370 -0
- package/src/adapters/gc/GcSocketClient.js +73 -0
- package/src/adapters/gc/GcStrategyEngine.js +103 -0
- package/src/adapters/gc/config.js +16 -0
- package/src/core/AgentManager.js +59 -0
- package/src/core/BaseGameAdapter.js +23 -0
- package/src/core/BaseModelProvider.js +17 -0
- package/src/core/DataCollector.js +48 -0
- package/src/core/EventBus.js +10 -0
- package/src/core/HealthMonitor.js +69 -0
- package/src/core/ModelRegistry.js +77 -0
- package/src/core/Scheduler.js +40 -0
- package/src/core/TrainingRunner.js +70 -0
- package/src/data/exporters/TrainingExporter.js +78 -0
- package/src/data/storage/SqliteStore.js +172 -0
- package/src/index.js +109 -0
- package/src/models/providers/OnnxProvider.js +78 -0
- package/src/models/providers/RuleBasedProvider.js +18 -0
- package/src/utils/logger.js +20 -0
- package/src/utils/metrics.js +96 -0
- package/src/utils/retry.js +21 -0
- package/training/__init__.py +0 -0
- package/training/models/__init__.py +0 -0
- package/training/models/gc_strategy_net.py +46 -0
- package/training/requirements.txt +6 -0
- package/training/train_gc_model.py +226 -0
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
|