@watchtower-sdk/core 0.2.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/README.md +336 -0
- package/dist/index.d.mts +296 -0
- package/dist/index.d.ts +296 -0
- package/dist/index.js +467 -0
- package/dist/index.mjs +441 -0
- package/package.json +49 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
Room: () => Room,
|
|
24
|
+
Watchtower: () => Watchtower,
|
|
25
|
+
default: () => index_default
|
|
26
|
+
});
|
|
27
|
+
module.exports = __toCommonJS(index_exports);
|
|
28
|
+
var PlayerStateManager = class {
|
|
29
|
+
constructor(room, syncRateMs = 50) {
|
|
30
|
+
this._state = {};
|
|
31
|
+
this.syncInterval = null;
|
|
32
|
+
this.dirty = false;
|
|
33
|
+
this.room = room;
|
|
34
|
+
this.syncRateMs = syncRateMs;
|
|
35
|
+
}
|
|
36
|
+
/** Set player state (merged with existing) */
|
|
37
|
+
set(state) {
|
|
38
|
+
this._state = { ...this._state, ...state };
|
|
39
|
+
this.dirty = true;
|
|
40
|
+
}
|
|
41
|
+
/** Replace entire player state */
|
|
42
|
+
replace(state) {
|
|
43
|
+
this._state = state;
|
|
44
|
+
this.dirty = true;
|
|
45
|
+
}
|
|
46
|
+
/** Get current player state */
|
|
47
|
+
get() {
|
|
48
|
+
return { ...this._state };
|
|
49
|
+
}
|
|
50
|
+
/** Clear player state */
|
|
51
|
+
clear() {
|
|
52
|
+
this._state = {};
|
|
53
|
+
this.dirty = true;
|
|
54
|
+
}
|
|
55
|
+
/** Start automatic sync */
|
|
56
|
+
startSync() {
|
|
57
|
+
if (this.syncInterval) return;
|
|
58
|
+
this.syncInterval = setInterval(() => {
|
|
59
|
+
if (this.dirty) {
|
|
60
|
+
this.room["send"]({ type: "player_state", state: this._state });
|
|
61
|
+
this.dirty = false;
|
|
62
|
+
}
|
|
63
|
+
}, this.syncRateMs);
|
|
64
|
+
}
|
|
65
|
+
/** Stop automatic sync */
|
|
66
|
+
stopSync() {
|
|
67
|
+
if (this.syncInterval) {
|
|
68
|
+
clearInterval(this.syncInterval);
|
|
69
|
+
this.syncInterval = null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Force immediate sync */
|
|
73
|
+
sync() {
|
|
74
|
+
this.room["send"]({ type: "player_state", state: this._state });
|
|
75
|
+
this.dirty = false;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
var GameStateManager = class {
|
|
79
|
+
constructor(room) {
|
|
80
|
+
this._state = {};
|
|
81
|
+
this.room = room;
|
|
82
|
+
}
|
|
83
|
+
/** Set game state (host only, merged with existing) */
|
|
84
|
+
set(state) {
|
|
85
|
+
if (!this.room.isHost) {
|
|
86
|
+
console.warn("Only the host can set game state");
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
this._state = { ...this._state, ...state };
|
|
90
|
+
this.room["send"]({ type: "game_state", state: this._state });
|
|
91
|
+
}
|
|
92
|
+
/** Replace entire game state (host only) */
|
|
93
|
+
replace(state) {
|
|
94
|
+
if (!this.room.isHost) {
|
|
95
|
+
console.warn("Only the host can set game state");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
this._state = state;
|
|
99
|
+
this.room["send"]({ type: "game_state", state: this._state });
|
|
100
|
+
}
|
|
101
|
+
/** Get current game state */
|
|
102
|
+
get() {
|
|
103
|
+
return { ...this._state };
|
|
104
|
+
}
|
|
105
|
+
/** Update internal state (called on sync from server) */
|
|
106
|
+
_update(state) {
|
|
107
|
+
this._state = state;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
var Room = class {
|
|
111
|
+
constructor(code, config) {
|
|
112
|
+
this.ws = null;
|
|
113
|
+
this.listeners = /* @__PURE__ */ new Map();
|
|
114
|
+
/** All players' current states */
|
|
115
|
+
this._players = {};
|
|
116
|
+
/** Current host ID */
|
|
117
|
+
this._hostId = "";
|
|
118
|
+
/** Room info from initial connection */
|
|
119
|
+
this._roomInfo = null;
|
|
120
|
+
this.code = code;
|
|
121
|
+
this.config = config;
|
|
122
|
+
this.player = new PlayerStateManager(this);
|
|
123
|
+
this.state = new GameStateManager(this);
|
|
124
|
+
}
|
|
125
|
+
/** Get the current host ID */
|
|
126
|
+
get hostId() {
|
|
127
|
+
return this._hostId;
|
|
128
|
+
}
|
|
129
|
+
/** Check if current player is the host */
|
|
130
|
+
get isHost() {
|
|
131
|
+
return this._hostId === this.config.playerId;
|
|
132
|
+
}
|
|
133
|
+
/** Get current player's ID */
|
|
134
|
+
get playerId() {
|
|
135
|
+
return this.config.playerId;
|
|
136
|
+
}
|
|
137
|
+
/** Get all players' states */
|
|
138
|
+
get players() {
|
|
139
|
+
return { ...this._players };
|
|
140
|
+
}
|
|
141
|
+
/** Get player count */
|
|
142
|
+
get playerCount() {
|
|
143
|
+
return Object.keys(this._players).length;
|
|
144
|
+
}
|
|
145
|
+
/** Connect to the room via WebSocket */
|
|
146
|
+
async connect() {
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
const wsUrl = this.config.apiUrl.replace("https://", "wss://").replace("http://", "ws://");
|
|
149
|
+
const params = new URLSearchParams({
|
|
150
|
+
playerId: this.config.playerId,
|
|
151
|
+
...this.config.apiKey ? { apiKey: this.config.apiKey } : {}
|
|
152
|
+
});
|
|
153
|
+
const url = `${wsUrl}/v1/rooms/${this.code}/ws?${params}`;
|
|
154
|
+
this.ws = new WebSocket(url);
|
|
155
|
+
this.ws.onopen = () => {
|
|
156
|
+
this.player.startSync();
|
|
157
|
+
resolve();
|
|
158
|
+
};
|
|
159
|
+
this.ws.onerror = () => {
|
|
160
|
+
const error = new Error("WebSocket connection failed");
|
|
161
|
+
this.emit("error", error);
|
|
162
|
+
reject(error);
|
|
163
|
+
};
|
|
164
|
+
this.ws.onclose = () => {
|
|
165
|
+
this.player.stopSync();
|
|
166
|
+
this.emit("disconnected");
|
|
167
|
+
};
|
|
168
|
+
this.ws.onmessage = (event) => {
|
|
169
|
+
try {
|
|
170
|
+
const data = JSON.parse(event.data);
|
|
171
|
+
this.handleMessage(data);
|
|
172
|
+
} catch (e) {
|
|
173
|
+
console.error("Failed to parse WebSocket message:", e);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
handleMessage(data) {
|
|
179
|
+
switch (data.type) {
|
|
180
|
+
case "connected":
|
|
181
|
+
this._hostId = data.room.hostId;
|
|
182
|
+
this._roomInfo = data.room;
|
|
183
|
+
if (data.playerStates) {
|
|
184
|
+
this._players = data.playerStates;
|
|
185
|
+
}
|
|
186
|
+
if (data.gameState) {
|
|
187
|
+
this.state._update(data.gameState);
|
|
188
|
+
}
|
|
189
|
+
this.emit("connected", {
|
|
190
|
+
playerId: data.playerId,
|
|
191
|
+
room: data.room
|
|
192
|
+
});
|
|
193
|
+
break;
|
|
194
|
+
case "player_joined":
|
|
195
|
+
this.emit("playerJoined", data.playerId, data.playerCount);
|
|
196
|
+
break;
|
|
197
|
+
case "player_left":
|
|
198
|
+
delete this._players[data.playerId];
|
|
199
|
+
this.emit("playerLeft", data.playerId, data.playerCount);
|
|
200
|
+
this.emit("players", this._players);
|
|
201
|
+
break;
|
|
202
|
+
case "players_sync":
|
|
203
|
+
this._players = data.players;
|
|
204
|
+
this.emit("players", this._players);
|
|
205
|
+
break;
|
|
206
|
+
case "player_state_update":
|
|
207
|
+
this._players[data.playerId] = data.state;
|
|
208
|
+
this.emit("players", this._players);
|
|
209
|
+
break;
|
|
210
|
+
case "game_state_sync":
|
|
211
|
+
this.state._update(data.state);
|
|
212
|
+
this.emit("state", data.state);
|
|
213
|
+
break;
|
|
214
|
+
case "host_changed":
|
|
215
|
+
this._hostId = data.hostId;
|
|
216
|
+
this.emit("hostChanged", data.hostId);
|
|
217
|
+
break;
|
|
218
|
+
case "message":
|
|
219
|
+
this.emit("message", data.from, data.data);
|
|
220
|
+
break;
|
|
221
|
+
case "pong":
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/** Subscribe to room events */
|
|
226
|
+
on(event, callback) {
|
|
227
|
+
if (!this.listeners.has(event)) {
|
|
228
|
+
this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
229
|
+
}
|
|
230
|
+
this.listeners.get(event).add(callback);
|
|
231
|
+
}
|
|
232
|
+
/** Unsubscribe from room events */
|
|
233
|
+
off(event, callback) {
|
|
234
|
+
this.listeners.get(event)?.delete(callback);
|
|
235
|
+
}
|
|
236
|
+
emit(event, ...args) {
|
|
237
|
+
this.listeners.get(event)?.forEach((callback) => {
|
|
238
|
+
try {
|
|
239
|
+
callback(...args);
|
|
240
|
+
} catch (e) {
|
|
241
|
+
console.error(`Error in ${event} handler:`, e);
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
/** Broadcast data to all players in the room (for one-off events) */
|
|
246
|
+
broadcast(data, excludeSelf = true) {
|
|
247
|
+
this.send({ type: "broadcast", data, excludeSelf });
|
|
248
|
+
}
|
|
249
|
+
/** Send data to a specific player */
|
|
250
|
+
sendTo(playerId, data) {
|
|
251
|
+
this.send({ type: "send", to: playerId, data });
|
|
252
|
+
}
|
|
253
|
+
/** Send a ping to measure latency */
|
|
254
|
+
ping() {
|
|
255
|
+
this.send({ type: "ping" });
|
|
256
|
+
}
|
|
257
|
+
/** Request host transfer (host only) */
|
|
258
|
+
transferHost(newHostId) {
|
|
259
|
+
if (!this.isHost) {
|
|
260
|
+
console.warn("Only the host can transfer host");
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
this.send({ type: "transfer_host", newHostId });
|
|
264
|
+
}
|
|
265
|
+
send(data) {
|
|
266
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
267
|
+
this.ws.send(JSON.stringify(data));
|
|
268
|
+
} else {
|
|
269
|
+
console.warn("WebSocket not connected");
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/** Disconnect from the room */
|
|
273
|
+
disconnect() {
|
|
274
|
+
this.player.stopSync();
|
|
275
|
+
if (this.ws) {
|
|
276
|
+
this.ws.close();
|
|
277
|
+
this.ws = null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/** Check if connected */
|
|
281
|
+
get connected() {
|
|
282
|
+
return this.ws?.readyState === WebSocket.OPEN;
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
var Watchtower = class {
|
|
286
|
+
constructor(config) {
|
|
287
|
+
Object.defineProperty(this, "config", {
|
|
288
|
+
value: {
|
|
289
|
+
gameId: config.gameId,
|
|
290
|
+
playerId: config.playerId || this.generatePlayerId(),
|
|
291
|
+
apiUrl: config.apiUrl || "https://watchtower-api.watchtower-host.workers.dev",
|
|
292
|
+
apiKey: config.apiKey || ""
|
|
293
|
+
},
|
|
294
|
+
writable: false,
|
|
295
|
+
enumerable: false,
|
|
296
|
+
configurable: false
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
generatePlayerId() {
|
|
300
|
+
try {
|
|
301
|
+
if (typeof localStorage !== "undefined") {
|
|
302
|
+
const stored = localStorage.getItem("watchtower_player_id");
|
|
303
|
+
if (stored) return stored;
|
|
304
|
+
const id = "player_" + Math.random().toString(36).substring(2, 11);
|
|
305
|
+
localStorage.setItem("watchtower_player_id", id);
|
|
306
|
+
return id;
|
|
307
|
+
}
|
|
308
|
+
} catch {
|
|
309
|
+
}
|
|
310
|
+
return "player_" + Math.random().toString(36).substring(2, 11);
|
|
311
|
+
}
|
|
312
|
+
/** Get the current player ID */
|
|
313
|
+
get playerId() {
|
|
314
|
+
return this.config.playerId;
|
|
315
|
+
}
|
|
316
|
+
/** Get the game ID */
|
|
317
|
+
get gameId() {
|
|
318
|
+
return this.config.gameId;
|
|
319
|
+
}
|
|
320
|
+
// ============ HTTP HELPERS ============
|
|
321
|
+
async fetch(method, path, body) {
|
|
322
|
+
const headers = {
|
|
323
|
+
"Content-Type": "application/json",
|
|
324
|
+
"X-Player-ID": this.config.playerId,
|
|
325
|
+
"X-Game-ID": this.config.gameId
|
|
326
|
+
};
|
|
327
|
+
if (this.config.apiKey) {
|
|
328
|
+
headers["Authorization"] = `Bearer ${this.config.apiKey}`;
|
|
329
|
+
}
|
|
330
|
+
const response = await fetch(`${this.config.apiUrl}${path}`, {
|
|
331
|
+
method,
|
|
332
|
+
headers,
|
|
333
|
+
// Use !== undefined to handle falsy values like null, 0, false, ''
|
|
334
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
335
|
+
});
|
|
336
|
+
const data = await response.json();
|
|
337
|
+
if (!response.ok) {
|
|
338
|
+
throw new Error(data.error || `HTTP ${response.status}`);
|
|
339
|
+
}
|
|
340
|
+
return data;
|
|
341
|
+
}
|
|
342
|
+
// ============ SAVES API ============
|
|
343
|
+
/**
|
|
344
|
+
* Save data to the cloud
|
|
345
|
+
* @param key - Save slot name (e.g., "progress", "settings")
|
|
346
|
+
* @param data - Any JSON-serializable data
|
|
347
|
+
*/
|
|
348
|
+
async save(key, data) {
|
|
349
|
+
await this.fetch("POST", `/v1/saves/${encodeURIComponent(key)}`, data);
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Load data from the cloud
|
|
353
|
+
* @param key - Save slot name
|
|
354
|
+
* @returns The saved data, or null if not found
|
|
355
|
+
*/
|
|
356
|
+
async load(key) {
|
|
357
|
+
try {
|
|
358
|
+
const result = await this.fetch("GET", `/v1/saves/${encodeURIComponent(key)}`);
|
|
359
|
+
return result.data;
|
|
360
|
+
} catch (e) {
|
|
361
|
+
if (e.message === "Save not found") {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
throw e;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* List all save keys for this player
|
|
369
|
+
*/
|
|
370
|
+
async listSaves() {
|
|
371
|
+
const result = await this.fetch("GET", "/v1/saves");
|
|
372
|
+
return result.keys;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Delete a save
|
|
376
|
+
* @param key - Save slot name
|
|
377
|
+
*/
|
|
378
|
+
async deleteSave(key) {
|
|
379
|
+
await this.fetch("DELETE", `/v1/saves/${encodeURIComponent(key)}`);
|
|
380
|
+
}
|
|
381
|
+
// ============ ROOMS API ============
|
|
382
|
+
/**
|
|
383
|
+
* Create a new multiplayer room
|
|
384
|
+
* @returns A Room instance (already connected)
|
|
385
|
+
*/
|
|
386
|
+
async createRoom() {
|
|
387
|
+
const result = await this.fetch("POST", "/v1/rooms");
|
|
388
|
+
const room = new Room(result.code, this.config);
|
|
389
|
+
await room.connect();
|
|
390
|
+
return room;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Join an existing room by code
|
|
394
|
+
* @param code - The 4-letter room code
|
|
395
|
+
* @returns A Room instance (already connected)
|
|
396
|
+
*/
|
|
397
|
+
async joinRoom(code) {
|
|
398
|
+
code = code.toUpperCase().trim();
|
|
399
|
+
await this.fetch("POST", `/v1/rooms/${code}/join`);
|
|
400
|
+
const room = new Room(code, this.config);
|
|
401
|
+
await room.connect();
|
|
402
|
+
return room;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Get info about a room without joining
|
|
406
|
+
* @param code - The 4-letter room code
|
|
407
|
+
*/
|
|
408
|
+
async getRoomInfo(code) {
|
|
409
|
+
code = code.toUpperCase().trim();
|
|
410
|
+
return this.fetch("GET", `/v1/rooms/${code}`);
|
|
411
|
+
}
|
|
412
|
+
// ============ STATS API ============
|
|
413
|
+
/**
|
|
414
|
+
* Get game-wide stats
|
|
415
|
+
* @returns Stats like online players, DAU, rooms active, etc.
|
|
416
|
+
*
|
|
417
|
+
* @example
|
|
418
|
+
* ```ts
|
|
419
|
+
* const stats = await wt.getStats()
|
|
420
|
+
* console.log(`${stats.online} players online`)
|
|
421
|
+
* console.log(`${stats.rooms} active rooms`)
|
|
422
|
+
* ```
|
|
423
|
+
*/
|
|
424
|
+
async getStats() {
|
|
425
|
+
return this.fetch("GET", "/v1/stats");
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Get the current player's stats
|
|
429
|
+
* @returns Player's firstSeen, sessions count, playtime
|
|
430
|
+
*
|
|
431
|
+
* @example
|
|
432
|
+
* ```ts
|
|
433
|
+
* const me = await wt.getPlayerStats()
|
|
434
|
+
* console.log(`You've played ${Math.floor(me.playtime / 3600)} hours`)
|
|
435
|
+
* console.log(`Member since ${new Date(me.firstSeen).toLocaleDateString()}`)
|
|
436
|
+
* ```
|
|
437
|
+
*/
|
|
438
|
+
async getPlayerStats() {
|
|
439
|
+
return this.fetch("GET", "/v1/stats/player");
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Track a session start (call on game load)
|
|
443
|
+
* This is called automatically if you use createRoom/joinRoom
|
|
444
|
+
*/
|
|
445
|
+
async trackSessionStart() {
|
|
446
|
+
await this.fetch("POST", "/v1/stats/track", { event: "session_start" });
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Track a session end (call on game close)
|
|
450
|
+
*/
|
|
451
|
+
async trackSessionEnd() {
|
|
452
|
+
await this.fetch("POST", "/v1/stats/track", { event: "session_end" });
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Convenience getter for stats (same as getStats but as property style)
|
|
456
|
+
* Note: This returns a promise, use `await wt.stats` or `wt.getStats()`
|
|
457
|
+
*/
|
|
458
|
+
get stats() {
|
|
459
|
+
return this.getStats();
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
var index_default = Watchtower;
|
|
463
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
464
|
+
0 && (module.exports = {
|
|
465
|
+
Room,
|
|
466
|
+
Watchtower
|
|
467
|
+
});
|