alclient
Version:
A node client for interacting with Adventure Land - The Code MMORPG. This package extends the functionality of 'alclient' by managing a mongo database.
1,239 lines • 259 kB
JavaScript
import { Database, DeathModel, EntityModel, InstanceModel, NPCModel, PlayerModel } from "./database/Database.js";
import { Constants } from "./Constants.js";
import { Item } from "./Item.js";
import { Observer } from "./Observer.js";
import { Pathfinder } from "./Pathfinder.js";
import { Tools } from "./Tools.js";
import { AchievementModel } from "./database/achievements/achievements.model.js";
import { BankModel } from "./database/banks/banks.model.js";
import { isDeepStrictEqual } from "util";
import { TradeItem } from "./TradeItem.js";
export class Character extends Observer {
userAuth;
characterID;
timeouts = new Map();
achievements = new Map();
get bank() {
return this.user;
}
chests = new Map();
nextSkill = new Map();
partyData;
ready = false;
/** Because of game server limitations, if your character disconnects you need make a new Character object, you can't call `connect()` again. */
noReconnect = false;
// CharacterData
afk;
age;
apiercing = 0;
blast = 0;
controller;
name;
id;
ctype;
abs;
angle;
armor = 0;
attack = 0;
c = {};
cid;
cx;
damage_type;
focus;
frequency;
going_x;
going_y;
gold = 0;
heal = 0;
home;
hp = 0;
level = 1;
m;
mp_cost;
max_hp = 1;
max_mp = 1;
move_num;
moving = false;
mp = 1;
npc;
owner;
party;
pdps;
q = {};
range = 1;
resistance = 0;
rip = true;
rpiercing = 0;
s = {};
skin;
slots;
speed = 1;
stand;
tp = false;
emx;
explosion;
incdmgamp;
firesistance;
fzresistance;
mp_reduction;
phresistance;
pnresistance;
stresistance;
stun;
int;
str;
dex;
vit;
for;
max_xp;
goldm;
xpm;
xp;
luckm;
isize;
esize;
cash;
targets;
target;
team;
evasion;
miss;
reflection;
lifesteal;
manasteal;
crit;
critdamage;
dreturn;
tax;
xrange;
items;
cc;
ipass;
friends;
acx;
xcx;
hitchhikers;
user = { gold: 0 };
fear;
courage;
mcourage;
pcourage;
width = Constants.CHARACTER_WIDTH;
height = Constants.CHARACTER_HEIGHT;
constructor(userID, userAuth, characterID, g, serverData) {
super(serverData, g);
this.owner = userID;
this.userAuth = userAuth;
this.characterID = characterID;
}
async updateLoop() {
if (this.noReconnect)
return;
if (!this.socket || this.socket.disconnected || !this.ready) {
this.timeouts.set("updateLoop", setTimeout(async () => {
this.updateLoop();
}, Constants.UPDATE_POSITIONS_EVERY_MS));
return;
}
if (this.lastPositionUpdate === undefined) {
this.updatePositions();
this.timeouts.set("updateLoop", setTimeout(async () => {
this.updateLoop();
}, Constants.UPDATE_POSITIONS_EVERY_MS));
return;
}
if (this.lastAllEntities !== undefined && Date.now() - this.lastAllEntities > Constants.STALE_MONSTER_MS) {
await this.requestEntitiesData().catch(() => {
/* Suppress Errors */
});
}
const msSinceLastUpdate = Date.now() - this.lastPositionUpdate;
if (msSinceLastUpdate > Constants.UPDATE_POSITIONS_EVERY_MS) {
// Update now
this.updatePositions();
this.timeouts.set("updateLoop", setTimeout(async () => {
this.updateLoop();
}, Constants.UPDATE_POSITIONS_EVERY_MS));
return;
}
else {
// Update in a bit
this.timeouts.set("updateLoop", setTimeout(async () => {
this.updateLoop();
}, Constants.UPDATE_POSITIONS_EVERY_MS - msSinceLastUpdate));
return;
}
}
parseCharacter(data) {
this.updatePositions();
// Update all the character information we can
for (const datum in data) {
if (datum == "hitchhikers") {
// Game responses
for (const [event, datum] of data.hitchhikers) {
if (event == "game_response") {
this.parseGameResponse(datum);
}
else if (event == "eval") {
this.parseEval(datum);
}
}
}
else if (datum == "entities") {
this.parseEntities(data[datum]);
}
else if (datum == "owner") {
// We know who the owner is, for some reason start sends it as a blank string
}
else if (datum == "tp") {
// We just teleported, but we don't want to keep the data.
}
else if (datum == "user") {
// Bank information
this.user = data.user;
if (Database.connection) {
const nextUpdate = Database.nextUpdate.get(`${this.serverData.name}${this.serverData.region}*bank*`);
if (!nextUpdate || Date.now() >= nextUpdate) {
const updateData = {
lastUpdated: Date.now(),
owner: this.owner,
...data.user,
};
BankModel.updateOne({ owner: this.owner }, updateData, { upsert: true })
.lean()
.exec()
.catch(console.error);
Database.nextUpdate.set(`${this.serverData.name}${this.serverData.region}*bank*`, Date.now() + Constants.MONGO_UPDATE_MS);
}
}
// NOTE: When you have a "new" bank slot, the length of the array won't be the bank slot's size until you use the last slot.
// This block of code solves that issue.
for (const datum in this.bank) {
if (Array.isArray(this.bank[datum])) {
this.bank[datum].length = Constants.BANK_PACK_SIZE;
}
}
}
else {
// Normal attribute
this[datum] = data[datum];
}
}
// Update cooldowns if we have penalty_cd
if (data.s.penalty_cd) {
const withPenalty = Date.now() + data.s.penalty_cd.ms;
for (const [skillName, next] of this.nextSkill) {
const penaltyCooldown = new Date(Math.max(withPenalty, next.getTime()));
this.setNextSkill(skillName, penaltyCooldown);
}
}
// Keep name updated for those that prefer to use name instead of id.
this.name = data.id;
// Clear party info if we have no party
if (!data.party) {
this.party = undefined;
this.partyData = undefined;
}
// Set damage type if not set
if (!this.damage_type && this.ctype)
this.damage_type = this.G.classes[this.ctype].damage_type;
// Update database with latest information
if (!Database.connection)
return;
const nextUpdate = Database.nextUpdate.get(`${this.server.name}${this.server.region}${this.id}`);
if (!nextUpdate || Date.now() >= nextUpdate) {
const updateData = {
in: this.in,
items: this.items,
lastSeen: Date.now(),
map: this.map,
name: this.id,
party: this.party,
s: this.s,
serverIdentifier: this.serverData.name,
serverRegion: this.serverData.region,
"slots.amulet": this.slots.amulet,
"slots.belt": this.slots.belt,
"slots.cape": this.slots.cape,
"slots.chest": this.slots.chest,
"slots.earring1": this.slots.earring1,
"slots.earring2": this.slots.earring2,
"slots.elixir": this.slots.elixir,
"slots.gloves": this.slots.gloves,
"slots.helmet": this.slots.helmet,
"slots.mainhand": this.slots.mainhand,
"slots.offhand": this.slots.offhand,
"slots.orb": this.slots.orb,
"slots.pants": this.slots.pants,
"slots.ring1": this.slots.ring1,
"slots.ring2": this.slots.ring2,
"slots.shoes": this.slots.shoes,
"slots.trade1": this.slots.trade1,
"slots.trade2": this.slots.trade2,
"slots.trade3": this.slots.trade3,
"slots.trade4": this.slots.trade4,
type: this.ctype,
x: this.x,
y: this.y,
};
if (this.stand) {
updateData["slots.trade5"] = this.slots.trade5;
updateData["slots.trade6"] = this.slots.trade6;
updateData["slots.trade7"] = this.slots.trade7;
updateData["slots.trade8"] = this.slots.trade8;
updateData["slots.trade9"] = this.slots.trade9;
updateData["slots.trade10"] = this.slots.trade10;
updateData["slots.trade11"] = this.slots.trade11;
updateData["slots.trade12"] = this.slots.trade12;
updateData["slots.trade13"] = this.slots.trade13;
updateData["slots.trade14"] = this.slots.trade14;
updateData["slots.trade15"] = this.slots.trade15;
updateData["slots.trade16"] = this.slots.trade16;
updateData["slots.trade17"] = this.slots.trade17;
updateData["slots.trade18"] = this.slots.trade18;
updateData["slots.trade19"] = this.slots.trade19;
updateData["slots.trade20"] = this.slots.trade20;
updateData["slots.trade21"] = this.slots.trade21;
updateData["slots.trade22"] = this.slots.trade22;
updateData["slots.trade23"] = this.slots.trade23;
updateData["slots.trade24"] = this.slots.trade24;
updateData["slots.trade25"] = this.slots.trade25;
updateData["slots.trade26"] = this.slots.trade26;
updateData["slots.trade27"] = this.slots.trade27;
updateData["slots.trade28"] = this.slots.trade28;
updateData["slots.trade29"] = this.slots.trade29;
updateData["slots.trade30"] = this.slots.trade30;
}
if (this.owner)
updateData.owner = this.owner;
PlayerModel.updateOne({ name: this.id }, updateData, { upsert: true }).lean().exec().catch(console.error);
Database.nextUpdate.set(`${this.server.name}${this.server.region}${this.id}`, Date.now() + Constants.MONGO_UPDATE_MS);
}
}
parseEntities(data) {
// Update our party members data if we have some
if (this.party) {
for (let i = 0; i < data.players.length; i++) {
const player = data.players[i];
const partyPlayer = this.partyData?.party?.[player.id];
if (!partyPlayer)
continue; // Not in our party
// Update all the information we can
for (const key in partyPlayer)
if (player[key])
partyPlayer[key] = player[key];
}
}
// Look for ourself in the players, and parse it differently so we don't get it mixed up with the other players
for (let i = 0; i < data.players.length; i++) {
const player = data.players[i];
if (player.id == this.id) {
this.parseCharacter(player);
data.players.splice(i, 1);
break;
}
}
super.parseEntities(data);
}
async parseEval(data) {
if (await super.parseEval(data))
return true; // Handled in Observer
if (typeof data === "string")
return true; // UI
// Skill timeouts (like attack) are sent via eval
const skillReg1 = /^skill_timeout\s*\(\s*['"](.+?)['"]\s*,?\s*(\d+\.?\d+?)?\s*\)/.exec(data.code);
if (skillReg1) {
const skill = skillReg1[1];
let cooldown;
if (skillReg1[2]) {
cooldown = Number.parseFloat(skillReg1[2]);
}
else if (this.G.skills[skill].cooldown) {
cooldown = this.G.skills[skill].cooldown;
}
if (cooldown !== undefined) {
const next = new Date(Date.now() + Math.ceil(cooldown));
this.setNextSkill(skill, next);
}
return true;
}
// Potion timeouts are sent via eval
const potReg = /^pot_timeout\s*\(\s*(\d*\.?\d+)\s*\)/.exec(data.code);
if (potReg) {
const cooldown = Number.parseFloat(potReg[1]);
const next = new Date(Date.now() + Math.ceil(cooldown));
this.setNextSkill("regen_hp", next);
this.setNextSkill("regen_mp", next);
return true;
}
// Skills that move your character (e.g.: dash) are sent via eval
const uiMoveReg = /^ui_move\s*\(\s*(-?\d*\.{0,1}\d+)\s*,\s*(-?\d*\.{0,1}\d+)\s*\)/.exec(data.code);
if (uiMoveReg) {
const x = Number.parseFloat(uiMoveReg[1]);
const y = Number.parseFloat(uiMoveReg[2]);
this.x = x;
this.y = y;
return true;
}
// TODO: Handle pvp_timeout
const pvpTimeoutReg = /^pvp_timeout\s*\(\s*3600\s*,?\s*\d*\s*\)/.exec(data.code);
if (pvpTimeoutReg) {
return true; // do nothing because pvp_timeout is handled serverside
}
console.error(`Unhandled 'eval': ${JSON.stringify(data)}`);
return false;
}
parseGameResponse(data) {
// Adjust cooldowns
if (typeof data == "object") {
if (data.response == "cooldown") {
// A skill is on cooldown
const skill = data.skill ?? data.place;
if (skill) {
const cooldown = data.ms;
this.setNextSkill(skill, new Date(Date.now() + Math.ceil(cooldown)));
}
}
else if (Database.connection && data.response == "defeated_by_a_monster") {
DeathModel.insertMany([
{
cause: data.monster,
map: this.map,
name: this.id,
serverIdentifier: this.server.name,
serverRegion: this.server.region,
time: Date.now(),
x: this.x,
y: this.y,
},
]).catch(console.error);
}
else if (data.response == "ex_condition") {
// The condition expired
delete this.s[data.name];
}
else if (data.response == "skill_success") {
const cooldown = this.G.skills[data.name].cooldown;
if (cooldown) {
this.setNextSkill(data.name, new Date(Date.now() + cooldown));
}
}
}
else if (typeof data == "string") {
if (data == "resolve_skill") {
// Ignore. We resolve our skills a different way than the vanilla client
}
}
}
parseNewMap(data) {
this.going_x = data.x;
this.going_y = data.y;
this.m = data.m;
this.moving = false;
// If there's an eval attached, parse it.
if (data.eval)
this.parseEval({ code: data.eval });
super.parseNewMap(data);
}
parseQData(data) {
if (data.q?.upgrade)
this.q.upgrade = data.q.upgrade;
if (data.q?.compound)
this.q.compound = data.q.compound;
}
setNextSkill(skill, next) {
this.nextSkill.set(skill, next);
if (this.G.skills[skill].share)
this.nextSkill.set(this.G.skills[skill].share, next);
}
updatePositions() {
if (this.lastPositionUpdate) {
const msSinceLastUpdate = Date.now() - this.lastPositionUpdate;
if (msSinceLastUpdate == 0)
return;
// Update character
if (this.moving) {
const distanceTraveled = (this.speed * msSinceLastUpdate) / 1000;
const angle = Math.atan2(this.going_y - this.y, this.going_x - this.x);
const distanceToGoal = Tools.distance({ x: this.x, y: this.y }, { x: this.going_x, y: this.going_y });
if (distanceTraveled > distanceToGoal) {
this.moving = false;
this.x = this.going_x;
this.y = this.going_y;
}
else {
this.x = this.x + Math.cos(angle) * distanceTraveled;
this.y = this.y + Math.sin(angle) * distanceTraveled;
}
}
// Update conditions
for (const condition in this.s) {
const newCooldown = this.s[condition].ms - msSinceLastUpdate;
if (newCooldown <= 0)
delete this.s[condition];
else
this.s[condition].ms = newCooldown;
}
// Update processes
for (const process in this.q) {
const newCooldown = this.q[process].ms - msSinceLastUpdate;
if (newCooldown <= 0)
delete this.q[process];
else
this.q[process].ms = newCooldown;
}
// Update channels
for (const channel in this.c) {
const newCooldown = this.c[channel].ms - msSinceLastUpdate;
if (newCooldown <= 0)
delete this.c[channel];
else
this.c[channel].ms = newCooldown;
}
}
super.updatePositions();
}
/**
* TODO: Add fail check for logging in with too many characters to one server
*
* @return {*} {Promise<void>}
* @memberof Character
*/
async connect() {
if (this.noReconnect)
throw new Error("You can no longer reconnect with this character. Call `Game.startCharacter()` and make a new Character.");
await super.connect(false, false);
this.socket.on("disconnect", () => {
this.ready = false;
this.noReconnect = true;
});
this.socket.on("disconnect_reason", () => {
this.ready = false;
this.noReconnect = true;
});
this.socket.on("friend", (data) => {
if (data.event == "lost" || data.event == "new" || data.event == "update") {
this.friends = data.friends;
}
});
this.socket.on("start", (data) => {
this.going_x = data.x;
this.going_y = data.y;
this.moving = false;
this.damage_type = this.G.classes[data.ctype].damage_type;
this.parseCharacter(data);
if (data.entities)
this.parseEntities(data.entities);
this.S = data.s_info;
this.ready = true;
// Start the update loop
this.updateLoop().catch(console.error);
});
this.socket.on("achievement_progress", (data) => {
this.achievements.set(data.name, data);
});
this.socket.on("chest_opened", (data) => {
this.chests.delete(data.id);
});
this.socket.on("drop", (data) => {
this.chests.set(data.id, data);
});
this.socket.on("game_error", (data) => {
if (typeof data == "string") {
console.error(`Game Error: ${data}`);
}
else {
console.error("Game Error ----------");
console.error(data);
}
});
this.socket.on("game_response", (data) => {
this.parseGameResponse(data);
});
// The listener in Observer will deal with most of this, we just have to deal with our own character's hp here
this.socket.on("hit", (data) => {
if (data.miss || data.evade || data.reflect || data.id !== this.id) {
return;
}
if (data.kill) {
this.hp = 0;
this.rip = true;
}
else if (data.damage) {
this.hp -= data.damage;
}
});
// TODO: Confirm this works for leave_party(), too.
this.socket.on("party_update", (data) => {
this.partyData = data;
if (data && Database.connection) {
const playerUpdates = [];
for (const id in data.party) {
const cData = data.party[id];
const updateData = {
in: cData.in,
lastSeen: Date.now(),
map: cData.map,
name: id,
serverIdentifier: this.serverData.name,
serverRegion: this.serverData.region,
type: cData.type,
x: cData.x,
y: cData.y,
};
playerUpdates.push({
updateOne: {
filter: { name: id },
update: updateData,
upsert: true,
},
});
}
if (playerUpdates.length)
PlayerModel.bulkWrite(playerUpdates).catch(console.error);
}
});
this.socket.on("player", (data) => {
this.parseCharacter(data);
});
this.socket.on("q_data", (data) => {
this.parseQData(data);
});
if (Database.connection) {
this.socket.on("game_log", async (data) => {
if (typeof data !== "object")
return;
const result = /^Slain by (.+)$/.exec(data.message);
if (result) {
DeathModel.create({
cause: result[1],
map: this.map,
name: this.id,
serverIdentifier: this.server.name,
serverRegion: this.server.region,
time: Date.now(),
x: this.x,
y: this.y,
}).catch(console.error);
}
});
this.socket.on("tracker", async (data) => {
// The maximum only gets updated when we re-login, but our characters kills are continuously updated.
// If we have a higher kill count than what our max says, override it.
for (const monsterName in data.max.monsters) {
const characterKills = data.monsters[monsterName] ?? 0;
const maxData = data.max.monsters[monsterName];
if (characterKills > maxData[0]) {
maxData[0] = characterKills;
maxData[1] = this.id;
}
}
// Add tracker data to the database
await AchievementModel.create({
date: Date.now(),
max: data.max,
monsters: data.monsters,
name: this.id,
}).catch(console.error);
});
}
this.socket.on("skill_timeout", (data) => {
const next = new Date(Date.now() + Math.ceil(data.ms));
this.setNextSkill(data.name, next);
});
this.socket.on("upgrade", (data) => {
if (data.type == "compound" && this.q.compound)
delete this.q.compound;
// else if (data.type == "exchange" && this.q.exchange) delete this.q.exchange
else if (data.type == "upgrade" && this.q.upgrade)
delete this.q.upgrade;
});
this.socket.on("welcome", () => {
// Send a response that we're ready to go
this.socket.emit("loaded", {
height: 1080,
scale: 2,
success: 1,
width: 1920,
});
// When we're loaded, authenticate
this.socket.emit("auth", {
auth: this.userAuth,
character: this.characterID,
height: 1080,
no_graphics: "True",
no_html: "1",
passphrase: "",
scale: 2,
user: this.owner,
width: 1920,
});
});
const connected = new Promise((resolve, reject) => {
const cleanup = () => {
this.socket.off("start", startCheck);
this.socket.off("game_error", failCheck);
this.socket.off("disconnect_reason", failCheck2);
clearTimeout(timeout);
};
const failCheck = (data) => {
cleanup();
if (typeof data == "string") {
reject(new Error(`Failed to connect: ${data}`));
}
else {
reject(new Error(`Failed to connect: ${data.message}`));
}
};
const failCheck2 = (data) => {
cleanup();
reject(new Error(`Failed to connect: ${data}`));
};
const startCheck = () => {
cleanup();
resolve();
};
const timeout = setTimeout(() => {
cleanup();
reject(new Error(`Failed to start within ${Constants.CONNECT_TIMEOUT_MS / 1000}s.`));
}, Constants.CONNECT_TIMEOUT_MS);
this.socket.once("start", startCheck);
this.socket.once("game_error", failCheck);
this.socket.once("disconnect_reason", failCheck2);
});
this.socket.open();
return connected;
}
/**
* Disconnects your bot from the server, cancels all timeouts that have been registered, and turns off all socket listeners.
*/
disconnect() {
// Cancel all timeouts
for (const [, timer] of this.timeouts)
clearTimeout(timer);
this.ready = false;
this.noReconnect = true;
// Close & remove the socket
if (this.socket) {
this.socket.disconnect();
this.socket.off();
}
}
getTime(military) {
const date = new Date();
function getHours(d) {
const hours = d.getHours();
if (!military) {
if (hours > 12)
return (hours - 12).toString().padStart(2, "0");
if (hours == 0)
return "12";
}
return hours.toString().padStart(2, "0");
}
const h = getHours(date);
const m = date.getMinutes().toString().padStart(2, "0");
const s = date.getSeconds().toString().padStart(2, "0");
const ms = date.getMilliseconds().toString().padStart(3, "0");
return `${h}:${m}:${s}:${ms}`;
}
/**
* Logs a debug message to the console
*/
debug(msg, military = true) {
const message = `[${this.getTime(military)}, DEBUG, ${this.id}]: ${msg}`;
console.debug(message);
}
error(e, trace = true, military = true) {
const message = `[${this.getTime(military)}, ERROR, ${this.id}]: ${typeof e === "string" ? e : e.message}`;
console.error(message);
if (trace)
console.trace(e);
}
/**
* This function will request all nearby entities and players from the server. You can use it to make sure we have the latest data.
* NOTE: There is a rather high code call cost to this, don't call it too often.
*/
async requestEntitiesData() {
if (!this.ready)
throw new Error("We aren't ready yet [requestEntitiesData].");
return new Promise((resolve, reject) => {
const checkEntitiesEvent = (data) => {
if (data.type == "all") {
this.socket.off("entities", checkEntitiesEvent);
resolve(data);
}
};
setTimeout(() => {
this.socket.off("entities", checkEntitiesEvent);
reject(new Error(`requestEntitiesData timeout (${Constants.TIMEOUT}ms)`));
}, Constants.TIMEOUT);
this.socket.on("entities", checkEntitiesEvent);
this.socket.emit("send_updates", {});
});
}
/**
* This function is a hack to get the server to respond with a player data update. It will respond with two...
*/
async requestPlayerData() {
if (!this.ready)
throw new Error("We aren't ready yet [requestPlayerData].");
return new Promise((resolve, reject) => {
const checkPlayerEvent = (data) => {
if (data.s.typing) {
this.socket.off("player", checkPlayerEvent);
resolve(data);
}
};
setTimeout(() => {
this.socket.off("player", checkPlayerEvent);
reject(new Error(`requestPlayerData timeout (${Constants.TIMEOUT}ms)`));
}, Constants.TIMEOUT);
this.socket.on("player", checkPlayerEvent);
this.socket.emit("property", { typing: true });
});
}
/**
* Accepts a friend request.
*
* @param {string} id
* @return {*} {Promise<FriendData>}
* @memberof Character
*/
async acceptFriendRequest(id) {
if (!this.ready)
throw new Error("We aren't ready yet [acceptFriendRequest].");
const friended = new Promise((resolve, reject) => {
const successCheck = (data) => {
if (data.event == "new") {
this.socket.off("friend", successCheck);
this.socket.off("game_response", failCheck);
resolve(data);
}
};
const failCheck = (data) => {
if (typeof data == "string") {
if (data == "friend_expired") {
this.socket.off("friend", successCheck);
this.socket.off("game_response", failCheck);
reject(new Error("Friend request expired."));
}
}
};
setTimeout(() => {
this.socket.off("friend", successCheck);
this.socket.off("game_response", failCheck);
reject(new Error(`acceptFriendRequest timeout(${Constants.TIMEOUT}ms)`));
}, Constants.TIMEOUT);
this.socket.on("friend", successCheck);
this.socket.on("game_response", failCheck);
});
this.socket.emit("friend", { event: "accept", name: id });
return friended;
}
/**
* Accepts a magiport request from another character
* @param name ID of the character that offered a magiport.
*/
async acceptMagiport(name) {
if (!this.ready)
throw new Error("We aren't ready yet [acceptMagiport].");
const acceptedMagiport = new Promise((resolve, reject) => {
const magiportCheck = (data) => {
if (data.effect == "magiport") {
this.socket.off("new_map", magiportCheck);
resolve({ map: data.name, x: data.x, y: data.y });
}
};
setTimeout(() => {
this.socket.off("new_map", magiportCheck);
reject(new Error(`acceptMagiport timeout (${Constants.TIMEOUT}ms)`));
}, Constants.TIMEOUT);
this.socket.on("new_map", magiportCheck);
});
this.socket.emit("magiport", { name: name });
return acceptedMagiport;
}
/**
* Accepts another character's party invite.
* @param id The ID of the character's party you want to accept the invite for.
*/
async acceptPartyInvite(id) {
if (!this.ready)
throw new Error("We aren't ready yet [acceptPartyInvite].");
const acceptedInvite = new Promise((resolve, reject) => {
const partyCheck = (data) => {
if (data.list && data.list.includes(this.id) && data.list.includes(id)) {
this.socket.off("party_update", partyCheck);
this.socket.off("game_log", unableCheck);
resolve(data);
}
};
const unableCheck = (data) => {
if (data == "Invitation expired") {
this.socket.off("party_update", partyCheck);
this.socket.off("game_log", unableCheck);
reject(new Error(data));
}
else if (typeof data == "string" && /^.+? is not found$/.test(data)) {
this.socket.off("party_update", partyCheck);
this.socket.off("game_log", unableCheck);
reject(new Error(data));
}
else if (data == "Already partying") {
if (this.partyData.list.includes(this.id) && this.partyData.list.includes(id)) {
// NOTE: We resolve the promise even if we have already accepted it if we're in the correct party.
this.socket.off("party_update", partyCheck);
this.socket.off("game_log", unableCheck);
resolve(this.partyData);
}
else {
this.socket.off("party_update", partyCheck);
this.socket.off("game_log", unableCheck);
reject(new Error(data));
}
}
};
setTimeout(() => {
this.socket.off("party_update", partyCheck);
this.socket.off("game_log", unableCheck);
reject(new Error(`acceptPartyInvite timeout (${Constants.TIMEOUT}ms)`));
}, Constants.TIMEOUT);
this.socket.on("party_update", partyCheck);
this.socket.on("game_log", unableCheck);
});
this.socket.emit("party", { event: "accept", name: id });
return acceptedInvite;
}
// TODO: Add failure checks
async acceptPartyRequest(id) {
if (!this.ready)
throw new Error("We aren't ready yet [acceptPartyRequest].");
const acceptedRequest = new Promise((resolve, reject) => {
const partyCheck = (data) => {
if (data.list && data.list.includes(this.id) && data.list.includes(id)) {
this.socket.off("party_update", partyCheck);
resolve(data);
}
};
setTimeout(() => {
this.socket.off("party_update", partyCheck);
reject(new Error(`acceptPartyRequest timeout (${Constants.TIMEOUT}ms)`));
}, Constants.TIMEOUT);
this.socket.on("party_update", partyCheck);
});
this.socket.emit("party", { event: "raccept", name: id });
return acceptedRequest;
}
/**
* Performs a basic attack on the given target.
*
* NOTE: We can't name this function `attack` because of the property `attack` that tells us how much damage we do.
* @param id The ID of the entity or player to attack
*/
async basicAttack(id) {
if (!this.ready)
throw new Error("We aren't ready yet [basicAttack].");
const response = this.getResponsePromise("attack");
this.socket.emit("attack", { id: id });
return response;
}
async buy(itemName, quantity = 1) {
if (!this.ready)
throw new Error("We aren't ready yet [buy].");
const gData = this.G.items[itemName];
if (!gData.s && quantity !== 1) {
console.warn(`${itemName} is not stackable, we will only buy 1, (${quantity} requested).`);
quantity = 1;
}
else if (quantity < 1) {
console.warn(`Non-positive quantity (${quantity}) specified, not buying ${itemName}.`);
return; // We aren't buying any
}
const cost = gData.g * (gData.markup ?? 1) * quantity;
if (this.gold < cost)
throw new Error(`Insufficient gold. We only have ${this.gold}, but ${quantity}x${itemName} costs ${cost}.`);
const response = this.getResponsePromise("buy", "buy_success", {
extraGameResponseCheck: (data) => {
if (data.name !== itemName)
return false;
if (data.q !== quantity)
return false;
return true;
},
});
this.socket.emit("buy", { name: itemName, quantity: quantity });
const data = await response;
return data.num;
}
/**
* Buy an item from an NPC (e.g. monsterhunter) with tokens
* @param itemName The item you wish to purchase with tokens
* @param useToken If set, we will only use this token to purchase the item, otherwise we will use any available token.
*/
async buyWithTokens(itemName, useToken) {
const numBefore = this.countItem(itemName);
const tokenInfo = {
friendtoken: {
error: null,
npcLocs: Pathfinder.locateNPC("friendtokens"),
},
funtoken: {
error: null,
npcLocs: Pathfinder.locateNPC("funtokens"),
},
monstertoken: {
error: null,
npcLocs: Pathfinder.locateNPC("monsterhunter"),
},
pvptoken: {
error: null,
npcLocs: Pathfinder.locateNPC("pvptokens"),
},
};
// Check if we can purchase the items with tokens
for (const t in this.G.tokens) {
const tokenType = t;
if (useToken && tokenType !== useToken)
continue; // We don't want to use this token
const gToken = this.G.tokens[tokenType];
if (!this.hasItem(["computer", "supercomputer"])) {
// Check if we're nearby the token exchange NPC
const inRange = tokenInfo[tokenType].npcLocs.some((npcLoc) => {
return Tools.distance(this, npcLoc) <= Constants.NPC_INTERACTION_DISTANCE;
});
if (!inRange) {
tokenInfo[tokenType].error = new Error(`We are too far away from the ${tokenType} npc to purchase anything.`);
continue;
}
}
if (gToken[itemName] !== undefined) {
// Check if we have enough tokens
const numTokensNeeded = gToken[itemName];
const numTokens = this.countItem(tokenType);
if (numTokens < numTokensNeeded) {
tokenInfo[tokenType].error = new Error(`We need ${numTokensNeeded} of ${tokenType} to buy ${itemName}, but we only have ${numTokens}`);
continue;
}
// We can afford it!
useToken = tokenType;
break;
}
tokenInfo[tokenType].error = new Error(`${itemName} is not purchaseable with ${tokenType}`);
}
if (useToken && tokenInfo[useToken].error) {
throw tokenInfo[useToken].error;
}
else if (!useToken) {
throw new AggregateError([tokenInfo.monstertoken.error, tokenInfo.funtoken.error, tokenInfo.pvptoken.error]);
}
const itemBought = new Promise((resolve, reject) => {
const cleanup = () => {
this.socket.off("player", playerCheck);
this.socket.off("game_response", gameResponseCheck);
clearTimeout(timeout);
};
const playerCheck = (data) => {
const numNow = this.countItem(itemName, data.items);
if (numNow > numBefore) {
cleanup();
resolve();
}
};
const gameResponseCheck = (data) => {
if (typeof data == "object") {
if (data.response == "exchange_notenough") {
cleanup();
reject(new Error(`Not enough tokens to buy ${itemName}.`));
}
}
};
const timeout = setTimeout(() => {
cleanup();
reject(new Error(`buyWithTokens timeout (${Constants.TIMEOUT}ms)`));
}, Constants.TIMEOUT);
this.socket.on("player", playerCheck);
this.socket.on("game_response", gameResponseCheck);
});
const invTokens = this.locateItem(useToken);
this.socket.emit("exchange_buy", { name: itemName, num: invTokens, q: this.items[invTokens].q });
return itemBought;
}
async buyFromMerchant(id, slot, rid, quantity = 1) {
if (!this.ready)
throw new Error("We aren't ready yet [buyFromMerchant].");
if (quantity <= 0)
throw new Error(`We can not buy a quantity of ${quantity}.`);
const merchant = this.players.get(id);
if (!merchant)
throw new Error(`We can not see ${id} nearby.`);
if (Tools.squaredDistance(this, merchant) > Constants.NPC_INTERACTION_DISTANCE_SQUARED)
throw new Error(`We are too far away from ${id} to buy from.`);
const item = merchant.slots[slot];
if (!item)
throw new Error(`We could not find an item in slot ${slot} on ${id}.`);
if (item.b)
throw new Error("The item is not for sale, this merchant is *buying* that item.");
if (item.rid !== rid)
throw new Error(`The RIDs do not match (item: ${item.rid}, supplied: ${rid})`);
if (!merchant.slots[slot].q && quantity != 1) {
console.warn("We are only going to buy 1, as there is only 1 available.");
quantity = 1;
}
else if (merchant.slots[slot].q && quantity > merchant.slots[slot].q) {
console.warn(`We can't buy ${quantity}, we can only buy ${merchant.slots[slot].q}, so we're doing that.`);
quantity = merchant.slots[slot].q;
}
if (this.gold < merchant.slots[slot].price * quantity) {
if (this.gold < merchant.slots[slot].price)
throw new Error(`We don't have enough gold. It costs ${merchant.slots[slot].price}, but we only have ${this.gold}`);
// Determine how many we *can* buy.
const buyableQuantity = Math.floor(this.gold / merchant.slots[slot].price);
console.warn(`We don't have enough gold to buy ${quantity}, we can only buy ${buyableQuantity}, so we're doing that.`);
quantity = buyableQuantity;
}
const itemBought = new Promise((resolve, reject) => {
const cleanup = () => {
this.socket.off("ui", buyCheck);
clearTimeout(timeout);
};
const buyCheck = (data) => {
if (data.type == "+$$" && data.seller == id && data.buyer == this.id && data.slot == slot) {
cleanup();
resolve(data.item);
}
};
const timeout = setTimeout(() => {
cleanup();
reject(new Error(`buy timeout (${Constants.TIMEOUT}ms)`));
}, Constants.TIMEOUT);
this.socket.on("ui", buyCheck);
});
this.socket.emit("trade_buy", { id: id, q: quantity.toString(), rid: rid, slot: slot });
return itemBought;
}
/**
* Buys an item from Ponty. Get items from `getPontyItems()`
*
* @param {ItemDataTrade} item
* @return {*} {Promise<void>}
* @memberof Character
*/
async buyFromPonty(item) {
if (!this.ready)
throw new Error("We aren't ready yet [buyFromPonty].");
if (!item.rid)
throw new Error("This item does not have an 'rid'.");
const price = this.G.items[item.name].g * Constants.PONTY_MARKUP * (item.q ? item.q : 1);
if (price > this.gold)
throw new Error(`We don't have enough gold to buy ${item.name} from Ponty.`);
if (this.esize === 0 && !item.q)
throw new Error("We have no space to buy an item from Ponty.");
const numBefore = this.countItem(item.name, this.items);
const bought = new Promise((resolve, reject) => {
const cleanup = () => {
this.socket.off("game_log", failCheck);
this.socket.off("game_response", failCheck2);
this.socket.off("player", successCheck);
clearTimeout(timeout);
};
const failCheck = (message) => {
if (message == "Item gone") {
cleanup();
reject(new Error(`${item.name} is no longer available from Ponty.`));
}
};
const failCheck2 = (message) => {
if (typeof message == "string") {
if (message == "buy_cost") {
cleanup();
reject(new Error(`We don't have enough money to buy ${item.name} from Ponty.`));
}
}
};
const successCheck = (data) => {
const numNow = this.countItem(item.name, data.items);
if ((item.q && numNow == numBefore + item.q) || numNow == numBefore + 1) {
cleanup();
resolve();
}
};
const timeout = setTimeout(() => {
cleanup();
reject(new Error("buyFromPonty timeout (5000ms)"));
}, 5000);
this.socket.on("game_log", failCheck);
this.socket.on("game_response", failCheck2);
this.socket.on("player", successCheck);
});
this.socket.emit("sbuy", { rid: item.rid });
return bought;
}
/**
* Calculates the type of targets attacking you.
*
* The first element is the current number of targets of the given damage type.
*
* The second element is our character's courage
*
* @return {*} {{
* magical: [number, number];
* physical: [number, number];
* pure: [number, number];
* }}
* @memberof Character
*/
calculateTargets() {
const targets = {
magical: 0,
physical: 0,
pure: 0,
};
for (const entity of this.getEntities({
targetingMe: true,
})) {
switch (entity.damage_type) {
case "magical":
targets.magical += 1;
break;
case "physical":
targets.physical += 1;
break;
case "pure":
targets.pure += 1;
break;
}
}
if (targets.magical + targets.physical + targets.pure < this.targets) {
// Something else is targeting us, assume the worst
const difference = this.targets - (targets.magical + targets.physical + targets.pure);
targets.magical += difference;
targets.physical += difference;
targets.pure += difference;
}
// TODO: We can probably use `this.fear` and `this.courage`/`this.mcourage`/`this.pcourage`
// If we're not feared, the `targets.X` count is guaranteed to be less than our courage
return targets;
}
/**
* Calculates the compound chance for the given item using the given scroll and offering.
*
* You need to be near the upgrade NPC, or have a computer, in order to calculate the compound chance.
*/
async calculateCompound(item1Pos, item2Pos, item3Pos, cscrollPos, offeringPos) {
if (!this.ready)
throw new Error("We aren't ready yet [compound].");
const item1Info = this.items[item1Pos];
const item2Info = this.items[item2Pos];
const item3Info = this.items[item3Pos];
const cscrollInfo = this.items[cscrollPos];
if (!item1Info)
throw new Error(`There is no item in inventory slot ${item1Pos} (item1).`);
if (!item2Info)
throw new Error(`There is no item in inventory slot ${item2Pos} (item2).`);
if (!item3Info)
throw new Error(`There is no item in inventory slot ${item3Pos} (item3).`);
if (!cscrollInfo)
throw new Error(`There is no item in inventory slot ${cscrollPos} (cscroll).`);
if (offeringPos !== undefined) {
const offeringInfo = this.items[o