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.
@@ -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
+ }
@@ -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
+ }
@@ -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
@@ -0,0 +1,3 @@
1
+ export { TerminalDestinyAgent } from './TerminalDestinyAgent.js';
2
+ export { GameState } from './GameState.js';
3
+ export { VALID_ZONES, ZONE_NAMES, WORLD_BOUNDS } from './constants.js';