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
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const axios = require('axios')
|
|
2
|
+
const { createLogger } = require('../../utils/logger')
|
|
3
|
+
const { retry } = require('../../utils/retry')
|
|
4
|
+
const log = createLogger('gc-api')
|
|
5
|
+
|
|
6
|
+
class GcApiClient {
|
|
7
|
+
constructor(config) {
|
|
8
|
+
this.client = axios.create({
|
|
9
|
+
baseURL: config.apiUrl,
|
|
10
|
+
timeout: 10000,
|
|
11
|
+
})
|
|
12
|
+
this.token = null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
setToken(token) {
|
|
16
|
+
this.token = token
|
|
17
|
+
this.client.defaults.headers.common['Authorization'] = `Bearer ${token}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async register(name) {
|
|
21
|
+
log.info(`Registering agent: ${name}`)
|
|
22
|
+
const { data } = await this.client.post('/agents/register', {
|
|
23
|
+
name,
|
|
24
|
+
model_name: 'appback-ai-agent',
|
|
25
|
+
})
|
|
26
|
+
return data
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async getChallenge() {
|
|
30
|
+
const { data } = await retry(() => this.client.get('/challenge'))
|
|
31
|
+
return data
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async submitChallenge(loadout = {}) {
|
|
35
|
+
const { data } = await this.client.post('/challenge', {
|
|
36
|
+
weapon: loadout.weapon || 'sword',
|
|
37
|
+
armor: loadout.armor || 'leather',
|
|
38
|
+
tier: loadout.tier || 'basic',
|
|
39
|
+
})
|
|
40
|
+
return data
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async submitStrategy(gameId, strategy) {
|
|
44
|
+
const { data } = await this.client.post(`/games/${gameId}/strategy`, strategy)
|
|
45
|
+
return data
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async getGameState(gameId) {
|
|
49
|
+
const { data } = await this.client.get(`/games/${gameId}/state`)
|
|
50
|
+
return data
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async getEquipment() {
|
|
54
|
+
const { data } = await this.client.get('/equipment')
|
|
55
|
+
return data
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async getAgentMe() {
|
|
59
|
+
const { data } = await this.client.get('/agents/me')
|
|
60
|
+
return data
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async submitMove(gameId, direction) {
|
|
64
|
+
const { data } = await this.client.post(`/games/${gameId}/move`, { direction })
|
|
65
|
+
return data
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = GcApiClient
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
const { createLogger } = require('../../utils/logger')
|
|
2
|
+
const log = createLogger('gc-equip')
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GcEquipmentManager — Selects optimal weapon/armor based on historical win rates.
|
|
6
|
+
* Tracks per-loadout performance and adapts over time.
|
|
7
|
+
*/
|
|
8
|
+
class GcEquipmentManager {
|
|
9
|
+
constructor(store) {
|
|
10
|
+
this.store = store
|
|
11
|
+
this.catalog = { weapons: [], armors: [] }
|
|
12
|
+
this._stats = new Map() // "weapon:armor" → { games, wins, avgRank, totalScore }
|
|
13
|
+
this._minGamesForStats = 5
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
setCatalog(equipment) {
|
|
17
|
+
this.catalog.weapons = equipment.weapons || []
|
|
18
|
+
this.catalog.armors = equipment.armors || []
|
|
19
|
+
log.info(`Catalog: ${this.catalog.weapons.length} weapons, ${this.catalog.armors.length} armors`)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Load historical loadout performance from SQLite
|
|
24
|
+
*/
|
|
25
|
+
loadStats() {
|
|
26
|
+
if (!this.store) return
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const sessions = this.store.db.prepare(`
|
|
30
|
+
SELECT result, strategy_log FROM game_sessions
|
|
31
|
+
WHERE game = 'claw-clash' AND result IS NOT NULL
|
|
32
|
+
`).all()
|
|
33
|
+
|
|
34
|
+
for (const s of sessions) {
|
|
35
|
+
const result = JSON.parse(s.result || '{}')
|
|
36
|
+
const stratLog = JSON.parse(s.strategy_log || '[]')
|
|
37
|
+
if (!result.placement) continue
|
|
38
|
+
|
|
39
|
+
// Extract loadout from first strategy entry or default
|
|
40
|
+
const weapon = result.weapon || 'sword'
|
|
41
|
+
const armor = result.armor || 'leather'
|
|
42
|
+
const key = `${weapon}:${armor}`
|
|
43
|
+
|
|
44
|
+
if (!this._stats.has(key)) {
|
|
45
|
+
this._stats.set(key, { games: 0, wins: 0, totalRank: 0, totalScore: 0 })
|
|
46
|
+
}
|
|
47
|
+
const stat = this._stats.get(key)
|
|
48
|
+
stat.games++
|
|
49
|
+
if (result.placement === 1) stat.wins++
|
|
50
|
+
stat.totalRank += result.placement
|
|
51
|
+
stat.totalScore += result.score || 0
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
log.info(`Loaded stats for ${this._stats.size} loadout combinations`)
|
|
55
|
+
} catch (err) {
|
|
56
|
+
log.warn('Failed to load loadout stats', err.message)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Record a game result for a specific loadout
|
|
62
|
+
*/
|
|
63
|
+
recordResult(weapon, armor, result) {
|
|
64
|
+
const key = `${weapon}:${armor}`
|
|
65
|
+
if (!this._stats.has(key)) {
|
|
66
|
+
this._stats.set(key, { games: 0, wins: 0, totalRank: 0, totalScore: 0 })
|
|
67
|
+
}
|
|
68
|
+
const stat = this._stats.get(key)
|
|
69
|
+
stat.games++
|
|
70
|
+
if (result.placement === 1) stat.wins++
|
|
71
|
+
stat.totalRank += result.placement || 8
|
|
72
|
+
stat.totalScore += result.score || 0
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Select the best loadout based on historical performance.
|
|
77
|
+
* Uses UCB1 (Upper Confidence Bound) for exploration vs exploitation.
|
|
78
|
+
*/
|
|
79
|
+
selectLoadout() {
|
|
80
|
+
const weapons = this.catalog.weapons.filter(w => w.is_active !== false)
|
|
81
|
+
const armors = this.catalog.armors.filter(a => a.is_active !== false)
|
|
82
|
+
|
|
83
|
+
if (weapons.length === 0 || armors.length === 0) {
|
|
84
|
+
return { weapon: 'sword', armor: 'leather', tier: 'basic' }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const totalGames = Array.from(this._stats.values()).reduce((s, v) => s + v.games, 0)
|
|
88
|
+
|
|
89
|
+
let bestScore = -Infinity
|
|
90
|
+
let bestLoadout = null
|
|
91
|
+
|
|
92
|
+
for (const w of weapons) {
|
|
93
|
+
for (const a of armors) {
|
|
94
|
+
const key = `${w.slug}:${a.slug}`
|
|
95
|
+
const stat = this._stats.get(key)
|
|
96
|
+
|
|
97
|
+
let score
|
|
98
|
+
if (!stat || stat.games < this._minGamesForStats) {
|
|
99
|
+
// Unexplored: give high exploration bonus
|
|
100
|
+
score = 10 + Math.random()
|
|
101
|
+
} else {
|
|
102
|
+
// UCB1: avgPerformance + exploration bonus
|
|
103
|
+
const avgRank = stat.totalRank / stat.games
|
|
104
|
+
// Lower rank is better, so invert (9 - avgRank) / 8
|
|
105
|
+
const performance = (9 - avgRank) / 8
|
|
106
|
+
const exploration = Math.sqrt(2 * Math.log(totalGames + 1) / stat.games)
|
|
107
|
+
score = performance + exploration
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (score > bestScore) {
|
|
111
|
+
bestScore = score
|
|
112
|
+
bestLoadout = { weapon: w.slug, armor: a.slug, tier: 'basic' }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (bestLoadout) {
|
|
118
|
+
log.info(`Selected loadout: ${bestLoadout.weapon} + ${bestLoadout.armor} (score: ${bestScore.toFixed(3)})`)
|
|
119
|
+
return bestLoadout
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { weapon: 'sword', armor: 'leather', tier: 'basic' }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Get performance summary for all loadouts
|
|
127
|
+
*/
|
|
128
|
+
getSummary() {
|
|
129
|
+
const summary = []
|
|
130
|
+
for (const [key, stat] of this._stats) {
|
|
131
|
+
if (stat.games === 0) continue
|
|
132
|
+
summary.push({
|
|
133
|
+
loadout: key,
|
|
134
|
+
games: stat.games,
|
|
135
|
+
winRate: (stat.wins / stat.games * 100).toFixed(1) + '%',
|
|
136
|
+
avgRank: (stat.totalRank / stat.games).toFixed(2),
|
|
137
|
+
avgScore: (stat.totalScore / stat.games).toFixed(0),
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
return summary.sort((a, b) => parseFloat(a.avgRank) - parseFloat(b.avgRank))
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = GcEquipmentManager
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GcFeatureBuilder — Client-side port of server featureBuilder.js (v6.0)
|
|
3
|
+
* Converts tick data into ONNX-compatible feature vectors.
|
|
4
|
+
*
|
|
5
|
+
* v6.0: 162-dim move features (attack is automatic on server)
|
|
6
|
+
*
|
|
7
|
+
* Layout:
|
|
8
|
+
* [0..21] Self features (22)
|
|
9
|
+
* [22..25] Strategy (4)
|
|
10
|
+
* [26..115] Opponents 6×15 = 90
|
|
11
|
+
* [116..119] Arena context (4)
|
|
12
|
+
* [120..144] 5×5 local terrain (25)
|
|
13
|
+
* [145..148] Directional move validity (4)
|
|
14
|
+
* [149..156] BFS path distances (8)
|
|
15
|
+
* [157..160] Attack possible after move (4)
|
|
16
|
+
* [161] Can attack from current pos (1)
|
|
17
|
+
* Total: 162
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const WEAPON_SLUGS = ['sword', 'dagger', 'hammer', 'bow', 'spear']
|
|
21
|
+
const STRATEGY_MODES = ['aggressive', 'balanced', 'defensive']
|
|
22
|
+
const DIRECTIONS = [
|
|
23
|
+
{ dir: 'up', dx: 0, dy: -1 },
|
|
24
|
+
{ dir: 'down', dx: 0, dy: 1 },
|
|
25
|
+
{ dir: 'left', dx: -1, dy: 0 },
|
|
26
|
+
{ dir: 'right', dx: 1, dy: 0 }
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
class GcFeatureBuilder {
|
|
30
|
+
constructor(equipment = {}) {
|
|
31
|
+
this.weapons = equipment.weapons || {}
|
|
32
|
+
this.armors = equipment.armors || {}
|
|
33
|
+
this.terrain = null
|
|
34
|
+
this.gridWidth = 8
|
|
35
|
+
this.gridHeight = 8
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
setEquipment(equipment) {
|
|
39
|
+
this.weapons = equipment.weapons || {}
|
|
40
|
+
this.armors = equipment.armors || {}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Cache terrain data from GET /games/:id/state response
|
|
45
|
+
*/
|
|
46
|
+
setTerrain(arena) {
|
|
47
|
+
if (!arena) return
|
|
48
|
+
this.gridWidth = arena.width || arena.grid_width || 8
|
|
49
|
+
this.gridHeight = arena.height || arena.grid_height || 8
|
|
50
|
+
this.terrain = arena.terrain || null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
clearTerrain() {
|
|
54
|
+
this.terrain = null
|
|
55
|
+
this.gridWidth = 8
|
|
56
|
+
this.gridHeight = 8
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Enrich a tick agent object with weapon/armor details from equipment catalog
|
|
61
|
+
*/
|
|
62
|
+
enrichAgent(agent) {
|
|
63
|
+
const weaponSlug = agent.weapon?.slug || agent.weapon || 'sword'
|
|
64
|
+
const armorSlug = agent.armor?.slug || agent.armor || 'leather'
|
|
65
|
+
|
|
66
|
+
const weaponData = this._findWeapon(weaponSlug)
|
|
67
|
+
const armorData = this._findArmor(armorSlug)
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
...agent,
|
|
71
|
+
weapon: {
|
|
72
|
+
slug: weaponSlug,
|
|
73
|
+
damage: weaponData?.damage || 10,
|
|
74
|
+
range: weaponData?.range || 1,
|
|
75
|
+
rangeType: weaponData?.range_type || weaponData?.rangeType || 'adjacent',
|
|
76
|
+
cooldown: weaponData?.cooldown || 0,
|
|
77
|
+
atkSpeed: weaponData?.atk_speed || weaponData?.atkSpeed || 100,
|
|
78
|
+
moveSpeed: weaponData?.move_speed || weaponData?.moveSpeed || 100,
|
|
79
|
+
},
|
|
80
|
+
armor: {
|
|
81
|
+
slug: armorSlug,
|
|
82
|
+
dmgReduction: armorData?.dmg_reduction || armorData?.dmgReduction || 0,
|
|
83
|
+
evasion: armorData?.evasion || 0,
|
|
84
|
+
},
|
|
85
|
+
effectiveSpeed: agent.speed || agent.effectiveSpeed || 100,
|
|
86
|
+
kills: agent.kills || 0,
|
|
87
|
+
damageDealt: agent.damageDealt || agent.damage_dealt || 0,
|
|
88
|
+
damageTaken: agent.damageTaken || agent.damage_taken || 0,
|
|
89
|
+
survivedTicks: agent.survivedTicks || agent.survived_ticks || 0,
|
|
90
|
+
actionAcc: agent.actionAcc || agent.action_acc || 0,
|
|
91
|
+
idleTicks: agent.idleTicks || agent.idle_ticks || 0,
|
|
92
|
+
score: agent.score || 0,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
_findWeapon(slug) {
|
|
97
|
+
if (this.weapons[slug]) return this.weapons[slug]
|
|
98
|
+
if (Array.isArray(this.weapons)) return this.weapons.find(w => w.slug === slug)
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
_findArmor(slug) {
|
|
103
|
+
if (this.armors[slug]) return this.armors[slug]
|
|
104
|
+
if (Array.isArray(this.armors)) return this.armors.find(a => a.slug === slug)
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Build 162-dim move feature vector (v6.0)
|
|
110
|
+
*/
|
|
111
|
+
buildMoveFeatures(agent, gameState) {
|
|
112
|
+
const gridW = this.gridWidth
|
|
113
|
+
const gridH = this.gridHeight
|
|
114
|
+
const terrain = this.terrain
|
|
115
|
+
const living = gameState.agents.filter(a => a.alive)
|
|
116
|
+
const shrinkPhase = gameState.shrinkPhase || gameState.shrink_phase || 0
|
|
117
|
+
|
|
118
|
+
const vec = new Array(162).fill(0)
|
|
119
|
+
let idx = 0
|
|
120
|
+
|
|
121
|
+
// --- Self features (22) ---
|
|
122
|
+
vec[idx++] = safe(agent.hp / agent.maxHp) // 0
|
|
123
|
+
vec[idx++] = safe(agent.x / gridW) // 1
|
|
124
|
+
vec[idx++] = safe(agent.y / gridH) // 2
|
|
125
|
+
vec[idx++] = safe((agent.weapon?.damage || 10) / 20) // 3
|
|
126
|
+
vec[idx++] = safe((agent.weapon?.range || 1) / 5) // 4
|
|
127
|
+
vec[idx++] = safe((agent.weapon?.cooldown || 0) / 10) // 5
|
|
128
|
+
vec[idx++] = safe((agent.effectiveSpeed || 100) / 120) // 6
|
|
129
|
+
vec[idx++] = safe((agent.effectiveSpeed || 100) / 120) // 7 (unified, same as 6)
|
|
130
|
+
vec[idx++] = safe((agent.armor?.dmgReduction || 0) / 50) // 8
|
|
131
|
+
vec[idx++] = safe((agent.armor?.evasion || 0) / 0.5) // 9
|
|
132
|
+
vec[idx++] = safe((agent.score || 0) / 1000) // 10
|
|
133
|
+
vec[idx++] = safe((agent.kills || 0) / 8) // 11
|
|
134
|
+
vec[idx++] = safe((agent.damageTaken || 0) / 1000) // 12
|
|
135
|
+
vec[idx++] = safe((agent.damageDealt || 0) / 1000) // 13
|
|
136
|
+
vec[idx++] = safe((agent.survivedTicks || 0) / 300) // 14
|
|
137
|
+
vec[idx++] = safe((agent.actionAcc || 0) / 200) // 15
|
|
138
|
+
vec[idx++] = safe((agent.idleTicks || 0) / 30) // 16
|
|
139
|
+
// weapon one-hot (5)
|
|
140
|
+
const wIdx = WEAPON_SLUGS.indexOf(agent.weapon?.slug || 'sword')
|
|
141
|
+
for (let w = 0; w < 5; w++) vec[idx++] = wIdx === w ? 1 : 0 // 17-21
|
|
142
|
+
// idx = 22
|
|
143
|
+
|
|
144
|
+
// --- Strategy (4) ---
|
|
145
|
+
const modeIdx = STRATEGY_MODES.indexOf(agent.strategy?.mode || 'balanced')
|
|
146
|
+
for (let m = 0; m < 3; m++) vec[idx++] = modeIdx === m ? 1 : 0 // 22-24
|
|
147
|
+
vec[idx++] = safe((agent.strategy?.flee_threshold || 15) / 100) // 25
|
|
148
|
+
// idx = 26
|
|
149
|
+
|
|
150
|
+
// --- Opponents 6×15 = 90 ---
|
|
151
|
+
const enemies = gameState.agents
|
|
152
|
+
.filter(a => a.alive && a.slot !== agent.slot)
|
|
153
|
+
.sort((a, b) => manhattan(agent, a) - manhattan(agent, b))
|
|
154
|
+
.slice(0, 6)
|
|
155
|
+
|
|
156
|
+
for (let e = 0; e < 6; e++) {
|
|
157
|
+
const startIdx = 26 + e * 15
|
|
158
|
+
if (e >= enemies.length) { idx = startIdx + 15; continue }
|
|
159
|
+
idx = startIdx
|
|
160
|
+
const en = enemies[e]
|
|
161
|
+
const dist = manhattan(agent, en)
|
|
162
|
+
vec[idx++] = safe(en.hp / en.maxHp) // +0
|
|
163
|
+
vec[idx++] = safe(en.x / gridW) // +1
|
|
164
|
+
vec[idx++] = safe(en.y / gridH) // +2
|
|
165
|
+
vec[idx++] = safe((en.x - agent.x) / gridW) // +3
|
|
166
|
+
vec[idx++] = safe((en.y - agent.y) / gridH) // +4
|
|
167
|
+
vec[idx++] = safe(dist / 14) // +5
|
|
168
|
+
vec[idx++] = safe((en.weapon?.damage || 10) / 20) // +6
|
|
169
|
+
vec[idx++] = safe((en.weapon?.range || 1) / 5) // +7
|
|
170
|
+
vec[idx++] = safe((en.armor?.evasion || 0) / 0.5) // +8
|
|
171
|
+
vec[idx++] = safe((en.kills || 0) / 8) // +9
|
|
172
|
+
vec[idx++] = inRange(agent, en) ? 1 : 0 // +10
|
|
173
|
+
vec[idx++] = dist <= (agent.weapon?.range || 1) ? 1 : 0 // +11
|
|
174
|
+
vec[idx++] = en.alive ? 1 : 0 // +12
|
|
175
|
+
const enWIdx = WEAPON_SLUGS.indexOf(en.weapon?.slug || 'sword')
|
|
176
|
+
vec[idx++] = safe((enWIdx + 1) / 5) // +13
|
|
177
|
+
vec[idx++] = en.weapon?.rangeType === 'ranged' ? 1 : 0 // +14
|
|
178
|
+
}
|
|
179
|
+
idx = 116
|
|
180
|
+
|
|
181
|
+
// --- Arena context (4) ---
|
|
182
|
+
vec[idx++] = safe(shrinkPhase / 3) // 116
|
|
183
|
+
vec[idx++] = safe(living.length / 8) // 117
|
|
184
|
+
const nearPU = findNearestPowerup(agent, gameState.powerups)
|
|
185
|
+
vec[idx++] = nearPU ? safe(manhattan(agent, nearPU) / 14) : 1.0 // 118
|
|
186
|
+
vec[idx++] = 1.0 // 119 (heal tile removed, reserved)
|
|
187
|
+
// idx = 120
|
|
188
|
+
|
|
189
|
+
// --- 5×5 local terrain (25) --- [120..144]
|
|
190
|
+
const occupied = buildOccupiedSet(gameState, agent.slot)
|
|
191
|
+
for (let ly = -2; ly <= 2; ly++) {
|
|
192
|
+
for (let lx = -2; lx <= 2; lx++) {
|
|
193
|
+
const wx = agent.x + lx
|
|
194
|
+
const wy = agent.y + ly
|
|
195
|
+
if (wx < 0 || wx >= gridW || wy < 0 || wy >= gridH) {
|
|
196
|
+
vec[idx++] = 0.33 // out of bounds = wall
|
|
197
|
+
} else {
|
|
198
|
+
vec[idx++] = safe(getTerrain(terrain, wx, wy) / 3)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// idx = 145
|
|
203
|
+
|
|
204
|
+
// --- Directional move validity (4) --- [145..148]
|
|
205
|
+
const moveValidity = []
|
|
206
|
+
for (const { dx, dy } of DIRECTIONS) {
|
|
207
|
+
const nx = agent.x + dx
|
|
208
|
+
const ny = agent.y + dy
|
|
209
|
+
const valid = nx >= 0 && nx < gridW && ny >= 0 && ny < gridH
|
|
210
|
+
&& getTerrain(terrain, nx, ny) !== 1
|
|
211
|
+
&& getTerrain(terrain, nx, ny) !== 2
|
|
212
|
+
&& !occupied.has(`${nx},${ny}`)
|
|
213
|
+
moveValidity.push(valid ? 1 : 0)
|
|
214
|
+
vec[idx++] = valid ? 1 : 0
|
|
215
|
+
}
|
|
216
|
+
// idx = 149
|
|
217
|
+
|
|
218
|
+
// --- BFS path distances (8) --- [149..156]
|
|
219
|
+
const nearestEnemy = enemies[0] || null
|
|
220
|
+
const maxDist = gridW + gridH
|
|
221
|
+
|
|
222
|
+
// BFS to nearest enemy per direction [149..152]
|
|
223
|
+
for (let d = 0; d < 4; d++) {
|
|
224
|
+
if (!moveValidity[d] || !nearestEnemy) {
|
|
225
|
+
vec[idx++] = 1.0
|
|
226
|
+
} else {
|
|
227
|
+
const nx = agent.x + DIRECTIONS[d].dx
|
|
228
|
+
const ny = agent.y + DIRECTIONS[d].dy
|
|
229
|
+
const dist = bfsDistance(nx, ny, nearestEnemy.x, nearestEnemy.y, terrain, gridW, gridH)
|
|
230
|
+
vec[idx++] = safe(dist / maxDist)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// BFS to nearest powerup per direction [153..156]
|
|
235
|
+
for (let d = 0; d < 4; d++) {
|
|
236
|
+
if (!moveValidity[d] || !nearPU) {
|
|
237
|
+
vec[idx++] = 1.0
|
|
238
|
+
} else {
|
|
239
|
+
const nx = agent.x + DIRECTIONS[d].dx
|
|
240
|
+
const ny = agent.y + DIRECTIONS[d].dy
|
|
241
|
+
const dist = bfsDistance(nx, ny, nearPU.x, nearPU.y, terrain, gridW, gridH)
|
|
242
|
+
vec[idx++] = safe(dist / maxDist)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// idx = 157
|
|
246
|
+
|
|
247
|
+
// --- Attack possible after move (4) --- [157..160]
|
|
248
|
+
for (let d = 0; d < 4; d++) {
|
|
249
|
+
if (!moveValidity[d]) {
|
|
250
|
+
vec[idx++] = 0
|
|
251
|
+
} else {
|
|
252
|
+
const nx = agent.x + DIRECTIONS[d].dx
|
|
253
|
+
const ny = agent.y + DIRECTIONS[d].dy
|
|
254
|
+
const fakeAgent = { x: nx, y: ny, weapon: agent.weapon }
|
|
255
|
+
const canAttack = enemies.some(en => inRange(fakeAgent, en))
|
|
256
|
+
vec[idx++] = canAttack ? 1 : 0
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// idx = 161
|
|
260
|
+
|
|
261
|
+
// --- Can attack from current position (1) --- [161]
|
|
262
|
+
vec[idx++] = enemies.some(en => inRange(agent, en)) ? 1 : 0
|
|
263
|
+
// idx = 162
|
|
264
|
+
|
|
265
|
+
return vec
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Build action mask for 5-class output: [stay, up, down, left, right]
|
|
270
|
+
*/
|
|
271
|
+
buildActionMask(agent, gameState) {
|
|
272
|
+
const gridW = this.gridWidth
|
|
273
|
+
const gridH = this.gridHeight
|
|
274
|
+
const terrain = this.terrain
|
|
275
|
+
const occupied = buildOccupiedSet(gameState, agent.slot)
|
|
276
|
+
|
|
277
|
+
const mask = [1, 0, 0, 0, 0] // stay always valid
|
|
278
|
+
|
|
279
|
+
for (let d = 0; d < DIRECTIONS.length; d++) {
|
|
280
|
+
const { dx, dy } = DIRECTIONS[d]
|
|
281
|
+
const nx = agent.x + dx
|
|
282
|
+
const ny = agent.y + dy
|
|
283
|
+
const valid = nx >= 0 && nx < gridW && ny >= 0 && ny < gridH
|
|
284
|
+
&& getTerrain(terrain, nx, ny) !== 1
|
|
285
|
+
&& getTerrain(terrain, nx, ny) !== 2
|
|
286
|
+
&& !occupied.has(`${nx},${ny}`)
|
|
287
|
+
mask[d + 1] = valid ? 1 : 0
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return mask
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// =============================================
|
|
295
|
+
// Utility functions
|
|
296
|
+
// =============================================
|
|
297
|
+
|
|
298
|
+
function safe(v) {
|
|
299
|
+
if (!isFinite(v) || isNaN(v)) return 0
|
|
300
|
+
return Math.max(-10, Math.min(10, v))
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function manhattan(a, b) {
|
|
304
|
+
return Math.abs(a.x - b.x) + Math.abs(a.y - b.y)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function getTerrain(terrain, x, y) {
|
|
308
|
+
if (!terrain || !terrain[y]) return 0
|
|
309
|
+
return terrain[y][x] || 0
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function inRange(agent, target) {
|
|
313
|
+
const dx = Math.abs(agent.x - target.x)
|
|
314
|
+
const dy = Math.abs(agent.y - target.y)
|
|
315
|
+
const dist = dx + dy
|
|
316
|
+
const range = agent.weapon?.range || 1
|
|
317
|
+
switch (agent.weapon?.rangeType) {
|
|
318
|
+
case 'adjacent': return dist >= 1 && dist <= range
|
|
319
|
+
case 'pierce': return (dx === 0 && dy >= 1 && dy <= range) || (dy === 0 && dx >= 1 && dx <= range)
|
|
320
|
+
case 'ranged': return dist >= 2 && dist <= range
|
|
321
|
+
default: return dist >= 1 && dist <= range
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function findNearestPowerup(agent, powerups) {
|
|
326
|
+
if (!powerups?.length) return null
|
|
327
|
+
let best = null, bestDist = Infinity
|
|
328
|
+
for (const p of powerups) {
|
|
329
|
+
const d = manhattan(agent, p)
|
|
330
|
+
if (d < bestDist) { bestDist = d; best = p }
|
|
331
|
+
}
|
|
332
|
+
return best
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function buildOccupiedSet(gameState, excludeSlot) {
|
|
336
|
+
const occupied = new Set()
|
|
337
|
+
for (const a of (gameState.agents || [])) {
|
|
338
|
+
if (a.alive && a.slot !== excludeSlot) occupied.add(`${a.x},${a.y}`)
|
|
339
|
+
}
|
|
340
|
+
return occupied
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function bfsDistance(sx, sy, tx, ty, terrain, gridW, gridH) {
|
|
344
|
+
if (sx === tx && sy === ty) return 0
|
|
345
|
+
|
|
346
|
+
const visited = new Set()
|
|
347
|
+
visited.add(`${sx},${sy}`)
|
|
348
|
+
const queue = [{ x: sx, y: sy, dist: 0 }]
|
|
349
|
+
let head = 0
|
|
350
|
+
|
|
351
|
+
while (head < queue.length) {
|
|
352
|
+
const { x, y, dist } = queue[head++]
|
|
353
|
+
for (const { dx, dy } of DIRECTIONS) {
|
|
354
|
+
const nx = x + dx
|
|
355
|
+
const ny = y + dy
|
|
356
|
+
if (nx === tx && ny === ty) return dist + 1
|
|
357
|
+
if (nx < 0 || nx >= gridW || ny < 0 || ny >= gridH) continue
|
|
358
|
+
const t = getTerrain(terrain, nx, ny)
|
|
359
|
+
if (t === 1 || t === 2) continue
|
|
360
|
+
const key = `${nx},${ny}`
|
|
361
|
+
if (visited.has(key)) continue
|
|
362
|
+
visited.add(key)
|
|
363
|
+
queue.push({ x: nx, y: ny, dist: dist + 1 })
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return gridW + gridH // unreachable
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
module.exports = GcFeatureBuilder
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const { io } = require('socket.io-client')
|
|
2
|
+
const { createLogger } = require('../../utils/logger')
|
|
3
|
+
const log = createLogger('gc-socket')
|
|
4
|
+
|
|
5
|
+
class GcSocketClient {
|
|
6
|
+
constructor(config) {
|
|
7
|
+
this.wsUrl = config.wsUrl
|
|
8
|
+
this.socket = null
|
|
9
|
+
this._handlers = new Map()
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
connect() {
|
|
13
|
+
if (this.socket?.connected) return
|
|
14
|
+
|
|
15
|
+
log.info(`Connecting to ${this.wsUrl}`)
|
|
16
|
+
this.socket = io(this.wsUrl, {
|
|
17
|
+
transports: ['websocket', 'polling'],
|
|
18
|
+
reconnection: true,
|
|
19
|
+
reconnectionDelay: 2000,
|
|
20
|
+
reconnectionAttempts: Infinity,
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
this.socket.on('connect', () => {
|
|
24
|
+
log.info('WebSocket connected')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
this.socket.on('disconnect', (reason) => {
|
|
28
|
+
log.warn('WebSocket disconnected', reason)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
this.socket.on('connect_error', (err) => {
|
|
32
|
+
log.error('WebSocket connection error', err.message)
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
joinGame(gameId) {
|
|
37
|
+
if (!this.socket?.connected) return
|
|
38
|
+
log.info(`Joining game room: ${gameId}`)
|
|
39
|
+
this.socket.emit('join_game', gameId)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
leaveGame(gameId) {
|
|
43
|
+
if (!this.socket?.connected) return
|
|
44
|
+
log.info(`Leaving game room: ${gameId}`)
|
|
45
|
+
this.socket.emit('leave_game', gameId)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
onTick(handler) {
|
|
49
|
+
this.socket?.on('tick', handler)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
onGameState(handler) {
|
|
53
|
+
this.socket?.on('game_state', handler)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
onBattleEnded(handler) {
|
|
57
|
+
this.socket?.on('battle_ended', handler)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
off(event, handler) {
|
|
61
|
+
this.socket?.off(event, handler)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
disconnect() {
|
|
65
|
+
if (this.socket) {
|
|
66
|
+
this.socket.disconnect()
|
|
67
|
+
this.socket = null
|
|
68
|
+
log.info('WebSocket disconnected')
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = GcSocketClient
|