Philipp Horstenkamp f2bd1a8cab
All checks were successful
Lint / pre-commit Linting (push) Successful in 22s
Dep update
2025-04-08 22:00:41 +02:00

1538 lines
50 KiB
JavaScript

'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
const WebSocket = require('ws');
const URL = require('url');
const events = require('events');
const Debug = require('debug');
const zlib = require('zlib');
const axios = require('axios');
const util = require('util');
const fs = require('fs');
const YAML = require('yamljs');
const path = require('path');
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
const WebSocket__default = /*#__PURE__*/_interopDefaultLegacy(WebSocket);
const URL__default = /*#__PURE__*/_interopDefaultLegacy(URL);
const Debug__default = /*#__PURE__*/_interopDefaultLegacy(Debug);
const zlib__default = /*#__PURE__*/_interopDefaultLegacy(zlib);
const axios__default = /*#__PURE__*/_interopDefaultLegacy(axios);
const util__default = /*#__PURE__*/_interopDefaultLegacy(util);
const fs__default = /*#__PURE__*/_interopDefaultLegacy(fs);
const YAML__default = /*#__PURE__*/_interopDefaultLegacy(YAML);
const path__default = /*#__PURE__*/_interopDefaultLegacy(path);
const debug = Debug__default['default']('screepsapi:socket');
const DEFAULTS$1 = {
reconnect: true,
resubscribe: true,
keepAlive: true,
maxRetries: 10,
maxRetryDelay: 60 * 1000 // in milli-seconds
};
class Socket extends events.EventEmitter {
constructor (ScreepsAPI) {
super();
this.api = ScreepsAPI;
this.opts = Object.assign({}, DEFAULTS$1);
this.on('error', () => {}); // catch to prevent unhandled-exception errors
this.reset();
this.on('auth', ev => {
if (ev.data.status === 'ok') {
while (this.__queue.length) {
this.emit(this.__queue.shift());
}
clearInterval(this.keepAliveInter);
if (this.opts.keepAlive) {
this.keepAliveInter = setInterval(() => this.ws && this.ws.ping(1), 10000);
}
}
});
}
reset () {
this.authed = false;
this.connected = false;
this.reconnecting = false;
clearInterval(this.keepAliveInter);
this.keepAliveInter = 0;
this.__queue = []; // pending messages (to send once authenticated)
this.__subQueue = []; // pending subscriptions (to request once authenticated)
this.__subs = {}; // number of callbacks for each subscription
}
async connect (opts = {}) {
Object.assign(this.opts, opts);
if (!this.api.token) {
throw new Error('No token! Call api.auth() before connecting the socket!')
}
return new Promise((resolve, reject) => {
const baseURL = this.api.opts.url.replace('http', 'ws');
const wsurl = new URL.URL('socket/websocket', baseURL);
this.ws = new WebSocket__default['default'](wsurl);
this.ws.on('open', () => {
this.connected = true;
this.reconnecting = false;
if (this.opts.resubscribe) {
this.__subQueue.push(...Object.keys(this.__subs));
}
debug('connected');
this.emit('connected');
resolve(this.auth(this.api.token));
});
this.ws.on('close', () => {
clearInterval(this.keepAliveInter);
this.authed = false;
this.connected = false;
debug('disconnected');
this.emit('disconnected');
if (this.opts.reconnect) {
this.reconnect().catch(() => { /* error emitted in reconnect() */ });
}
});
this.ws.on('error', (err) => {
this.ws.terminate();
this.emit('error', err);
debug(`error ${err}`);
if (!this.connected) {
reject(err);
}
});
this.ws.on('unexpected-response', (req, res) => {
const err = new Error(`WS Unexpected Response: ${res.statusCode} ${res.statusMessage}`);
this.emit('error', err);
reject(err);
});
this.ws.on('message', (data) => this.handleMessage(data));
})
}
async reconnect () {
if (this.reconnecting) {
return
}
this.reconnecting = true;
let retries = 0;
let retry;
do {
let time = Math.pow(2, retries) * 100;
if (time > this.opts.maxRetryDelay) time = this.opts.maxRetryDelay;
await this.sleep(time);
if (!this.reconnecting) return // reset() called in-between
try {
await this.connect();
retry = false;
} catch (err) {
retry = true;
}
retries++;
debug(`reconnect ${retries}/${this.opts.maxRetries}`);
} while (retry && retries < this.opts.maxRetries)
if (retry) {
const err = new Error(`Reconnection failed after ${this.opts.maxRetries} retries`);
this.reconnecting = false;
debug('reconnect failed');
this.emit('error', err);
throw err
} else {
// Resume existing subscriptions on the new socket
Object.keys(this.__subs).forEach(sub => this.subscribe(sub));
}
}
disconnect () {
debug('disconnect');
clearInterval(this.keepAliveInter);
this.ws.removeAllListeners(); // remove listeners first or we may trigger reconnection & Co.
this.ws.terminate();
this.reset();
this.emit('disconnected');
}
sleep (time) {
return new Promise((resolve, reject) => {
setTimeout(resolve, time);
})
}
handleMessage (msg) {
msg = msg.data || msg; // Handle ws/browser difference
if (msg.slice(0, 3) === 'gz:') { msg = this.api.inflate(msg); }
debug(`message ${msg}`);
if (msg[0] === '[') {
msg = JSON.parse(msg);
let [, type, id, channel] = msg[0].match(/^(.+):(.+?)(?:\/(.+))?$/);
channel = channel || type;
const event = { channel, id, type, data: msg[1] };
this.emit(msg[0], event);
this.emit(event.channel, event);
this.emit('message', event);
} else {
const [channel, ...data] = msg.split(' ');
const event = { type: 'server', channel, data };
if (channel === 'auth') { event.data = { status: data[0], token: data[1] }; }
if (['protocol', 'time', 'package'].includes(channel)) { event.data = { [channel]: data[0] }; }
this.emit(channel, event);
this.emit('message', event);
}
}
async gzip (bool) {
this.send(`gzip ${bool ? 'on' : 'off'}`);
}
async send (data) {
if (!this.connected) {
this.__queue.push(data);
} else {
this.ws.send(data);
}
}
auth (token) {
return new Promise((resolve, reject) => {
this.send(`auth ${token}`);
this.once('auth', (ev) => {
const { data } = ev;
if (data.status === 'ok') {
this.authed = true;
this.emit('token', data.token);
this.emit('authed');
while (this.__subQueue.length) {
this.send(this.__subQueue.shift());
}
resolve();
} else {
reject(new Error('socket auth failed'));
}
});
})
}
async subscribe (path, cb) {
if (!path) return
const userID = await this.api.userID();
if (!path.match(/^(\w+):(.+?)$/)) { path = `user:${userID}/${path}`; }
if (this.authed) {
this.send(`subscribe ${path}`);
} else {
this.__subQueue.push(`subscribe ${path}`);
}
this.emit('subscribe', path);
this.__subs[path] = this.__subs[path] || 0;
this.__subs[path]++;
if (cb) this.on(path, cb);
}
async unsubscribe (path) {
if (!path) return
const userID = await this.api.userID();
if (!path.match(/^(\w+):(.+?)$/)) { path = `user:${userID}/${path}`; }
this.send(`unsubscribe ${path}`);
this.emit('unsubscribe', path);
if (this.__subs[path]) this.__subs[path]--;
}
}
const debugHttp = Debug__default['default']('screepsapi:http');
const debugRateLimit = Debug__default['default']('screepsapi:ratelimit');
const { format } = URL__default['default'];
const gunzipAsync = util__default['default'].promisify(zlib__default['default'].gunzip);
const inflateAsync = util__default['default'].promisify(zlib__default['default'].inflate);
const DEFAULT_SHARD = 'shard0';
const OFFICIAL_HISTORY_INTERVAL = 100;
const PRIVATE_HISTORY_INTERVAL = 20;
const sleep = ms => new Promise(resolve => setInterval(resolve, ms));
class RawAPI extends events.EventEmitter {
constructor (opts = {}) {
super();
this.setServer(opts);
const self = this;
this.raw = {
/**
* GET /api/version
* @returns {{
* ok:1, package:number, protocol: number,
* serverData: {
* customObjectTypes,
* historyChunkSize:number,
* features,
* shards: string[]
* },
* users:number
* }}
*/
version () {
return self.req('GET', '/api/version')
},
/**
* GET /api/authmod
* @returns {Object}
*/
authmod () {
if (self.isOfficialServer()) {
return Promise.resolve({ name: 'official' })
}
return self.req('GET', '/api/authmod')
},
/**
* Official:
* GET /room-history/${shard}/${room}/${tick}.json
* Private:
* GET /room-history
* @param {string} room
* @param {number} tick
* @param {string} shard
* @returns {Object} A json file with history data
*/
history (room, tick, shard = DEFAULT_SHARD) {
if (self.isOfficialServer()) {
tick -= tick % OFFICIAL_HISTORY_INTERVAL;
return self.req('GET', `/room-history/${shard}/${room}/${tick}.json`)
} else {
tick -= tick % PRIVATE_HISTORY_INTERVAL;
return self.req('GET', '/room-history', { room, time: tick })
}
},
servers: {
/**
* POST /api/servers/list
* A list of community servers
* @returns {{
* ok:number,
* servers:{
* _id:string,
* settings:{
* host:string,
* port:string,
* pass:string
* },
* name:string,
* status:"active"|string
* likeCount:number
* }[]
* }}
*/
list () {
return self.req('POST', '/api/servers/list', {})
}
},
auth: {
/**
* POST /api/auth/signin
* @param {string} email
* @param {string} password
* @returns {{ok:number, token:string}}
*/
signin (email, password) {
return self.req('POST', '/api/auth/signin', { email, password })
},
/**
* POST /api/auth/steam-ticket
* @param {*} ticket
* @param {*} useNativeAuth
* @returns {Object}
*/
steamTicket (ticket, useNativeAuth = false) {
return self.req('POST', '/api/auth/steam-ticket', { ticket, useNativeAuth })
},
/**
* GET /api/auth/me
* @returns {{
* ok: number;
* _id: string;
* email: string;
* username: string;
* cpu: number;
* badge: Badge;
* password: string;
* notifyPrefs: { sendOnline: any; errorsInterval: any; disabledOnMessages: any; disabled: any; interval: any };
* gcl: number;
* credits: number;
* lastChargeTime: any;
* lastTweetTime: any;
* github: { id: any; username: any };
* twitter: { username: string; followers_count: number };
*}}
*/
me () {
return self.req('GET', '/api/auth/me')
},
/**
* GET /api/auth/query-token
* @param {string} token
* @returns {Object}
*/
queryToken (token) {
return self.req('GET', '/api/auth/query-token', { token })
}
},
register: {
/**
* GET /api/register/check-email
* @param {string} email
* @returns {Object}
*/
checkEmail (email) {
return self.req('GET', '/api/register/check-email', { email })
},
/**
* GET /api/register/check-username
* @param {string} username
* @returns {Object}
*/
checkUsername (username) {
return self.req('GET', '/api/register/check-username', { username })
},
/**
* POST /api/register/set-username
* @param {string} username
* @returns {Object}
*/
setUsername (username) {
return self.req('POST', '/api/register/set-username', { username })
},
/**
* POST /api/register/submit
* @param {string} username
* @param {string} email
* @param {string} password
* @param {*} modules
* @returns {Object}
*/
submit (username, email, password, modules) {
return self.req('POST', '/api/register/submit', { username, email, password, modules })
}
},
userMessages: {
/**
* GET /api/user/messages/list?respondent={userId}
* @param {string} respondent the long `_id` of the user, not the username
* @returns {{ ok, messages: [ { _id, date, type, text, unread } ] }}
*/
list (respondent) {
return self.req('GET', '/api/user/messages/list', { respondent })
},
/**
* GET /api/user/messages/index
* @returns {{ ok, messages: [ { _id, message: { _id, user, respondent, date, type, text, unread } } ], users: { <user's _id>: { _id, username, badge: Badge } } }}
*/
index () {
return self.req('GET', '/api/user/messages/index')
},
/**
* GET /api/user/messages/unread-count
* @returns {{ ok, count:number }}
*/
unreadCount () {
return self.req('GET', '/api/user/messages/unread-count')
},
/**
* POST /api/user/messages/send
* @param {string} respondent the long `_id` of the user, not the username
* @param {string} text
* @returns {{ ok }}
*/
send (respondent, text) {
return self.req('POST', '/api/user/messages/send', { respondent, text })
},
/**
* POST /api/user/messages/mark-read
* @param {string} id
* @returns {Object}
*/
markRead (id) {
return self.req('POST', '/api/user/messages/mark-read', { id })
}
},
game: {
/**
* @typedef {"creepsLost"|"creepsProduced"|"energyConstruction"|"energyControl"|"energyCreeps"|"energyHarvested"} stat
* @param {string[]} rooms An array of room names
* @param {"owner0"|"claim0"|stat} statName
* @param {string} shard
* @returns {{
* ok:number,
* stats: {
* [roomName:string]: {
* status,
* novice,
* own: { user, level },
* <stat>: [ { user, value }]
* }
* }
* , users: { [userId:string]: { _id, username, badge: Badge } } }}
* The return type is not mapped correctly
*/
mapStats (rooms, statName, shard = DEFAULT_SHARD) {
return self.req('POST', '/api/game/map-stats', { rooms, statName, shard })
},
/**
* POST /api/game/gen-unique-object-name
* @param {"flag"|"spawn"|string} type can be at least "flag" or "spawn"
* @param {string} shard
* @returns { ok, name:string }
*/
genUniqueObjectName (type, shard = DEFAULT_SHARD) {
return self.req('POST', '/api/game/gen-unique-object-name', { type, shard })
},
/**
* POST /api/game/check-unique-object-name
* @param {string} type
* @param {string} name
* @param {string} shard
* @returns {Object}
*/
checkUniqueObjectName (type, name, shard = DEFAULT_SHARD) {
return self.req('POST', '/api/game/check-unique-object-name', { type, name, shard })
},
/**
* @param {string} room
* @param {number} x
* @param {number} y
* @param {string} name
* @param {string?} shard
*/
placeSpawn (room, x, y, name, shard = DEFAULT_SHARD) {
return self.req('POST', '/api/game/place-spawn', { name, room, x, y, shard })
},
/**
* POST /api/game/create-flag
* @param {string} room
* @param {number} x
* @param {number} y
* @param {string} name
* @param {FlagColor} color
* @param {FlagColor} secondaryColor
* @param {string} shard
* @returns {{ ok, result: { nModified, ok, upserted: [ { index, _id } ], n }, connection: { host, id, port } }}
* - if the name is new, result.upserted[0]._id is the game id of the created flag
* - if not, this moves the flag and the response does not contain the id (but the id doesn't change)
* - `connection` looks like some internal MongoDB thing that is irrelevant to us
*/
createFlag (room, x, y, name, color = 1, secondaryColor = 1, shard = DEFAULT_SHARD) {
return self.req('POST', '/api/game/create-flag', { name, room, x, y, color, secondaryColor, shard })
},
/**
* POST/api/game/gen-unique-flag-name
* @param {string} shard
* @returns {Object}
*/
genUniqueFlagName (shard = DEFAULT_SHARD) {
return self.req('POST', '/api/game/gen-unique-flag-name', { shard })
},
/**
* POST /api/game/check-unique-flag-name
* @param {string} name
* @param {string} shard
* @returns {Object}
*/
checkUniqueFlagName (name, shard = DEFAULT_SHARD) {
return self.req('POST', '/api/game/check-unique-flag-name', { name, shard })
},
/**
* POST /api/game/change-flag-color
* @param {FlagColor} color
* @param {FlagColor} secondaryColor
* @param {string} shard
* @returns {{ ok, result: { nModified, ok, n }, connection: { host, id, port } }}
*/
changeFlagColor (color = 1, secondaryColor = 1, shard = DEFAULT_SHARD) {
return self.req('POST', '/api/game/change-flag-color', { color, secondaryColor, shard })
},
/**
* POST /api/game/remove-flag
* @param {string} room
* @param {string} name
* @param {string} shard
* @returns {Object}
*/
removeFlag (room, name, shard = DEFAULT_SHARD) {
return self.req('POST', '/api/game/remove-flag', { name, room, shard })
},
/**
* POST /api/game/add-object-intent
* [Missing parameter] _id is the game id of the object to affect (except for destroying structures), room is the name of the room it's in
* this method is used for a variety of actions, depending on the `name` and `intent` parameters
* @example remove flag: name = "remove", intent = {}
* @example destroy structure: _id = "room", name = "destroyStructure", intent = [ {id: <structure id>, roomName, <room name>, user: <user id>} ]
can destroy multiple structures at once
* @example suicide creep: name = "suicide", intent = {id: <creep id>}
* @example unclaim controller: name = "unclaim", intent = {id: <controller id>}
intent can be an empty object for suicide and unclaim, but the web interface sends the id in it, as described
* @example remove construction site: name = "remove", intent = {}
* @param {string} room
* @param {string} name
* @param {string} intent
* @param {string} shard
* @returns {{ ok, result: { nModified, ok, upserted: [ { index, _id } ], n }, connection: { host, id, port } }}
*/
addObjectIntent (room, name, intent, shard = DEFAULT_SHARD) {
return self.req('POST', '/api/game/add-object-intent', { room, name, intent, shard })
},
/**
* POST /api/game/create-construction
* @param {string} room
* @param {number} x
* @param {number} y
* @param {string} structureType the same value as one of the in-game STRUCTURE_* constants ('road', 'spawn', etc.)
* @param {string} name
* @param {string} shard
* @returns {{ ok, result: { ok, n }, ops: [ { type, room, x, y, structureType, user, progress, progressTotal, _id } ], insertedCount, insertedIds }}
*/
createConstruction (room, x, y, structureType, name, shard = DEFAULT_SHARD) {
return self.req('POST', '/api/game/create-construction', { room, x, y, structureType, name, shard })
},
/**
* POST /api/game/set-notify-when-attacked
* @param {string} _id
* @param {bool} enabled is either true or false (literal values, not strings)
* @param {string} shard
* @returns {{ ok, result: { ok, nModified, n }, connection: { id, host, port } }}
*/
setNotifyWhenAttacked (_id, enabled = true, shard = DEFAULT_SHARD) {
return self.req('POST', '/api/game/set-notify-when-attacked', { _id, enabled, shard })
},
/**
* POST /api/game/create-invader
* @param {string} room
* @param {number} x
* @param {number} y
* @param {*} size
* @param {*} type
* @param {boolean} boosted
* @param {string} shard
* @returns {Object}
*/
createInvader (room, x, y, size, type, boosted = false, shard = DEFAULT_SHARD) {
return self.req('POST', '/api/game/create-invader', { room, x, y, size, type, boosted, shard })
},
/**
* POST /api/game/remove-invader
* @param {string} _id
* @param {string} shard
* @returns {Object}
*/
removeInvader (_id, shard = DEFAULT_SHARD) {
return self.req('POST', '/api/game/remove-invader', { _id, shard })
},
/**
* GET /api/game/time
* @param {string} shard
* @returns {{ ok:number, time:number }}
*/
time (shard = DEFAULT_SHARD) {
return self.req('GET', '/api/game/time', { shard })
},
/**
* GET /api/game/world-size
* @param {string} shard
* @returns {Object}
*/
worldSize (shard = DEFAULT_SHARD) {
return self.req('GET', '/api/game/world-size', { shard })
},
/**
* GET /api/game/room-decorations
* @param {string} room
* @param {string} shard
* @returns {Object}
*/
roomDecorations (room, shard = DEFAULT_SHARD) {
return self.req('GET', '/api/game/room-decorations', { room, shard })
},
/**
* GET /api/game/room-objects
* @param {string} room
* @param {string} shard
* @returns {Object}
*/
roomObjects (room, shard = DEFAULT_SHARD) {
return self.req('GET', '/api/game/room-objects', { room, shard })
},
/**
* @param {string} room
* @param {*} encoded can be anything non-empty
* @param {string} shard
* @returns {{ ok, terrain: [ { room:string, x:number, y:number, type:"wall"|"swamp" } ] }
* | { ok, terrain: [ { _id,room:string, terrain:string, type:"wall"|"swamp" } ] }}
* terrain is a string of digits, giving the terrain left-to-right and top-to-bottom
* 0: plain, 1: wall, 2: swamp, 3: also wall
*/
roomTerrain (room, encoded = 1, shard = DEFAULT_SHARD) {
return self.req('GET', '/api/game/room-terrain', { room, encoded, shard })
},
/**
* @param {string} room
* @param {string} shard
* @returns {{ _id, status:"normal"|"out of borders"|string, novice:string }}
* `status` can at least be "normal" or "out of borders"
* if the room is in a novice area, novice will contain the Unix timestamp of the end of the protection (otherwise it is absent)
*/
roomStatus (room, shard = DEFAULT_SHARD) {
return self.req('GET', '/api/game/room-status', { room, shard })
},
/**
* GET /api/game/room-overview
* @param {string} room
* @param {number} interval
* @param {string} shard
* @returns {Object}
*/
roomOverview (room, interval = 8, shard = DEFAULT_SHARD) {
return self.req('GET', '/api/game/room-overview', { room, interval, shard })
},
market: {
/**
* GET /api/game/market/orders-index
* @param {string} shard
* @returns {{ok:1,list:[{_id:string,count:number}]}}
* - _id is the resource type, and there will only be one of each type.
* - `count` is the number of orders.
*/
ordersIndex (shard = DEFAULT_SHARD) {
return self.req('GET', '/api/game/market/orders-index', { shard })
},
/**
* GET /api/game/market/my-orders
* @returns {{ ok:number, list: [ { _id, created, user, active, type, amount, remainingAmount, resourceType, price, totalAmount, roomName } ] }}
* `resourceType` is one of the RESOURCE_* constants.
*/
myOrders () {
return self.req('GET', '/api/game/market/my-orders').then(self.mapToShard)
},
/**
* GET /api/game/market/orders
* @param {string} resourceType one of the RESOURCE_* constants.
* @param {string} shard
* @returns {{ ok:number, list: [ { _id, created, user, active, type, amount, remainingAmount, resourceType, price, totalAmount, roomName } ] }}
* `resourceType` is one of the RESOURCE_* constants.
*/
orders (resourceType, shard = DEFAULT_SHARD) {
return self.req('GET', '/api/game/market/orders', { resourceType, shard })
},
/**
* GET /api/game/market/stats
* @param {*} resourceType
* @param {string} shard
* @returns {Object}
*/
stats (resourceType, shard = DEFAULT_SHARD) {
return self.req('GET', '/api/game/market/stats', { resourceType, shard })
}
},
shards: {
/**
* GET /api/game/shards/info
* @returns {{ok:number, shards:[{name:string,lastTicks:number[],cpuLimimt:number,rooms:number,users:number,tick:number}]}}
*/
info () {
return self.req('GET', '/api/game/shards/info')
}
}
},
leaderboard: {
/**
* GET /api/leaderboard/list
* @param {number} limit
* @param {"world"|"power"} mode
* @param {number?} offset
* @param {string?} season
* @returns {{ ok, list: [ { _id, season, user, score, rank } ], count, users: { <user's _id>: { _id, username, badge: { type, color1, color2, color3, param, flip }, gcl } } }}
*/
list (limit = 10, mode = 'world', offset = 0, season) {
if (mode !== 'world' && mode !== 'power') throw new Error('incorrect mode parameter')
if (!season) season = self.currentSeason();
return self.req('GET', '/api/leaderboard/list', { limit, mode, offset, season })
},
/**
* GET /api/leaderboard/find
* @param {string} username
* @param {"world"|string} mode
* @param {string?} season An optional date in the format YYYY-MM, if not supplied all ranks in all seasons is returned.
* @returns {{ ok, _id, season, user, score, rank }}
* - `user` (not `_id`) is the user's _id, as returned by `me` and `user/find`
* - `rank` is 0-based
*/
find (username, mode = 'world', season = '') {
return self.req('GET', '/api/leaderboard/find', { season, mode, username })
},
/**
* GET /api/leaderboard/seasons
* @returns {{ ok, seasons: [ { _id, name, date } ] }}
* The _id returned here is used for the season name in the other leaderboard calls
*/
seasons () {
return self.req('GET', '/api/leaderboard/seasons')
}
},
user: {
/**
* @param {Badge} badge
* @returns {{ ok?:number,error?:string}}
*/
badge (badge) {
return self.req('POST', '/api/user/badge', { badge })
},
/**
* POST /api/user/respawn
* @returns {Object}
*/
respawn () {
return self.req('POST', '/api/user/respawn')
},
/**
* POST /api/user/set-active-branch
* @param {string} branch
* @param {string} activeName
* @returns {Object}
*/
setActiveBranch (branch, activeName) {
return self.req('POST', '/api/user/set-active-branch', { branch, activeName })
},
/**
* POST /api/user/clone-branch
* @param {string} branch
* @param {string} newName
* @param {*} defaultModules
* @returns {Object}
*/
cloneBranch (branch, newName, defaultModules) {
return self.req('POST', '/api/user/clone-branch', { branch, newName, defaultModules })
},
/**
* POST /api/user/delete-branch
* @param {string} branch
* @returns {Object}
*/
deleteBranch (branch) {
return self.req('POST', '/api/user/delete-branch', { branch })
},
/**
* POST /api/user/notify-prefs
* @param {*} prefs
* @returns {Object}
*/
notifyPrefs (prefs) {
// disabled,disabledOnMessages,sendOnline,interval,errorsInterval
return self.req('POST', '/api/user/notify-prefs', prefs)
},
/**
* POST /api/user/tutorial-done
* @returns {Object}
*/
tutorialDone () {
return self.req('POST', '/api/user/tutorial-done')
},
/**
* POST /api/user/email
* @param {string} email
* @returns {Object}
*/
email (email) {
return self.req('POST', '/api/user/email', { email })
},
/**
* GET /api/user/world-start-room
* @param {string} shard
* @returns {Object}
*/
worldStartRoom (shard) {
return self.req('GET', '/api/user/world-start-room', { shard })
},
/**
* returns a world status
* - 'normal'
* - 'lost' when you loose all your spawns
* - 'empty' when you have respawned and not placed your spawn yet
* @returns {{ ok: number; status: "normal" | "lost" | "empty" }} */
worldStatus () {
return self.req('GET', '/api/user/world-status')
},
/**
* GET /api/user/branches
* @returns {{ ok:number, list: [{
* _id: string;
* branch: string;
* activeWorld: boolean;
* activeSim: boolean;
* }]}
* }
*/
branches () {
return self.req('GET', '/api/user/branches')
},
code: {
/**
* GET /api/user/code
* for pushing or pulling code, as documented at http://support.screeps.com/hc/en-us/articles/203022612
* @param {string} branch
* @returns code
*/
get (branch) {
return self.req('GET', '/api/user/code', { branch })
},
/**
* POST /api/user/code
* for pushing or pulling code, as documented at http://support.screeps.com/hc/en-us/articles/203022612
* @param {string} branch
* @param {*} modules
* @param {*} _hash
* @returns {Object}
*/
set (branch, modules, _hash) {
if (!_hash) _hash = Date.now();
return self.req('POST', '/api/user/code', { branch, modules, _hash })
}
},
decorations: {
/**
* GET /api/user/decorations/inventory
* @returns {Object}
*/
inventory () {
return self.req('GET', '/api/user/decorations/inventory')
},
/**
* GET /api/user/decorations/themes
* @returns {Object}
*/
themes () {
return self.req('GET', '/api/user/decorations/themes')
},
/**
* POST /api/user/decorations/convert
* @param {*} decorations decorations is a string array of ids
* @returns {Object}
*/
convert (decorations) {
return self.req('POST', '/api/user/decorations/convert', { decorations })
},
/**
* POST /api/user/decorations/pixelize
* @param {number} count
* @param {string} theme
* @returns {Object}
*/
pixelize (count, theme = '') {
return self.req('POST', '/api/user/decorations/pixelize', { count, theme })
},
/**
* POST /api/user/decorations/activate
* @param {string} _id
* @param {*} active
* @returns {Object}
*/
activate (_id, active) {
return self.req('POST', '/api/user/decorations/activate', { _id, active })
},
/**
* POST /api/user/decorations/deactivate
* @param {*} decorations decorations is a string array of ids
* @returns {Object}
*/
deactivate (decorations) {
return self.req('POST', '/api/user/decorations/deactivate', { decorations })
}
},
/**
* GET /api/user/respawn-prohibited-rooms
* @returns {{ ok, rooms: [ ] }}
* - `room` is an array, but seems to always contain only one element
*/
respawnProhibitedRooms () {
return self.req('GET', '/api/user/respawn-prohibited-rooms')
},
memory: {
/**
* GET /api/user/memory?path={path}
* @param {string} path the path may be empty or absent to retrieve all of Memory, Example: flags.Flag1
* @param {string} shard
* @returns {string} gz: followed by base64-encoded gzipped JSON encoding of the requested memory path
*/
get (path, shard = DEFAULT_SHARD) {
return self.req('GET', '/api/user/memory', { path, shard })
},
/**
* POST /api/user/memory
* @param {string} path the path may be empty or absent to retrieve all of Memory, Example: flags.Flag1
* @param {*} value
* @param {string} shard
* @returns {{ ok, result: { ok, n }, ops: [ { user, expression, hidden } ], data, insertedCount, insertedIds }}
*/
set (path, value, shard = DEFAULT_SHARD) {
return self.req('POST', '/api/user/memory', { path, value, shard })
},
segment: {
/**
* GET /api/user/memory-segment?segment=[0-99]
* @param {number} segment A number from 0-99
* @param {string} shard
* @returns {{ ok, data: string }}
*/
get (segment, shard = DEFAULT_SHARD) {
return self.req('GET', '/api/user/memory-segment', { segment, shard })
},
/**
* POST /api/user/memory-segment
* @param {number} segment A number from 0-99
* @param {*} data
* @param {string} shard
* @returns {Object}
*/
set (segment, data, shard = DEFAULT_SHARD) {
return self.req('POST', '/api/user/memory-segment', { segment, data, shard })
}
}
},
/**
* GET /api/user/find?username={username}
* @param {string} username
* @returns {{ ok, user: { _id, username, badge: Badge, gcl } }}
*/
find (username) {
return self.req('GET', '/api/user/find', { username })
},
/**
* GET /api/user/find?id={userId}
* @param {string} id
* @returns {{ ok, user: { _id, username, badge: Badge, gcl } }}
*/
findById (id) {
return self.req('GET', '/api/user/find', { id })
},
/**
* GET /api/user/stats
* @param {number} interval
* @returns {Object}
*/
stats (interval) {
return self.req('GET', '/api/user/stats', { interval })
},
/**
* GET /api/user/rooms
* @param {string} id
* @returns {Object}
*/
rooms (id) {
return self.req('GET', '/api/user/rooms', { id }).then(self.mapToShard)
},
/**
* GET /api/user/overview?interval={interval}&statName={statName}
* @param {number} interval
* @param {string} statName energyControl
* @returns {{{ ok, rooms: [ <room name> ], stats: { <room name>: [ { value, endTime } ] }, statsMax }}}
*/
overview (interval, statName) {
return self.req('GET', '/api/user/overview', { interval, statName })
},
/**
* GET /api/user/money-history
* @param {number} page Used for pagination
* @returns {{"ok":1,"page":0,"list":[ { _id, date, tick, user, type, balance, change, market: {} } ] }}
* - page used for pagination.
* - hasMore is true if there are more pages to view.
* - market
* - New Order- { order: { type, resourceType, price, totalAmount, roomName } }
* - Extended Order- { extendOrder: { orderId, addAmount } }
* - Fulfilled Order- { resourceType, roomName, targetRoomName, price, npc, amount }
* - Price Change - { changeOrderPrice: { orderId, oldPrice, newPrice } }
*/
moneyHistory (page = 0) {
return self.req('GET', '/api/user/money-history', { page })
},
/**
* POST /api/user/console
* @param {*} expression
* @param {string} shard
* @returns {{ ok, result: { ok, n }, ops: [ { user, expression, _id } ], insertedCount, insertedIds: [ <mongodb id> ] }}
*/
console (expression, shard = DEFAULT_SHARD) {
return self.req('POST', '/api/user/console', { expression, shard })
},
/**
* GET /api/user/name
* @returns {Object}
*/
name () {
return self.req('GET', '/api/user/name')
}
},
experimental: {
// https://screeps.com/api/experimental/pvp?start=14787157 seems to not be implemented in the api
/**
* @param {number} interval
* @returns {{ ok, time, rooms: [ { _id, lastPvpTime } ] }}
* time is the current server tick
* _id contains the room name for each room, and lastPvpTime contains the last tick pvp occurred
* if neither a valid interval nor a valid start argument is provided, the result of the call is still ok, but with an empty rooms array.
*/
pvp (interval = 100) {
return self.req('GET', '/api/experimental/pvp', { interval }).then(self.mapToShard)
},
/**
* GET /api/experimental/nukes
* @returns {Object}
*/
nukes () {
return self.req('GET', '/api/experimental/nukes').then(self.mapToShard)
}
},
warpath: {
/**
* GET /api/warpath/battles
* @param {number} interval
* @returns {Object}
*/
battles (interval = 100) {
return self.req('GET', '/api/warpath/battles', { interval })
}
},
scoreboard: {
/**
* GET /api/scoreboard/list
* @param {number} limit
* @param {number} offset
* @returns {Object}
*/
list (limit = 20, offset = 0) {
return self.req('GET', '/api/scoreboard/list', { limit, offset })
}
}
};
}
currentSeason () {
const now = new Date();
const year = now.getFullYear();
let month = (now.getUTCMonth() + 1).toString();
if (month.length === 1) month = `0${month}`;
return `${year}-${month}`
}
isOfficialServer () {
return this.opts.url.match(/screeps\.com/) !== null
}
mapToShard (res) {
if (!res.shards) {
res.shards = {
privSrv: res.list || res.rooms
};
}
return res
}
setServer (opts) {
if (!this.opts) {
this.opts = {};
}
Object.assign(this.opts, opts);
if (opts.path && !opts.pathname) {
this.opts.pathname = this.opts.path;
}
if (!opts.url) {
this.opts.url = format(this.opts);
if (!this.opts.url.endsWith('/')) this.opts.url += '/';
}
if (opts.token) {
this.token = opts.token;
}
this.http = axios__default['default'].create({
baseURL: this.opts.url
});
}
async auth (email, password, opts = {}) {
this.setServer(opts);
if (email && password) {
Object.assign(this.opts, { email, password });
}
const res = await this.raw.auth.signin(this.opts.email, this.opts.password);
this.emit('token', res.token);
this.emit('auth');
this.__authed = true;
return res
}
async req (method, path, body = {}) {
const opts = {
method,
url: path,
headers: {}
};
debugHttp(`${method} ${path} ${JSON.stringify(body)}`);
if (this.token) {
Object.assign(opts.headers, {
'X-Token': this.token,
'X-Username': this.token
});
}
if (method === 'GET') {
opts.params = body;
} else {
opts.data = body;
}
try {
const res = await this.http(opts);
const token = res.headers['x-token'];
if (token) {
this.emit('token', token);
}
const rateLimit = this.buildRateLimit(method, path, res);
this.emit('rateLimit', rateLimit);
debugRateLimit(`${method} ${path} ${rateLimit.remaining}/${rateLimit.limit} ${rateLimit.toReset}s`);
if (typeof res.data.data === 'string' && res.data.data.slice(0, 3) === 'gz:') {
res.data.data = await this.gz(res.data.data);
}
this.emit('response', res);
return res.data
} catch (err) {
const res = err.response || {};
const rateLimit = this.buildRateLimit(method, path, res);
this.emit('rateLimit', rateLimit);
debugRateLimit(`${method} ${path} ${rateLimit.remaining}/${rateLimit.limit} ${rateLimit.toReset}s`);
if (res.status === 401) {
if (this.__authed && this.opts.email && this.opts.password) {
this.__authed = false;
await this.auth(this.opts.email, this.opts.password);
return this.req(method, path, body)
} else {
throw new Error('Not Authorized')
}
}
if (res.status === 429 && !res.headers['x-ratelimit-limit'] && this.opts.experimentalRetry429) {
await sleep(Math.floor(Math.random() * 500) + 200);
return this.req(method, path, body)
}
if (err.response) {
throw new Error(res.data)
}
throw new Error(err.message)
}
}
async gz (data) {
const buf = Buffer.from(data.slice(3), 'base64');
const ret = await gunzipAsync(buf);
return JSON.parse(ret.toString())
}
async inflate (data) { // es
const buf = Buffer.from(data.slice(3), 'base64');
const ret = await inflateAsync(buf);
return JSON.parse(ret.toString())
}
buildRateLimit (method, path, res) {
const {
headers: {
'x-ratelimit-limit': limit,
'x-ratelimit-remaining': remaining,
'x-ratelimit-reset': reset
} = {}
} = res;
return {
method,
path,
limit: +limit,
remaining: +remaining,
reset: +reset,
toReset: reset - Math.floor(Date.now() / 1000)
}
}
}
/**
* @typedef {{
* "color1": string;
* "color2": string;
* "color3": string;
* "flip": boolean;
* "param": number;
* "type": number|{ path1:string, path2:string};
*}} Badge
*/
/**
* @typedef {1|2|3|4|5|6|7|8|9|10} FlagColor
* - Red = 1,
* - Purple = 2,
* - Blue = 3,
* - Cyan = 4,
* - Green = 5,
* - Yellow = 6,
* - Orange = 7,
* - Brown = 8,
* - Grey = 9,
* - White = 10
*/
const readFileAsync = util__default['default'].promisify(fs__default['default'].readFile);
class ConfigManager {
async refresh () {
this._config = null;
await this.getConfig();
}
async getServers () {
const conf = await this.getConfig();
return Object.keys(conf.servers)
}
async getConfig () {
if (this._config) {
return this._config
}
const paths = [];
if (process.env.SCREEPS_CONFIG) {
paths.push(process.env.SCREEPS_CONFIG);
}
const dirs = [__dirname, ''];
for (const dir of dirs) {
paths.push(path__default['default'].join(dir, '.screeps.yaml'));
paths.push(path__default['default'].join(dir, '.screeps.yml'));
}
if (process.platform === 'win32') {
paths.push(path__default['default'].join(process.env.APPDATA, 'screeps/config.yaml'));
paths.push(path__default['default'].join(process.env.APPDATA, 'screeps/config.yml'));
} else {
if (process.env.XDG_CONFIG_PATH) {
paths.push(
path__default['default'].join(process.env.XDG_CONFIG_HOME, 'screeps/config.yaml')
);
paths.push(
path__default['default'].join(process.env.XDG_CONFIG_HOME, 'screeps/config.yml')
);
}
if (process.env.HOME) {
paths.push(path__default['default'].join(process.env.HOME, '.config/screeps/config.yaml'));
paths.push(path__default['default'].join(process.env.HOME, '.config/screeps/config.yml'));
paths.push(path__default['default'].join(process.env.HOME, '.screeps.yaml'));
paths.push(path__default['default'].join(process.env.HOME, '.screeps.yml'));
}
}
for (const path of paths) {
const data = await this.loadConfig(path);
if (data) {
if (!data.servers) {
throw new Error(
`Invalid config: 'servers' object does not exist in '${path}'`
)
}
this._config = data;
this.path = path;
return data
}
}
return null
}
async loadConfig (file) {
try {
const contents = await readFileAsync(file, 'utf8');
return YAML__default['default'].parse(contents)
} catch (e) {
if (e.code === 'ENOENT') {
return false
} else {
throw e
}
}
}
}
const DEFAULTS = {
protocol: 'https',
hostname: 'screeps.com',
port: 443,
path: '/'
};
const configManager = new ConfigManager();
class ScreepsAPI extends RawAPI {
static async fromConfig (server = 'main', config = false, opts = {}) {
const data = await configManager.getConfig();
if (data) {
if (!data.servers[server]) {
throw new Error(`Server '${server}' does not exist in '${configManager.path}'`)
}
const conf = data.servers[server];
if (conf.ptr) conf.path = '/ptr';
if (conf.season) conf.path = '/season';
const api = new ScreepsAPI(
Object.assign(
{
hostname: conf.host,
port: conf.port,
protocol: conf.secure ? 'https' : 'http',
token: conf.token,
path: conf.path || '/'
},
opts
)
);
api.appConfig = (data.configs && data.configs[config]) || {};
if (!conf.token && conf.username && conf.password) {
await api.auth(conf.username, conf.password);
}
return api
}
throw new Error('No valid config found')
}
constructor (opts) {
opts = Object.assign({}, DEFAULTS, opts);
super(opts);
this.on('token', token => {
this.token = token;
this.raw.token = token;
});
const defaultLimit = (limit, period) => ({
limit,
period,
remaining: limit,
reset: 0,
toReset: 0
});
this.rateLimits = {
global: defaultLimit(120, 'minute'),
GET: {
'/api/game/room-terrain': defaultLimit(360, 'hour'),
'/api/user/code': defaultLimit(60, 'hour'),
'/api/user/memory': defaultLimit(1440, 'day'),
'/api/user/memory-segment': defaultLimit(360, 'hour'),
'/api/game/market/orders-index': defaultLimit(60, 'hour'),
'/api/game/market/orders': defaultLimit(60, 'hour'),
'/api/game/market/my-orders': defaultLimit(60, 'hour'),
'/api/game/market/stats': defaultLimit(60, 'hour'),
'/api/game/user/money-history': defaultLimit(60, 'hour')
},
POST: {
'/api/user/console': defaultLimit(360, 'hour'),
'/api/game/map-stats': defaultLimit(60, 'hour'),
'/api/user/code': defaultLimit(240, 'day'),
'/api/user/set-active-branch': defaultLimit(240, 'day'),
'/api/user/memory': defaultLimit(240, 'day'),
'/api/user/memory-segment': defaultLimit(60, 'hour')
}
};
this.on('rateLimit', limits => {
const rate =
this.rateLimits[limits.method][limits.path] || this.rateLimits.global;
const copy = Object.assign({}, limits);
delete copy.path;
delete copy.method;
Object.assign(rate, copy);
});
this.socket = new Socket(this);
}
getRateLimit (method, path) {
return this.rateLimits[method][path] || this.rateLimits.global
}
get rateLimitResetUrl () {
return `https://screeps.com/a/#!/account/auth-tokens/noratelimit?token=${this.token.slice(
0,
8
)}`
}
async me () {
if (this._user) return this._user
const tokenInfo = await this.tokenInfo();
if (tokenInfo.full) {
this._user = await this.raw.auth.me();
} else {
const { username } = await this.raw.user.name();
const { user } = await this.raw.user.find(username);
this._user = user;
}
return this._user
}
async tokenInfo () {
if (this._tokenInfo) {
return this._tokenInfo
}
if (this.opts.token) {
const { token } = await this.raw.auth.queryToken(this.token);
this._tokenInfo = token;
} else {
this._tokenInfo = { full: true };
}
return this._tokenInfo
}
async userID () {
const user = await this.me();
return user._id
}
get history () {
return this.raw.history
}
get authmod () {
return this.raw.authmod
}
get version () {
return this.raw.version
}
get time () {
return this.raw.game.time
}
get leaderboard () {
return this.raw.leaderboard
}
get market () {
return this.raw.game.market
}
get registerUser () {
return this.raw.register.submit
}
get code () {
return this.raw.user.code
}
get memory () {
return this.raw.user.memory
}
get segment () {
return this.raw.user.memory.segment
}
get console () {
return this.raw.user.console
}
}
exports.ScreepsAPI = ScreepsAPI;