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.
@@ -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