UNPKG

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
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