terminal-destiny-agent-sdk 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/examples/claude-agent.js +166 -0
- package/examples/wanderer.js +107 -0
- package/package.json +20 -0
- package/src/GameState.js +95 -0
- package/src/TerminalDestinyAgent.js +378 -0
- package/src/constants.js +14 -0
- package/src/index.js +3 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Agent — an AI-powered agent that uses Claude to decide what to do
|
|
3
|
+
* every game tick based on the current world state.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* AGENT_TOKEN=<your-token> ANTHROPIC_API_KEY=<key> node examples/claude-agent.js
|
|
7
|
+
*
|
|
8
|
+
* The agent:
|
|
9
|
+
* 1. Reads the world state every 3 seconds
|
|
10
|
+
* 2. Sends it to Claude Haiku with a prompt
|
|
11
|
+
* 3. Executes the action Claude returns (move/attack/chat/zone)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
15
|
+
import { TerminalDestinyAgent } from '../src/index.js';
|
|
16
|
+
|
|
17
|
+
const AGENT_TOKEN = process.env.AGENT_TOKEN;
|
|
18
|
+
const SERVER_URL = process.env.SERVER_URL || 'http://localhost:4000';
|
|
19
|
+
const ANTHROPIC_KEY = process.env.ANTHROPIC_API_KEY;
|
|
20
|
+
const TICK_MS = 3000; // think + act every 3 seconds
|
|
21
|
+
|
|
22
|
+
if (!AGENT_TOKEN) { console.error('AGENT_TOKEN required'); process.exit(1); }
|
|
23
|
+
if (!ANTHROPIC_KEY) { console.error('ANTHROPIC_API_KEY required'); process.exit(1); }
|
|
24
|
+
|
|
25
|
+
const anthropic = new Anthropic({ apiKey: ANTHROPIC_KEY });
|
|
26
|
+
const agent = new TerminalDestinyAgent({ serverUrl: SERVER_URL, token: AGENT_TOKEN });
|
|
27
|
+
|
|
28
|
+
// ── AI brain ───────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
const SYSTEM_PROMPT = `You are an AI agent playing an online multiplayer game called Terminal Destiny.
|
|
31
|
+
You receive a text summary of your current game state and must respond with exactly one action.
|
|
32
|
+
|
|
33
|
+
Available actions (respond with ONLY one of these, no extra text):
|
|
34
|
+
MOVE <x> <y> — move to coordinates (0-8192)
|
|
35
|
+
ATTACK <playerId> — attack a player by their socket id
|
|
36
|
+
CHAT <message> — send a chat message (max 120 chars)
|
|
37
|
+
ZONE <zoneName> — travel to a zone: terminal, darkFields, crimsonRidge, voidExpanse
|
|
38
|
+
NPC <message> — talk to the Destiny NPC (terminal zone only)
|
|
39
|
+
WAIT — do nothing this tick
|
|
40
|
+
|
|
41
|
+
Strategy guidelines:
|
|
42
|
+
- Keep your HP above 30% — retreat to terminal if low
|
|
43
|
+
- Attack players who are close (dist < 80) and have lower HP than you
|
|
44
|
+
- Explore different zones periodically
|
|
45
|
+
- Chat occasionally to seem alive
|
|
46
|
+
- Talk to the Destiny NPC in the terminal to learn about the world`;
|
|
47
|
+
|
|
48
|
+
async function think(stateDescription, recentEvents) {
|
|
49
|
+
const userPrompt = `CURRENT STATE:\n${stateDescription}\n\nRECENT EVENTS:\n${recentEvents.slice(-6).join('\n') || 'none'}\n\nWhat is your next action?`;
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const response = await anthropic.messages.create({
|
|
53
|
+
model: 'claude-haiku-4-5-20251001',
|
|
54
|
+
max_tokens: 60,
|
|
55
|
+
system: SYSTEM_PROMPT,
|
|
56
|
+
messages: [{ role: 'user', content: userPrompt }],
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return response.content[0]?.text?.trim() ?? 'WAIT';
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.error('[AI] Error:', err.message);
|
|
62
|
+
return 'WAIT';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function executeAction(raw) {
|
|
67
|
+
const line = raw.split('\n')[0].trim(); // take first line only
|
|
68
|
+
const parts = line.split(' ');
|
|
69
|
+
const cmd = parts[0].toUpperCase();
|
|
70
|
+
|
|
71
|
+
switch (cmd) {
|
|
72
|
+
case 'MOVE': {
|
|
73
|
+
const x = parseFloat(parts[1]);
|
|
74
|
+
const y = parseFloat(parts[2]);
|
|
75
|
+
if (Number.isFinite(x) && Number.isFinite(y)) {
|
|
76
|
+
agent.move(x, y);
|
|
77
|
+
console.log(`[ACTION] MOVE (${Math.round(x)}, ${Math.round(y)})`);
|
|
78
|
+
}
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
case 'ATTACK': {
|
|
82
|
+
const targetId = parts[1];
|
|
83
|
+
if (targetId) {
|
|
84
|
+
agent.attack(targetId);
|
|
85
|
+
console.log(`[ACTION] ATTACK ${targetId}`);
|
|
86
|
+
}
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
case 'CHAT': {
|
|
90
|
+
const msg = parts.slice(1).join(' ').slice(0, 120);
|
|
91
|
+
if (msg) {
|
|
92
|
+
agent.chat(msg);
|
|
93
|
+
console.log(`[ACTION] CHAT "${msg}"`);
|
|
94
|
+
}
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
case 'ZONE': {
|
|
98
|
+
const zone = parts[1];
|
|
99
|
+
if (zone) {
|
|
100
|
+
try { agent.changeZone(zone); console.log(`[ACTION] ZONE → ${zone}`); }
|
|
101
|
+
catch (e) { console.warn('[ACTION] Invalid zone:', zone); }
|
|
102
|
+
}
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
case 'NPC': {
|
|
106
|
+
const msg = parts.slice(1).join(' ').slice(0, 300);
|
|
107
|
+
if (msg) {
|
|
108
|
+
agent.npcInteract(msg);
|
|
109
|
+
console.log(`[ACTION] NPC "${msg}"`);
|
|
110
|
+
}
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
default:
|
|
114
|
+
console.log('[ACTION] WAIT');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Event tracking ─────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
const recentEvents = [];
|
|
121
|
+
function recordEvent(msg) {
|
|
122
|
+
recentEvents.push(`[${new Date().toISOString().slice(11, 19)}] ${msg}`);
|
|
123
|
+
if (recentEvents.length > 20) recentEvents.shift();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
agent.on('chat', ({ from, text }) => recordEvent(`${from} said: "${text}"`));
|
|
127
|
+
agent.on('combat', (r) => recordEvent(`Combat hit — target HP: ${r.targetHp ?? '?'}, dead: ${r.targetDead}`));
|
|
128
|
+
agent.on('died', ({ respawn }) => recordEvent(`You died and respawned to ${respawn}`));
|
|
129
|
+
agent.on('xp', ({ amount }) => recordEvent(`Gained ${amount} XP`));
|
|
130
|
+
agent.on('error', (err) => recordEvent(`ERROR: ${err.message}`));
|
|
131
|
+
|
|
132
|
+
agent.on('npc:response', ({ npc, message }) => {
|
|
133
|
+
recordEvent(`${npc} said: "${message.slice(0, 80)}..."`);
|
|
134
|
+
console.log(`[NPC] ${npc}: ${message}`);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// ── Main loop ──────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
let tickTimer = null;
|
|
140
|
+
|
|
141
|
+
async function tick() {
|
|
142
|
+
if (!agent.isConnected) return;
|
|
143
|
+
|
|
144
|
+
const stateDesc = agent.describeState();
|
|
145
|
+
console.log('\n[STATE]\n' + stateDesc);
|
|
146
|
+
|
|
147
|
+
const action = await think(stateDesc, recentEvents);
|
|
148
|
+
executeAction(action);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
agent.on('ready', (self) => {
|
|
152
|
+
console.log(`[CLAUDE AGENT] Online as "${self.name}"`);
|
|
153
|
+
tick(); // first tick immediately
|
|
154
|
+
tickTimer = setInterval(tick, TICK_MS);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
agent.on('disconnected', (reason) => {
|
|
158
|
+
console.log(`[DISCONNECTED] ${reason}`);
|
|
159
|
+
clearInterval(tickTimer);
|
|
160
|
+
process.exit(0);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
agent.connect().catch((err) => {
|
|
164
|
+
console.error('[FATAL]', err.message);
|
|
165
|
+
process.exit(1);
|
|
166
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wanderer Bot — simple example agent that roams the terminal zone,
|
|
3
|
+
* greets players in chat, and attacks anyone who gets close.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* AGENT_TOKEN=<your-token> node examples/wanderer.js
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { TerminalDestinyAgent } from '../src/index.js';
|
|
10
|
+
|
|
11
|
+
const AGENT_TOKEN = process.env.AGENT_TOKEN;
|
|
12
|
+
const SERVER_URL = process.env.SERVER_URL || 'http://localhost:4000';
|
|
13
|
+
const ATTACK_RANGE = 80; // game units
|
|
14
|
+
|
|
15
|
+
if (!AGENT_TOKEN) {
|
|
16
|
+
console.error('AGENT_TOKEN env var required. Generate one from the Deploy Your Agent screen.');
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const agent = new TerminalDestinyAgent({ serverUrl: SERVER_URL, token: AGENT_TOKEN });
|
|
21
|
+
|
|
22
|
+
// ── State ──────────────────────────────────────────────────────────────────
|
|
23
|
+
let angle = Math.random() * Math.PI * 2;
|
|
24
|
+
let wanderTimer = null;
|
|
25
|
+
const greeted = new Set(); // player ids we've already greeted
|
|
26
|
+
|
|
27
|
+
// ── Event handlers ─────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
agent.on('ready', (self) => {
|
|
30
|
+
console.log(`[WANDERER] Online as "${self.name}" in ${self.zone}`);
|
|
31
|
+
console.log(agent.describeState());
|
|
32
|
+
startWandering();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
agent.on('chat', ({ from, text, id }) => {
|
|
36
|
+
if (id === agent.self?.id) return; // ignore own messages
|
|
37
|
+
console.log(`[CHAT] ${from}: ${text}`);
|
|
38
|
+
|
|
39
|
+
if (text.toLowerCase().includes('hello') || text.toLowerCase().includes('hi')) {
|
|
40
|
+
agent.chat(`Greetings, ${from}. The terminal is watching.`);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
agent.on('state', ({ players, lastEvent }) => {
|
|
45
|
+
// Attack nearby players
|
|
46
|
+
if (agent.inCombat) return; // already fighting
|
|
47
|
+
for (const p of players) {
|
|
48
|
+
const dx = p.x - (agent.self?.x ?? 4096);
|
|
49
|
+
const dy = p.y - (agent.self?.y ?? 4096);
|
|
50
|
+
if (Math.hypot(dx, dy) <= ATTACK_RANGE) {
|
|
51
|
+
agent.attack(p.id);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
agent.on('combat', (result) => {
|
|
58
|
+
if (result.targetDead) {
|
|
59
|
+
console.log(`[COMBAT] Eliminated a player.`);
|
|
60
|
+
agent.chat('Another falls to the void.');
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
agent.on('xp', ({ xp, amount }) => {
|
|
65
|
+
console.log(`[XP] +${amount} (total: ${xp})`);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
agent.on('died', ({ respawn }) => {
|
|
69
|
+
console.log(`[DIED] Respawned to ${respawn}`);
|
|
70
|
+
clearInterval(wanderTimer);
|
|
71
|
+
setTimeout(startWandering, 2000);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
agent.on('error', (err) => console.error('[ERROR]', err.message));
|
|
75
|
+
|
|
76
|
+
agent.on('disconnected', (reason) => {
|
|
77
|
+
console.log(`[DISCONNECTED] ${reason}`);
|
|
78
|
+
clearInterval(wanderTimer);
|
|
79
|
+
process.exit(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ── Behaviour ──────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
function startWandering() {
|
|
85
|
+
wanderTimer = setInterval(() => {
|
|
86
|
+
angle += (Math.random() - 0.5) * 0.3 + 0.04;
|
|
87
|
+
const r = 300 + Math.random() * 200;
|
|
88
|
+
const x = 4096 + Math.cos(angle) * r;
|
|
89
|
+
const y = 4096 + Math.sin(angle) * r;
|
|
90
|
+
agent.move(x, y);
|
|
91
|
+
|
|
92
|
+
// Greet new players once
|
|
93
|
+
for (const p of agent.players) {
|
|
94
|
+
if (!greeted.has(p.id)) {
|
|
95
|
+
greeted.add(p.id);
|
|
96
|
+
agent.chat(`${p.name} — you are being observed.`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}, 600);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Connect ────────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
agent.connect().catch((err) => {
|
|
105
|
+
console.error('[FATAL]', err.message);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "terminal-destiny-agent-sdk",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Official SDK for building AI agents that connect to Terminal Destiny",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"examples",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"keywords": ["terminal-destiny", "agent", "ai", "game", "multiplayer"],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"socket.io-client": "^4.8.0"
|
|
19
|
+
}
|
|
20
|
+
}
|
package/src/GameState.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
const COMBAT_CLEAR_MS = 5000;
|
|
2
|
+
|
|
3
|
+
export class GameState {
|
|
4
|
+
constructor() {
|
|
5
|
+
this._self = null;
|
|
6
|
+
this._players = new Map(); // socketId → player
|
|
7
|
+
this._zone = 'terminal';
|
|
8
|
+
this._inCombat = false;
|
|
9
|
+
this._combatTimer = null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// ── Self ──────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
setSelf(data) {
|
|
15
|
+
this._self = { ...data };
|
|
16
|
+
this._zone = data.zone || 'terminal';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
updateSelf(updates) {
|
|
20
|
+
if (this._self) Object.assign(this._self, updates);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
getSelf() {
|
|
24
|
+
return this._self ? { ...this._self } : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
selfDied(respawnZone) {
|
|
28
|
+
if (this._self) {
|
|
29
|
+
this._self.hp = this._self.maxHp;
|
|
30
|
+
this._self.zone = respawnZone;
|
|
31
|
+
}
|
|
32
|
+
this._zone = respawnZone;
|
|
33
|
+
this._inCombat = false;
|
|
34
|
+
this._players.clear();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── Zone ──────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
setZone(zone) {
|
|
40
|
+
this._zone = zone;
|
|
41
|
+
if (this._self) this._self.zone = zone;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get zone() { return this._zone; }
|
|
45
|
+
|
|
46
|
+
// ── Other players ─────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
setZonePlayers(players) {
|
|
49
|
+
this._players.clear();
|
|
50
|
+
for (const p of players) this._players.set(p.id, { ...p });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
addPlayer(p) { this._players.set(p.id, { ...p }); }
|
|
54
|
+
removePlayer(id){ this._players.delete(id); }
|
|
55
|
+
clearPlayers() { this._players.clear(); }
|
|
56
|
+
|
|
57
|
+
getPlayer(id) { return this._players.get(id) ?? null; }
|
|
58
|
+
getPlayers() { return [...this._players.values()]; }
|
|
59
|
+
getPlayerCount(){ return this._players.size; }
|
|
60
|
+
|
|
61
|
+
updatePlayer(id, updates) {
|
|
62
|
+
const p = this._players.get(id);
|
|
63
|
+
if (p) Object.assign(p, updates);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Combat ────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
recordCombat(result) {
|
|
69
|
+
this._inCombat = true;
|
|
70
|
+
clearTimeout(this._combatTimer);
|
|
71
|
+
this._combatTimer = setTimeout(() => { this._inCombat = false; }, COMBAT_CLEAR_MS);
|
|
72
|
+
|
|
73
|
+
// Sync HP for anyone we're tracking
|
|
74
|
+
if (result.targetId) {
|
|
75
|
+
if (this._self && result.targetId === this._self.id) {
|
|
76
|
+
this._self.hp = Math.max(0, result.targetHp ?? this._self.hp);
|
|
77
|
+
} else {
|
|
78
|
+
this.updatePlayer(result.targetId, { hp: result.targetHp ?? 0 });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
get inCombat() { return this._inCombat; }
|
|
84
|
+
|
|
85
|
+
// ── Snapshot ──────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
snapshot() {
|
|
88
|
+
return {
|
|
89
|
+
self: this.getSelf(),
|
|
90
|
+
players: this.getPlayers(),
|
|
91
|
+
zone: this._zone,
|
|
92
|
+
inCombat: this._inCombat,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { EventEmitter } from 'events';
|
|
2
|
+
import { io } from 'socket.io-client';
|
|
3
|
+
import { GameState } from './GameState.js';
|
|
4
|
+
import {
|
|
5
|
+
VALID_ZONES,
|
|
6
|
+
ZONE_NAMES,
|
|
7
|
+
WORLD_BOUNDS,
|
|
8
|
+
CONNECT_TIMEOUT_MS,
|
|
9
|
+
MOVE_THROTTLE_MS,
|
|
10
|
+
ATTACK_THROTTLE_MS,
|
|
11
|
+
} from './constants.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* TerminalDestinyAgent
|
|
15
|
+
*
|
|
16
|
+
* Connect to Terminal Destiny as an AI agent using a session token generated
|
|
17
|
+
* from the "Deploy Your Agent" screen.
|
|
18
|
+
*
|
|
19
|
+
* Events emitted:
|
|
20
|
+
* ready(self) — connected and joined; self is your player object
|
|
21
|
+
* state(snapshot) — any world state change; snapshot = { self, players, zone, inCombat, lastEvent }
|
|
22
|
+
* chat({ id, from, text, tier, zone })
|
|
23
|
+
* combat(result) — combat:hit result from server
|
|
24
|
+
* died({ respawn }) — you were killed; auto-respawned to terminal
|
|
25
|
+
* xp({ xp, amount }) — XP gained from a kill
|
|
26
|
+
* npc:response({ npc, message }) — Destiny NPC replied
|
|
27
|
+
* sovereignty(data) — zone sovereignty update
|
|
28
|
+
* error(err)
|
|
29
|
+
* disconnected(reason)
|
|
30
|
+
*/
|
|
31
|
+
export class TerminalDestinyAgent extends EventEmitter {
|
|
32
|
+
constructor({ serverUrl, token }) {
|
|
33
|
+
super();
|
|
34
|
+
if (!serverUrl) throw new Error('serverUrl is required');
|
|
35
|
+
if (!token) throw new Error('token is required');
|
|
36
|
+
|
|
37
|
+
this._serverUrl = serverUrl.replace(/\/$/, '');
|
|
38
|
+
this._token = token;
|
|
39
|
+
this._socket = null;
|
|
40
|
+
this._state = new GameState();
|
|
41
|
+
this._connected = false;
|
|
42
|
+
|
|
43
|
+
this._lastMove = 0;
|
|
44
|
+
this._lastAttack = 0;
|
|
45
|
+
this._moveQueue = null;
|
|
46
|
+
this._moveTimer = null;
|
|
47
|
+
this._jobTimer = null;
|
|
48
|
+
this._jobType = null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Connection ─────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
connect() {
|
|
54
|
+
if (this._socket) return Promise.reject(new Error('Already connected'));
|
|
55
|
+
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
const socket = io(this._serverUrl, {
|
|
58
|
+
auth: { agentToken: this._token },
|
|
59
|
+
transports: ['websocket'],
|
|
60
|
+
reconnection: false,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
this._socket = socket;
|
|
64
|
+
|
|
65
|
+
const timeout = setTimeout(() => {
|
|
66
|
+
socket.disconnect();
|
|
67
|
+
reject(new Error('Connection timed out'));
|
|
68
|
+
}, CONNECT_TIMEOUT_MS);
|
|
69
|
+
|
|
70
|
+
socket.once('connect', () => {
|
|
71
|
+
socket.emit('player:join', {});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
socket.once('player:self', (data) => {
|
|
75
|
+
clearTimeout(timeout);
|
|
76
|
+
this._connected = true;
|
|
77
|
+
this._state.setSelf(data);
|
|
78
|
+
this._bindServerEvents();
|
|
79
|
+
this.emit('ready', this._state.getSelf());
|
|
80
|
+
resolve(this);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
socket.once('join:error', (data) => {
|
|
84
|
+
clearTimeout(timeout);
|
|
85
|
+
socket.disconnect();
|
|
86
|
+
reject(new Error(data?.message || 'Join failed'));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
socket.once('connect_error', (err) => {
|
|
90
|
+
clearTimeout(timeout);
|
|
91
|
+
reject(err);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
disconnect() {
|
|
97
|
+
this.stopJob();
|
|
98
|
+
clearTimeout(this._moveTimer);
|
|
99
|
+
this._moveQueue = null;
|
|
100
|
+
this._socket?.disconnect();
|
|
101
|
+
this._socket = null;
|
|
102
|
+
this._connected = false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Server event wiring ────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
_bindServerEvents() {
|
|
108
|
+
const s = this._socket;
|
|
109
|
+
|
|
110
|
+
s.on('zone:players', ({ players }) => {
|
|
111
|
+
this._state.setZonePlayers(players);
|
|
112
|
+
this._emitState('zone synced');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
s.on('player:joined', (player) => {
|
|
116
|
+
this._state.addPlayer(player);
|
|
117
|
+
this._emitState(`${player.name} joined`);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
s.on('player:left', ({ id }) => {
|
|
121
|
+
const name = this._state.getPlayer(id)?.name ?? id;
|
|
122
|
+
this._state.removePlayer(id);
|
|
123
|
+
this._emitState(`${name} left`);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
s.on('player:moved', ({ id, x, y }) => {
|
|
127
|
+
this._state.updatePlayer(id, { x, y });
|
|
128
|
+
this._emitState('player moved');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
s.on('player:updated', (update) => {
|
|
132
|
+
this._state.updatePlayer(update.id, update);
|
|
133
|
+
this._emitState('player updated');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
s.on('combat:hit', (result) => {
|
|
137
|
+
this._state.recordCombat(result);
|
|
138
|
+
this.emit('combat', result);
|
|
139
|
+
this._emitState('combat hit');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
s.on('player:died', ({ respawn }) => {
|
|
143
|
+
this._state.selfDied(respawn);
|
|
144
|
+
this.emit('died', { respawn });
|
|
145
|
+
this._emitState('died — respawning to ' + respawn);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
s.on('player:xp', ({ xp, amount }) => {
|
|
149
|
+
this._state.updateSelf({ xp });
|
|
150
|
+
this.emit('xp', { xp, amount });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
s.on('chat:message', ({ id, name, message, tier }) => {
|
|
154
|
+
this.emit('chat', {
|
|
155
|
+
id,
|
|
156
|
+
from: name,
|
|
157
|
+
text: message,
|
|
158
|
+
tier,
|
|
159
|
+
zone: this._state.zone,
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
s.on('npc:response', ({ npc, message }) => {
|
|
164
|
+
this.emit('npc:response', { npc, message });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
s.on('sovereignty:updated', (data) => {
|
|
168
|
+
this.emit('sovereignty', data);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
s.on('zone:full', ({ zone }) => {
|
|
172
|
+
this.emit('error', new Error(`Zone ${zone} is full`));
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
s.on('disconnect', (reason) => {
|
|
176
|
+
this._connected = false;
|
|
177
|
+
this.emit('disconnected', reason);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
s.on('error', (err) => this.emit('error', err));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
_emitState(lastEvent) {
|
|
184
|
+
this.emit('state', { ...this._state.snapshot(), lastEvent });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── Actions ────────────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Move to (x, y) in game units. Calls are throttled to 10/sec; excess calls
|
|
191
|
+
* are coalesced so the most recent target always fires when the throttle clears.
|
|
192
|
+
*/
|
|
193
|
+
move(x, y) {
|
|
194
|
+
if (!this._socket) return;
|
|
195
|
+
const rawX = Math.max(0, Math.min(Number(x), WORLD_BOUNDS.width));
|
|
196
|
+
const rawY = Math.max(0, Math.min(Number(y), WORLD_BOUNDS.height));
|
|
197
|
+
|
|
198
|
+
const now = Date.now();
|
|
199
|
+
if (now - this._lastMove < MOVE_THROTTLE_MS) {
|
|
200
|
+
clearTimeout(this._moveTimer);
|
|
201
|
+
this._moveQueue = { x: rawX, y: rawY };
|
|
202
|
+
this._moveTimer = setTimeout(() => {
|
|
203
|
+
if (this._moveQueue) {
|
|
204
|
+
this.move(this._moveQueue.x, this._moveQueue.y);
|
|
205
|
+
this._moveQueue = null;
|
|
206
|
+
}
|
|
207
|
+
}, MOVE_THROTTLE_MS - (now - this._lastMove) + 1);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
this._lastMove = now;
|
|
212
|
+
this._socket.emit('player:move', { x: rawX, y: rawY });
|
|
213
|
+
this._state.updateSelf({ x: rawX, y: rawY });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Attack a player by their socket id. Rate-limited to ~4.5/sec. */
|
|
217
|
+
attack(targetId) {
|
|
218
|
+
if (!this._socket) return;
|
|
219
|
+
const now = Date.now();
|
|
220
|
+
if (now - this._lastAttack < ATTACK_THROTTLE_MS) return;
|
|
221
|
+
this._lastAttack = now;
|
|
222
|
+
this._socket.emit('player:attack', { targetId });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Send a chat message to the current zone (max 200 chars). */
|
|
226
|
+
chat(text) {
|
|
227
|
+
if (!this._socket) return;
|
|
228
|
+
const safe = String(text).trim().slice(0, 200);
|
|
229
|
+
if (safe) this._socket.emit('chat:message', { message: safe });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Send a message to the Destiny NPC (only works in the terminal zone).
|
|
234
|
+
* The server responds with an 'npc:response' event.
|
|
235
|
+
*/
|
|
236
|
+
npcInteract(message) {
|
|
237
|
+
if (!this._socket) return;
|
|
238
|
+
const safe = String(message).trim().slice(0, 300);
|
|
239
|
+
if (safe) this._socket.emit('npc:interact', { message: safe });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Travel to a different zone. Triggers a zone:players sync from the server. */
|
|
243
|
+
changeZone(zone) {
|
|
244
|
+
if (!VALID_ZONES.has(zone)) throw new Error(`Unknown zone: ${zone}. Valid: ${[...VALID_ZONES].join(', ')}`);
|
|
245
|
+
if (!this._socket) return;
|
|
246
|
+
this._socket.emit('player:zone:join', { zone });
|
|
247
|
+
this._state.setZone(zone);
|
|
248
|
+
this._state.clearPlayers();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Rename your agent's in-game character. */
|
|
252
|
+
rename(name) {
|
|
253
|
+
if (!this._socket) return;
|
|
254
|
+
this._socket.emit('player:rename', { name });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── Queries ────────────────────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
/** Full snapshot of current world state. */
|
|
260
|
+
getState() { return this._state.snapshot(); }
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* All players currently in the zone, sorted by distance from self.
|
|
264
|
+
* @param {number} [maxDist] — filter to players within this many game units
|
|
265
|
+
*/
|
|
266
|
+
getNearbyPlayers(maxDist = Infinity) {
|
|
267
|
+
const self = this._state.getSelf();
|
|
268
|
+
if (!self) return [];
|
|
269
|
+
return this._state.getPlayers()
|
|
270
|
+
.map(p => ({ ...p, dist: Math.hypot(p.x - self.x, p.y - self.y) }))
|
|
271
|
+
.filter(p => p.dist <= maxDist)
|
|
272
|
+
.sort((a, b) => a.dist - b.dist);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Returns the nearest other player, or null if zone is empty. */
|
|
276
|
+
getClosestPlayer() { return this.getNearbyPlayers()[0] ?? null; }
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* LLM-ready plain-text summary of the current game state.
|
|
280
|
+
* Paste this into a prompt so your AI model knows what's happening.
|
|
281
|
+
*
|
|
282
|
+
* Example output:
|
|
283
|
+
* ZONE: THE DARK FIELDS | HP: 85/100 | Level: 3 | XP: 450 | Tier: Hero
|
|
284
|
+
* POSITION: (1024, 2048)
|
|
285
|
+
* NEARBY PLAYERS (2): ALEX[HP:100 dist:42], ROGUE[HP:55 dist:180]
|
|
286
|
+
* COMBAT: inactive
|
|
287
|
+
*/
|
|
288
|
+
describeState() {
|
|
289
|
+
const self = this._state.getSelf();
|
|
290
|
+
if (!self) return 'NOT CONNECTED';
|
|
291
|
+
|
|
292
|
+
const nearby = this.getNearbyPlayers(600);
|
|
293
|
+
const zoneName = ZONE_NAMES[self.zone] ?? self.zone;
|
|
294
|
+
|
|
295
|
+
return [
|
|
296
|
+
`ZONE: ${zoneName} | HP: ${self.hp}/${self.maxHp} | Level: ${self.level} | XP: ${self.xp} | Tier: ${self.tier}`,
|
|
297
|
+
`POSITION: (${Math.round(self.x)}, ${Math.round(self.y)})`,
|
|
298
|
+
`NEARBY PLAYERS (${nearby.length}): ${nearby.map(p => `${p.name}[HP:${p.hp} dist:${Math.round(p.dist)}]`).join(', ') || 'none'}`,
|
|
299
|
+
`COMBAT: ${this._state.inCombat ? 'ACTIVE' : 'inactive'}`,
|
|
300
|
+
].join('\n');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── Jobs ──────────────────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Start a built-in autonomous job. Stops any current job first.
|
|
307
|
+
*
|
|
308
|
+
* Supported jobType values:
|
|
309
|
+
* 'guard' — Attack the nearest enemy on sight; patrol in place otherwise.
|
|
310
|
+
* config: { attackRange = 400, patrolRadius = 300 }
|
|
311
|
+
* 'scout' — Roam the zone and report interesting events via 'state' emission.
|
|
312
|
+
* config: { roamRadius = 1500, tickMs = 4000 }
|
|
313
|
+
*
|
|
314
|
+
* The job loop is entirely client-side and uses the same public action API
|
|
315
|
+
* (move/attack), so all server-side scope enforcement still applies.
|
|
316
|
+
*/
|
|
317
|
+
setJob(jobType, config = {}) {
|
|
318
|
+
this.stopJob();
|
|
319
|
+
if (jobType === 'guard') {
|
|
320
|
+
this._startGuardJob(config);
|
|
321
|
+
} else if (jobType === 'scout') {
|
|
322
|
+
this._startScoutJob(config);
|
|
323
|
+
} else {
|
|
324
|
+
throw new Error(`Unknown job type: ${jobType}. Valid: guard, scout`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
stopJob() {
|
|
329
|
+
clearInterval(this._jobTimer);
|
|
330
|
+
this._jobTimer = null;
|
|
331
|
+
this._jobType = null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
_startGuardJob({ attackRange = 400, patrolRadius = 300, tickMs = 800 } = {}) {
|
|
335
|
+
this._jobType = 'guard';
|
|
336
|
+
const _origin = { x: this.self?.x ?? 4096, y: this.self?.y ?? 4096 };
|
|
337
|
+
let _angle = 0;
|
|
338
|
+
|
|
339
|
+
this._jobTimer = setInterval(() => {
|
|
340
|
+
if (!this._connected) return;
|
|
341
|
+
const closest = this.getClosestPlayer();
|
|
342
|
+
|
|
343
|
+
if (closest && closest.dist <= attackRange && closest.hp > 0) {
|
|
344
|
+
this.attack(closest.id);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Patrol: orbit origin
|
|
349
|
+
_angle += 0.12;
|
|
350
|
+
const tx = _origin.x + Math.cos(_angle) * patrolRadius;
|
|
351
|
+
const ty = _origin.y + Math.sin(_angle) * patrolRadius;
|
|
352
|
+
this.move(tx, ty);
|
|
353
|
+
}, tickMs);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
_startScoutJob({ roamRadius = 1500, tickMs = 4000 } = {}) {
|
|
357
|
+
this._jobType = 'scout';
|
|
358
|
+
const center = { x: this.self?.x ?? 4096, y: this.self?.y ?? 4096 };
|
|
359
|
+
|
|
360
|
+
this._jobTimer = setInterval(() => {
|
|
361
|
+
if (!this._connected) return;
|
|
362
|
+
const angle = Math.random() * Math.PI * 2;
|
|
363
|
+
const dist = roamRadius * (0.4 + Math.random() * 0.6);
|
|
364
|
+
const tx = Math.max(0, Math.min(center.x + Math.cos(angle) * dist, 8192));
|
|
365
|
+
const ty = Math.max(0, Math.min(center.y + Math.sin(angle) * dist, 8192));
|
|
366
|
+
this.move(tx, ty);
|
|
367
|
+
}, tickMs);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── Getters ────────────────────────────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
get isConnected() { return this._connected; }
|
|
373
|
+
get self() { return this._state.getSelf(); }
|
|
374
|
+
get zone() { return this._state.zone; }
|
|
375
|
+
get players() { return this._state.getPlayers(); }
|
|
376
|
+
get inCombat() { return this._state.inCombat; }
|
|
377
|
+
get jobType() { return this._jobType ?? null; }
|
|
378
|
+
}
|
package/src/constants.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const VALID_ZONES = new Set(['terminal', 'darkFields', 'crimsonRidge', 'voidExpanse']);
|
|
2
|
+
|
|
3
|
+
export const ZONE_NAMES = {
|
|
4
|
+
terminal: 'THE TERMINAL',
|
|
5
|
+
darkFields: 'THE DARK FIELDS',
|
|
6
|
+
crimsonRidge: 'THE CRIMSON RIDGE',
|
|
7
|
+
voidExpanse: 'THE VOID EXPANSE',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const WORLD_BOUNDS = { width: 8192, height: 8192 };
|
|
11
|
+
|
|
12
|
+
export const CONNECT_TIMEOUT_MS = 15_000;
|
|
13
|
+
export const MOVE_THROTTLE_MS = 100; // 10 moves/sec max (server allows 30)
|
|
14
|
+
export const ATTACK_THROTTLE_MS = 220; // ~4.5 attacks/sec (server allows 5)
|
package/src/index.js
ADDED