import * as express from "express"; import { RowDataPacket } from "mysql2/promise"; import { Armory } from "../Armory"; import { IRealmConfig } from "../Config"; import { IEmblem, Utils } from "../Utils"; import { IAchievement as IAchievementDbc, ISkillDbc, IAreas } from "../data/DbcReader"; interface ICharacterData { guid: number; name: string; race: number; class: number; gender: number; level: number; skin: number; face: number; hairStyle: number; hairColor: number; facialStyle: number; playerFlags: number; online: number; guild: string; } interface IEquipmentData { slot: number; itemEntry: number; flags: number; enchantments: string | number[]; randomPropertyId: number; classId: number; subclassId: number; quality: number; transmog?: number; icon?: number; gems?: number[]; } interface ICustomizationOption { optionId: number; choiceId: number; } interface IMount { creatureDisplayId: number; spell: number; icon: string; } interface IAchievement { id: number; category: number; title: string; description: string; points: number; icon: string; } interface IArenaTeam { id: number; name: string; type: number; rating: number; seasonWins: number; seasonGames: number; background: number; emblemStyle: number; emblemColor: number; borderStyle: number; borderColor: number; emblem?: IEmblem; } interface ISkills { id: number; categoryId: number skill: string; value: number; max: number ; } interface IReputation { id: number; name: string; standing: string; value: number; valueInGrade: number; max: number; expansionId: number; } interface IQuest { id: number; title: string; status: 'Completed' | 'In Progress'; minLevel: number; questLevel: number; questSortID: number; } const ItemClassGem = 3; const SpellMechanicMounted = 21; const RaceDisplayName = { 1: "Human", 2: "Orc", 3: "Dwarf", 4: "Night Elf", 5: "Undead", 6: "Tauren", 7: "Gnome", 8: "Troll", 10: "Blood Elf", 11: "Draenei", }; const ClassDisplayName = { 1: "Warrior", 2: "Paladin", 3: "Hunter", 4: "Rogue", 5: "Priest", 6: "Death Knight", 7: "Shaman", 8: "Mage", 9: "Warlock", 11: "Druid", }; export class CharacterController { private armory: Armory; private areaById: { [key: number]: IAreas }; private itemInventoryTypes: { [key: number]: number }; private itemIcons: { [key: number]: number }; private gemItems: { [key: number]: boolean }; private enchantSrcItems: { [key: number]: number }; private itemSocketBonuses: { [key: number]: number }; private mountSpells: number[]; private mountBySpellId: { [key: number]: IMount }; private skillById: { [key: number]: ISkillDbc }; private achievementById: { [key: number]: IAchievementDbc }; public constructor(armory: Armory) { this.armory = armory; } public async load(): Promise { this.itemInventoryTypes = {}; const itemsRetail = await this.armory.dbc.itemRetail().toArray(); for await (const item of this.armory.dbc.item()) { const retailItem = itemsRetail.find((row) => row.id === item.id); if (retailItem !== undefined) { this.itemInventoryTypes[item.id] = retailItem.inventoryType; } } this.itemIcons = {}; const itemIconsByDisplayInfoId: { [key: number]: number } = {}; for await (const row of this.armory.dbc.itemDisplayInfo()) { itemIconsByDisplayInfoId[row.id] = row.inventoryIcon0; } for await (const item of this.armory.dbc.item()) { const icon = itemIconsByDisplayInfoId[item.displayInfoId]; if (icon !== undefined) { this.itemIcons[item.id] = icon; } } this.gemItems = {}; for await (const row of this.armory.dbc.item().filter((item) => item.classId === ItemClassGem)) { this.gemItems[row.id] = true; } this.enchantSrcItems = {}; for await (const row of this.armory.dbc.spellItemEnchantment()) { this.enchantSrcItems[row.id] = row.srcItemId; } this.itemSocketBonuses = {}; const [rows] = await this.armory.worldDb.query({ sql: "SELECT entry, socketBonus FROM item_template WHERE socketBonus <> 0", timeout: this.armory.config.dbQueryTimeout, }); for (const row of rows as RowDataPacket[]) { this.itemSocketBonuses[row.entry] = row.socketBonus; } const mountSpells = await this.armory.dbc .spell() .filter((m) => m.mechanic === SpellMechanicMounted) .toArray(); this.mountSpells = mountSpells.map((spell) => spell.id); this.mountBySpellId = {}; for (const spell of mountSpells) { const mount = await this.armory.dbc.mount().find((m) => m.sourceSpellId === spell.id); const icon = await this.armory.dbc.spellIcon().find((icon) => icon.id === spell.spellIconId); if (mount !== undefined) { const display = await this.armory.dbc.mountDisplay().find((d) => d.mountId === mount.id); if (display !== undefined) { this.mountBySpellId[spell.id] = { creatureDisplayId: display.creatureDisplayInfoId, spell: spell.id, icon: this.processSpellIconTexture(icon?.textureFilename ?? ""), }; } } } this.achievementById = {}; for await (const achievement of this.armory.dbc.achievement()) { this.achievementById[achievement.id] = achievement; } this.skillById = {}; for await (const skill of this.armory.dbc.skill()) { this.skillById[skill.id] = skill; } this.areaById = {}; for await (const area of this.armory.dbc.areas()) { this.areaById[area.id] = area; } } public async character(req: express.Request, res: express.Response, next: express.NextFunction): Promise { const realmName = req.params.realm; const charName = req.params.name; const realm = this.armory.getRealm(realmName); if (realm === undefined) { // Could not find realm return next(404); } const charData = await this.getCharacterData(realm, charName); if (charData === null) { // Could not find character return next(404); } const equipmentData = await this.getEquipmentData(realmName, charData.guid); const customization = this.getCustomizationOptions(charData); const equipment = equipmentData.map((row) => { row.icon = this.itemIcons[row.itemEntry]; row.gems = this.getGemsFromEnchantments(row.enchantments as string); row.enchantments = this.filterEnchantments(row.itemEntry, row.enchantments as string); return row; }); const mounts = await this.getMounts(realmName, charData.guid); const transmogs: number[][] = this.armory.config.transmogModule ? [] : undefined; const characterModelItems = await this.getModelViewerItems(equipmentData, charData.class, transmogs); res.render("character.hbs", { title: `Armory - ${charData.name}`, ...this.makeSharedDataObject(realm, charData), contentPath: this.armory.config.useZamCdn ? "https://wow.zamimg.com/modelviewer/live/" : this.armory.config.websiteRoot + "/data/", data: { race: charData.race, gender: charData.gender, class: charData.class, flags: charData.playerFlags, characterModelItems, characterModelTransmogs: transmogs, customizationOptions: customization, equipment, mounts, }, }); this.armory.gc(); } public async talents(req: express.Request, res: express.Response, next: express.NextFunction): Promise { const realmName = req.params.realm; const charName = req.params.name; const realm = this.armory.getRealm(realmName); if (realm === undefined) { // Could not find realm return next(404); } const charData = await this.getCharacterData(realm, charName); if (charData === null) { // Could not find character return next(404); } res.render("character-talents.hbs", { title: `Armory - ${charData.name} - Talents`, ...this.makeSharedDataObject(realm, charData), data: { talents: await this.getTalents(realm.name, charData.guid), trees: await this.getTalentTrees(charData.class), glyphs: await this.getGlyphs(realm.name, charData.guid), }, }); } public async skills(req: express.Request, res: express.Response, next: express.NextFunction): Promise { const realmName = req.params.realm; const charName = req.params.name; const realm = this.armory.getRealm(realmName); if (realm === undefined) { // Could not find realm return next(404); } const charData = await this.getCharacterData(realm, charName); if (charData === null) { // Could not find character return next(404); } const skills = await this.getSkills(realm.name, charData.guid); const professions = skills.filter((skill) => skill.categoryId === 11); const secondarySkills = skills.filter((skill) => skill.categoryId === 9); const weaponSkills = skills.filter((skill) => skill.categoryId === 6); const classSkills = skills.filter((skill) => skill.categoryId === 7); const armorSkills = skills.filter((skill) => skill.categoryId === 8); const languages = skills.filter((skill) => skill.categoryId === 10); res.render("character-skills.hbs", { title: `Armory - ${charData.name} - Skills`, ...this.makeSharedDataObject(realm, charData), data: { skills: skills, professions: professions, secondarySkills: secondarySkills, weaponSkills: weaponSkills, classSkills: classSkills, armorSkills: armorSkills, languages: languages, }, }); } public async reputation(req: express.Request, res: express.Response, next: express.NextFunction): Promise { const realmName = req.params.realm; const charName = req.params.name; const realm = this.armory.getRealm(realmName); if (realm === undefined) { return next(404); } const charData = await this.getCharacterData(realm, charName); if (charData === null) { return next(404); } const reputations = await this.getReputations(realm.name, charData.guid); // Group reputations by expansion/category const classicReps = reputations.filter(rep => rep.expansionId === 0); const tbcReps = reputations.filter(rep => rep.expansionId === 1); const wotlkReps = reputations.filter(rep => rep.expansionId === 2); res.render("character-reputation.hbs", { title: `Armory - ${charData.name} - Reputation`, ...this.makeSharedDataObject(realm, charData), data: { classic: classicReps, tbc: tbcReps, wotlk: wotlkReps }, }); } public async quests(req: express.Request, res: express.Response, next: express.NextFunction): Promise { const realmName = req.params.realm; const charName = req.params.name; const realm = this.armory.getRealm(realmName); if (realm === undefined) { return next(404); } const charData = await this.getCharacterData(realm, charName); if (charData === null) { return next(404); } const quests = await this.getQuests(realm.name, charData.guid); // Group quests by zone/profession const questsByCategory = quests.reduce((acc, quest) => { const category = quest.questSortID > 0 ? this.getZoneName(quest.questSortID): this.getProfessionName(quest.questSortID); if (!acc[category]) { acc[category] = []; } acc[category].push(quest); return acc; }, {}); // Get list of all characters const allCharacters = await this.getAllCharacters(realm.name); res.render("character-quests.hbs", { title: `Armory - ${charData.name} - Quests`, ...this.makeSharedDataObject(realm, charData), data: { categories: questsByCategory, otherCharacters: allCharacters.filter(c => c.guid !== charData.guid) }, }); } public async questsCompare(req: express.Request, res: express.Response, next: express.NextFunction): Promise { const realmName = req.params.realm; const charName = req.params.name; const otherRealmName = req.params.otherRealm; const otherCharName = req.params.otherName; const realm = this.armory.getRealm(realmName); const otherRealm = this.armory.getRealm(otherRealmName); if (realm === undefined || otherRealm === undefined) { return next(404); } const charData = await this.getCharacterData(realm, charName); const otherCharData = await this.getCharacterData(otherRealm, otherCharName); if (charData === null || otherCharData === null) { return next(404); } const charQuests = await this.getQuests(realm.name, charData.guid); const otherQuests = await this.getQuests(otherRealm.name, otherCharData.guid); // Group quests by category const categories: { [key: string]: any[] } = {}; const addQuestsToCategories = (quests: any[], source: string) => { quests.forEach(quest => { const category = quest.questSortID > 0 ? this.getZoneName(quest.questSortID) : this.getProfessionName(quest.questSortID); if (!categories[category]) { categories[category] = []; } const existingQuest = categories[category].find(q => q.id === quest.id); if (existingQuest) { existingQuest[source] = quest.status; } else { categories[category].push({ id: quest.id, title: quest.title, questLevel: quest.questLevel, [source]: quest.status, }); } }); }; addQuestsToCategories(charQuests, 'char1Status'); addQuestsToCategories(otherQuests, 'char2Status'); // Get list of all characters const allCharacters = await this.getAllCharacters(realm.name); res.render("character-quests-compare.hbs", { title: `Armory - Quest Compare - ${charData.name} vs ${otherCharData.name}`, ...this.makeSharedDataObject(realm, charData), data: { categories: categories, char1: { name: charData.name, realm: realm.name }, char2: { name: otherCharData.name, realm: otherRealm.name }, otherCharacters: allCharacters.filter(c => c.guid !== charData.guid) }, }); } private async getReputations(realm: string, character: number): Promise { const [rows] = await this.armory.getCharactersDb(realm).query({ sql: ` SELECT faction, standing, flags FROM character_reputation WHERE guid = ? AND flags NOT IN (0, 4, 8) AND standing != 0 `, values: [character], timeout: this.armory.config.dbQueryTimeout, }); const reputations = []; for (const row of rows as RowDataPacket[]) { const factionInfo = await this.armory.dbc.faction().find(f => f.id === row.faction); if (factionInfo && factionInfo.reputationId >= 0) { reputations.push({ id: row.faction, name: factionInfo.name, standing: this.getReputationStanding(row.standing), value: row.standing, valueInGrade: this.getReputationInGrade(row.standing), max: this.getReputationMax(row.standing), expansionId: this.getExpansionId(row.faction) }); } } return reputations; } private getReputationStanding(value: number): string { if (value < -6000) return 'Hated'; // These are probably not correct, but I don't have a character to test against if (value < -3000) return 'Hostile'; // These are probably not correct, but I don't have a character to test against if (value < 0) return 'Unfriendly'; // These are probably not correct, but I don't have a character to test against if (value < 1000) return 'Neutral'; // These are probably not correct, but I don't have a character to test against if (value < 6000) return 'Friendly'; if (value < 19000) return 'Honored'; if (value < 40000) return 'Revered'; return 'Exalted'; // These are probably not correct, but I don't have a character to test against } private getReputationMax(value: number): number { if (value < -6000) return -6000; // These are probably not correct, but I don't have a character to test against if (value < -3000) return -3000; // These are probably not correct, but I don't have a character to test against if (value < 0) return 0; // These are probably not correct, but I don't have a character to test against if (value < 1000) return 3000; if (value < 6000) return 6000; if (value < 12000) return 12000; if (value < 21000) return 21000; return 40000; // This might not be right, but I dont have an exalted character to test against } private getReputationInGrade(value: number): number { if (value < -6000) return value + 3000; // These are probably not correct, but I don't have a character to test against if (value < -3000) return value; // These are probably not correct, but I don't have a character to test against if (value < 0) return value; // These are probably not correct, but I don't have a character to test against if (value < 1000) return value + 2000; // These are probably not correct, but I don't have a character to test against if (value < 6000) return value - 1000; if (value < 12000) return value - 5900; // For some reason I needed to remove an extra 100 from this one to get it to match the client if (value < 21000) return value - 16900; // For some reason I needed to remove an extra 100 from this one to get it to match the client return value - 21100; // This might not be right, but I dont have an exalted character to test against } private getExpansionId(factionId: number): number { // Classic factions if (factionId < 900) return 0; // TBC factions if (factionId < 1100) return 1; // WotLK factions return 2; } private async getQuests(realm: string, character: number): Promise { // Get completed and rewarded quests const [completedRows] = await this.armory.getCharactersDb(realm).query({ sql: ` SELECT quest FROM ( SELECT quest FROM character_queststatus WHERE guid = ? AND status = 1 UNION SELECT quest FROM character_queststatus_rewarded WHERE guid = ? ) AS completed_quests `, values: [character, character], timeout: this.armory.config.dbQueryTimeout, }); // Get in progress quests const [inProgressRows] = await this.armory.getCharactersDb(realm).query({ sql: ` SELECT quest FROM character_queststatus WHERE guid = ? AND status = 3 `, values: [character], timeout: this.armory.config.dbQueryTimeout, }); const quests: IQuest[] = []; // Process completed quests for (const row of completedRows as RowDataPacket[]) { const questInfo = await this.getQuestInfo(row.quest); if (questInfo) { quests.push({ id: row.quest, title: questInfo.title, status: 'Completed', minLevel: questInfo.minLevel, questLevel: questInfo.questLevel, questSortID: questInfo.questSortID }); } } // Process in progress quests for (const row of inProgressRows as RowDataPacket[]) { const questInfo = await this.getQuestInfo(row.quest); if (questInfo) { quests.push({ id: row.quest, title: questInfo.title, status: 'In Progress', minLevel: questInfo.minLevel, questLevel: questInfo.questLevel, questSortID: questInfo.questSortID }); } } return quests; } private async getQuestInfo(questId: number): Promise { const [rows] = await this.armory.worldDb.query({ sql: ` SELECT ID, LogTitle as title, MinLevel as minLevel, QuestLevel as questLevel, QuestSortID as questSortID FROM quest_template WHERE ID = ? `, values: [questId], timeout: this.armory.config.dbQueryTimeout, }); return rows[0]; } private getZoneName(zoneId: number): string { this.areaById[zoneId]?.zoneName; return this.areaById[zoneId]?.zoneName || `Zone ${zoneId}`; } private getProfessionName(professionId: number): string { const questTypes = { // Negative IDs (Classes and Professions) // Classes "-61": "Warlock", "-81": "Warrior", "-82": "Shaman", "-141": "Paladin", "-161": "Mage", "-162": "Rogue", "-261": "Hunter", "-262": "Priest", "-263": "Druid", "-372": "Death Knight", // Professions "-24": "Herbalism", "-101": "Fishing", "-121": "Blacksmithing", "-181": "Alchemy", "-182": "Leatherworking", "-201": "Engineering", "-264": "Tailoring", "-304": "Cooking", "-324": "First Aid", "-371": "Inscription", "-373": "Jewelcrafting", "-762": "Riding", // Misc "-1": "Epic", "-21": "Wailing Caverns", "-22": "Seasonal", "-23": "Undercity", "-25": "Battlegrounds", "-41": "Uldaman", "-221": "Treasure Map", "-241": "Tournament", "-284": "Special", "-344": "Legendary", "-364": "Darkmoon Faire", "-365": "Ahn'Qiraj War", "-366": "Lunar Festival", "-367": "Reputation", "-368": "Invasion", "-369": "Midsummer", "-370": "Brewfest", "-374": "Noblegarden", "-375": "Pilgrim's Bounty", "-376": "Love is in the Air" }; return questTypes[professionId] || `Category ${professionId}`; } private getQuestExpansionId(questLevel: number): number { if (questLevel <= 60) return 0; // Classic if (questLevel <= 70) return 1; // TBC return 2; // WotLK } public async achievements(req: express.Request, res: express.Response, next: express.NextFunction): Promise { const realmName = req.params.realm; const charName = req.params.name; const realm = this.armory.getRealm(realmName); if (realm === undefined) { // Could not find realm return next(404); } const charData = await this.getCharacterData(realm, charName); if (charData === null) { // Could not find character return next(404); } res.render("character-achievements.hbs", { title: `Armory - ${charData.name} - Achievements`, ...this.makeSharedDataObject(realm, charData), }); } public async achievementsData(req: express.Request, res: express.Response, next: express.NextFunction): Promise { const realmName = req.params.realm; const character = parseInt(req.params.character) || -1; const realm = this.armory.getRealm(realmName); if (realm === undefined) { // Could not find realm return next(404); } const charData = await this.getCharacterData(realm, character); if (charData === null) { // Could not find character return next(404); } res.json({ categories: await this.armory.dbc.achievementCategory().toArray(), ...(await this.getAchievements(realm.name, charData)), }); } public async pvp(req: express.Request, res: express.Response, next: express.NextFunction): Promise { const realmName = req.params.realm; const charName = req.params.name; const realm = this.armory.getRealm(realmName); if (realm === undefined) { // Could not find realm return next(404); } const charData = await this.getCharacterData(realm, charName); if (charData === null) { // Could not find character return next(404); } res.render("character-pvp.hbs", { title: `Armory - ${charData.name} - PvP`, realm: realm.name, ...this.makeSharedDataObject(realm, charData), faction: Utils.getFactionFromRaceId(charData.race), kills: await this.getPvpKills(realm.name, charData.guid), arenaTeams: await this.getArenaTeams(realm.name, charData.guid), }); } private makeSharedDataObject(realm: IRealmConfig, charData: ICharacterData) { return { realm: realm.name, name: charData.name, guid: charData.guid, race: RaceDisplayName[charData.race], class: ClassDisplayName[charData.class], level: charData.level, online: charData.online === 1, guild: charData.guild, }; } private async getCharacterData(realm: IRealmConfig, character: string | number): Promise { const where = typeof character === "string" ? "LOWER(`characters`.`name`) = LOWER(?)" : "`characters`.`guid` = ?"; const [rows] = await this.armory.getCharactersDb(realm.name).query({ sql: ` SELECT \`characters\`.\`guid\`, \`characters\`.\`name\`, \`race\`, \`class\`, \`gender\`, \`level\`, \`skin\`, \`face\`, \`hairStyle\`, \`hairColor\`, \`facialStyle\`, \`playerFlags\`, \`online\`, \`guild\`.\`name\` AS \`guild\` FROM \`characters\` LEFT JOIN \`guild_member\` ON \`guild_member\`.\`guid\` = \`characters\`.\`guid\` LEFT JOIN \`guild\` ON \`guild\`.\`guildid\` = \`guild_member\`.\`guildid\` LEFT JOIN \`${realm.authDatabase}\`.\`account_access\` ON \`account_access\`.\`id\` = \`characters\`.\`account\` AND \`account_access\`.\`RealmID\` IN (-1, ${realm.realmId}) AND \`account_access\`.\`gmlevel\` > 0 WHERE ${where} AND (\`account_access\`.\`id\` IS NULL OR ? = 0) `, values: [character, this.armory.config.hideGameMasters ? 1 : 0], timeout: this.armory.config.dbQueryTimeout, }); if ((rows as RowDataPacket[]).length === 0) { return null; } return rows[0]; } private async getEquipmentData(realm: string, charGuid: number): Promise { const transmogSelect = this.armory.config.transmogModule ? ", custom_transmogrification.FakeEntry AS transmog" : ""; const transmogJoin = this.armory.config.transmogModule ? "LEFT JOIN custom_transmogrification ON custom_transmogrification.GUID = item_instance.guid" : ""; let [rows] = await this.armory.getCharactersDb(realm).query({ sql: ` SELECT character_inventory.slot, item_instance.itemEntry, item_instance.flags, item_instance.enchantments, item_instance.randomPropertyId ${transmogSelect} FROM character_inventory JOIN item_instance ON item_instance.guid = character_inventory.item ${transmogJoin} WHERE character_inventory.guid = ? AND character_inventory.bag = 0 AND character_inventory.slot IN (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18) `, values: [charGuid], timeout: this.armory.config.dbQueryTimeout, }); const data = rows as RowDataPacket[] as IEquipmentData[]; if (data.length === 0) { return []; } for (const row of data) { const item = await this.armory.dbc.item().find((item) => item.id === row.itemEntry); row.classId = item.classId; row.subclassId = item.subclassId; } [rows] = await this.armory.worldDb.query({ sql: "SELECT entry, quality FROM item_template WHERE entry IN (?)", values: [data.map((row) => row.itemEntry)], timeout: this.armory.config.dbQueryTimeout, }); for (const row of rows as RowDataPacket[]) { const item = data.find((item) => item.itemEntry === row.entry); item.quality = row.quality; } return data; } private async getMounts(realm: string, charGuid: number): Promise { const [rows] = await this.armory.getCharactersDb(realm).query({ sql: ` SELECT spell FROM character_spell WHERE guid = ? AND spell IN (?) `, values: [charGuid, this.mountSpells], timeout: this.armory.config.dbQueryTimeout, }); return (rows as RowDataPacket[]).map((row) => this.mountBySpellId[row.spell]).filter((m) => m !== undefined); } private async getModelViewerItems(equipmentData: IEquipmentData[], charClass: number, transmogOut?: number[][]): Promise { if (charClass !== 3) { // Keep ranged weapon only if the character is a hunter equipmentData = equipmentData.filter((row) => row.slot !== 17); } const visibleEquipment = equipmentData.filter( (item) => [0, 2, 3, 4, 5, 6, 7, 8, 9, 14, 15, 16, 17, 18].includes(item.slot) && // visible slots item.itemEntry !== 5976, // filter out Guild Tabard (displays blank otherwise) ); const items: number[][] = []; for (const equipment of visibleEquipment) { const modifiedAppearance = await this.armory.dbc.itemModifiedAppearance().find((row) => row.itemId === equipment.itemEntry); if (modifiedAppearance === undefined) { continue; } let appearance = await this.armory.dbc.itemAppearance().find((row) => row.id === modifiedAppearance.itemAppearanceId); if (appearance === undefined) { continue; } appearance = { ...appearance }; let invType = this.itemInventoryTypes[equipment.itemEntry]; items.push([invType, appearance.itemDisplayInfoId]); if (transmogOut !== undefined) { if (equipment.transmog !== undefined) { if (equipment.transmog === 1) { appearance.itemDisplayInfoId = -1; // Hidden gear piece from transmog } else { const modifiedAppearance = await this.armory.dbc.itemModifiedAppearance().find((row) => row.itemId === equipment.transmog); if (modifiedAppearance !== undefined) { const tmogAppearance = await this.armory.dbc.itemAppearance().find((row) => row.id === modifiedAppearance.itemAppearanceId); if (tmogAppearance !== undefined) { appearance = tmogAppearance; invType = this.itemInventoryTypes[equipment.transmog]; } } } } transmogOut.push([invType, appearance.itemDisplayInfoId]); } } return items; } private parseEnchantmentsString(enchantments: string): number[] { return enchantments .trim() .split(" ") .map((enchant) => parseInt(enchant)) .filter((enchant) => enchant !== 0); } private getGemsFromEnchantments(enchantments: string): number[] { return this.parseEnchantmentsString(enchantments) .filter((enchant) => enchant in this.enchantSrcItems && this.enchantSrcItems[enchant] in this.gemItems) .map((enchant) => this.enchantSrcItems[enchant]); } private filterEnchantments(item: number, enchantments: string): number[] { const socketBonus = this.itemSocketBonuses[item]; return this.parseEnchantmentsString(enchantments).filter( (enchant) => enchant in this.enchantSrcItems && !(this.enchantSrcItems[enchant] in this.gemItems) && enchant !== socketBonus, ); } private getCustomizationOptions(charData: ICharacterData): ICustomizationOption[] { const data = this.armory.characterCustomization.getCharacterCustomizationData(charData.race, charData.gender); const options = []; const setOptionByChoiceIndex = (optionName: string, choiceIndex: number) => { const option = data["Options"].find((opt) => opt.Name === optionName); if (option !== undefined) { const choice = option.Choices.find((choice) => choice.OrderIndex === choiceIndex); if (choice !== undefined) { options.push({ optionId: option.Id, choiceId: choice.Id }); } } }; const setOptionByChoiceName = (optionName: string, choiceName: string) => { const option = data["Options"].find((opt) => opt.Name === optionName); if (option !== undefined) { const choice = option.Choices.find((ch) => ch.Name === choiceName); if (choice !== undefined) { options.push({ optionId: option.Id, choiceId: choice.Id }); } } }; const setOptionByChoiceId = (optionName: string, choiceId: number) => { const option = data["Options"].find((opt) => opt.Name === optionName); if (option !== undefined) { options.push({ optionId: option.Id, choiceId: choiceId }); } }; const optionMapping = { Face: charData.face, "Skin Color": charData.skin, "Hair Style": charData.hairStyle, "Hair Color": charData.hairColor, }; for (const optionName in optionMapping) { setOptionByChoiceIndex(optionName, optionMapping[optionName]); } // Race-specific customization options switch (charData.race) { case 1: // Human if (charData.gender === 0) { setOptionByChoiceName( "Mustache", { 0: "Horseshoe", 1: "Brush", 2: "Horseshoe", 3: "None", 4: "Brush", 5: "Brush", 6: "Horseshoe", 7: "Brush", 8: "None" }[ charData.facialStyle ], ); setOptionByChoiceName( "Beard", { 0: "Short", 1: "Chin Puff", 2: "Soul Patch", 3: "Goatee", 4: "Goatee", 5: "None", 6: "Goatee", 7: "None", 8: "None" }[ charData.facialStyle ], ); setOptionByChoiceName( "Sideburns", { 0: "Medium", 1: "None", 2: "None", 3: "Medium", 4: "Long", 5: "Long", 6: "None", 8: "None", 7: "None" }[charData.facialStyle], ); setOptionByChoiceName("Eyebrows", "Natural"); setOptionByChoiceName("Face Shape", "Narrow"); setOptionByChoiceId( "Eye Color", { 0: 4138, 1: 4140, 2: 4130, 3: 4136, 4: 4141, 5: 4134, 6: 4130, 7: 4138, 8: 4144, 9: 4135, 10: 4126, 11: 4136 }[charData.face], ); } else { setOptionByChoiceIndex("Piercings", charData.facialStyle); setOptionByChoiceName("Eyebrows", "Natural"); setOptionByChoiceName("Face Shape", "Narrow"); setOptionByChoiceName("Makeup", "None"); setOptionByChoiceName("Necklace", "None"); setOptionByChoiceId( "Eye Color", { 0: 4162, 1: 4153, 2: 4161, 3: 4164, 4: 4154, 5: 4160, 6: 4160, 7: 4157, 8: 4152, 9: 4154, 10: 4155, 11: 4165, 12: 4163, 13: 4155, 14: 4151, }[charData.face], ); } if (charData.class === 6) { // Death Knight setOptionByChoiceId("Eye Color", charData.gender === 0 ? 4534 : 4535); } break; case 3: // Dwarf setOptionByChoiceName("Tattoo", "None"); setOptionByChoiceIndex("Tattoo Color", 0); setOptionByChoiceIndex("Eyebrows", 0); if (charData.gender === 0) { setOptionByChoiceName( "Mustache", { 0: "Trimmed", 1: "Bushy", 2: "Grand", 3: "Thin Braids", 4: "Wise", 5: "Thick Braids", 6: "Fancy", 7: "Bold", 8: "Tied", 9: "None", 10: "None", }[charData.facialStyle], ); setOptionByChoiceIndex("Beard", charData.facialStyle); setOptionByChoiceName("Earrings", "None"); setOptionByChoiceName("Nose Ring", "None"); setOptionByChoiceIndex("Eye Color", 0); // TODO } else { setOptionByChoiceIndex("Earrings", { 0: 0, 1: 1, 2: 2, 3: 3, 4: 0, 5: 4 }[charData.facialStyle]); setOptionByChoiceName( "Piercings", { 0: "None", 1: "None", 2: "None", 3: "None", 4: "Right Nostril", 5: "None" }[charData.facialStyle], ); setOptionByChoiceIndex("Eye Color", 0); // TODO } if (charData.class === 6) { // Death Knight setOptionByChoiceId("Eye Color", charData.gender === 0 ? 5559 : 5587); } break; case 7: // Gnome if (charData.gender === 0) { setOptionByChoiceIndex("Mustache", charData.facialStyle > 1 ? charData.facialStyle - 1 : 0); setOptionByChoiceIndex("Beard", charData.facialStyle < 7 ? charData.facialStyle : 0); setOptionByChoiceIndex("Eyebrows", charData.facialStyle < 6 ? charData.facialStyle : 1); setOptionByChoiceIndex("Eye Color", 0); // TODO } else { setOptionByChoiceIndex("Earrings", charData.facialStyle); setOptionByChoiceId("Earring Color", 8796); setOptionByChoiceIndex("Eye Color", 0); // TODO } if (charData.class === 6) { // Death Knight setOptionByChoiceId("Eye Color", charData.gender === 0 ? 5629 : 5643); } break; case 4: // Night Elf setOptionByChoiceName("Vines", "None"); setOptionByChoiceIndex("Vine Color", 0); setOptionByChoiceName("Ears", "Thin"); setOptionByChoiceName("Scars", "None"); if (charData.gender === 0) { setOptionByChoiceName( "Sideburns", { 0: "None", 1: "Groomed", 2: "None", 3: "Short", 4: "Medium", 5: "Groomed" }[charData.facialStyle], ); setOptionByChoiceName("Mustache", { 0: "None", 1: "Groomed", 2: "None", 3: "Thin", 4: "None", 5: "None" }[charData.facialStyle]); setOptionByChoiceName("Beard", { 0: "None", 1: "Trimmed", 2: "Full", 3: "None", 4: "Short", 5: "Long" }[charData.facialStyle]); setOptionByChoiceName("Eyebrows", { 0: "Shaved", 1: "Short", 2: "Long", 3: "Flat", 4: "Short", 5: "Owl" }[charData.facialStyle]); } else { setOptionByChoiceName("Eyebrows", "Long"); setOptionByChoiceIndex("Markings", charData.facialStyle + 1); setOptionByChoiceIndex("Markings Color", { 0: 1, 1: 2, 2: 3, 3: 4, 4: 5, 5: 3, 6: 6, 7: 7 }[charData.hairColor]); } setOptionByChoiceName("Blindfold", ""); setOptionByChoiceName("Headdress", "None"); setOptionByChoiceName("Earrings", "None"); setOptionByChoiceName("Nose Ring", "None"); setOptionByChoiceName("Necklace", "None"); setOptionByChoiceName("Horns", "None"); setOptionByChoiceName("Tattoo", "None"); setOptionByChoiceName("Tattoo Color", "None"); if (charData.class === 6) { // Death Knight setOptionByChoiceId("Eye Color", charData.gender === 0 ? 7618 : 7634); } else { setOptionByChoiceId("Eye Color", charData.gender === 0 ? 7610 : 7619); } break; case 11: // Draenei setOptionByChoiceName("Circlet", "None"); setOptionByChoiceId("Jewelry Color", charData.gender === 0 ? 8707 : 8646); setOptionByChoiceName("Horn Decoration", "None"); setOptionByChoiceName("Tail", charData.gender === 0 ? "Long" : "Short"); if (charData.gender === 0) { setOptionByChoiceName( "Facial Hair", { 0: "Bare", 1: "Bare", 2: "Burns", 3: "Chops", 4: "Mustache", 5: "Soul Patch", 6: "Handlebar", 7: "Bare" }[ charData.facialStyle ], ); setOptionByChoiceName( "Tendrils", { 0: "None", 1: "Splayed", 2: "Double", 3: "Fanned", 4: "Single", 5: "Paired", 6: "Uniform", 7: "Twin" }[charData.facialStyle], ); } else { setOptionByChoiceName( "Horns", { 0: "Sweeping", 1: "Curled", 2: "Curved", 3: "Thick", 4: "Wide", 5: "Grand", 6: "Short" }[charData.facialStyle], ); } if (charData.class === 6) { // Death Knight setOptionByChoiceId("Eye Color", charData.gender === 0 ? 6977 : 6979); } else { setOptionByChoiceId("Eye Color", charData.gender === 0 ? 6976 : 6978); } break; case 2: // Orc setOptionByChoiceName("Scars", "None"); setOptionByChoiceName("Grime", "None"); setOptionByChoiceName("Tattoo", "None"); setOptionByChoiceName("War Paint", "None"); setOptionByChoiceName("War Paint Color", "None"); if (charData.gender === 0) { setOptionByChoiceName( "Beard", { 0: "None", 1: "Stubble", 2: "Thick", 3: "Full", 4: "Tied", 5: "Braid", 6: "Twin Braids", 7: "None", 8: "Ringed", 9: "Split", 10: "Goatee", }[charData.facialStyle], ); setOptionByChoiceName( "Sideburns", { 0: "None", 1: "None", 2: "Full", 3: "Low", 4: "Full", 5: "None", 6: "None", 7: "Braids", 8: "None", 9: "Full", 10: "Thick" }[ charData.facialStyle ], ); setOptionByChoiceName("Earrings", "None"); setOptionByChoiceName("Nose Ring", "None"); setOptionByChoiceName("Tusks", "Natural"); setOptionByChoiceName("Upright", "Hunched"); setOptionByChoiceIndex("Eye Color", 0); // TODO } else { setOptionByChoiceIndex("Earrings", { 0: 0, 1: 1, 2: 2, 3: 0, 4: 1, 5: 2, 6: 4 }[charData.facialStyle]); setOptionByChoiceIndex("Nose Ring", { 0: 0, 1: 0, 2: 0, 3: 1, 4: 1, 5: 1, 6: 0 }[charData.facialStyle]); setOptionByChoiceName("Necklace", "None"); setOptionByChoiceIndex("Eye Color", 0); // TODO } if (charData.class === 6) { // Death Knight setOptionByChoiceId("Eye Color", charData.gender === 0 ? 9289 : 9313); } break; case 5: // Undead setOptionByChoiceName("Skin Type", "Bony"); if (charData.gender === 0) { setOptionByChoiceName( "Jaw Features", { 0: "Intact", 1: "Rot-Kissed", 2: "Intact", 3: "Slackjawed", 4: "Drooler", 5: "Intact", 6: "Slackjawed", 7: "Drooler", 8: "Bonejawed", 9: "Jawsome", 10: "Toothy", 11: "Unhinged", 12: "Cheeky", 13: "Loose", 14: "Intact", 15: "Slackjawed", 16: "Slobber", }[charData.facialStyle], ); setOptionByChoiceIndex( "Face Features", { 0: 0, 1: 0, 2: 1, 3: 1, 4: 1, 5: 2, 6: 3, 7: 3, 8: 0, 9: 0, 10: 0, 11: 0, 12: 0, 13: 0, 14: 4, 15: 4, 16: 0 }[ charData.facialStyle ], ); setOptionByChoiceId( "Eye Color", { 0: 5330, 1: 5330, 2: 6304, 3: 6304, 4: 6304, 5: 5330, 6: 5330, 7: 5330, 8: 5330, 9: 5330, 10: 6304, 11: 6304, 12: 5330, 13: 5330, 14: 5330, 15: 5330, 16: 5330, }[charData.facialStyle], ); } else { setOptionByChoiceName( "Face Features", { 0: "None", 1: "None", 2: "Strapped", 3: "Rotting", 4: "None", 5: "None", 6: "None", 7: "Putrid" }[charData.facialStyle], ); setOptionByChoiceName( "Jaw Features", { 0: "Intact", 1: "Stitched", 2: "Intact", 3: "Intact", 4: "Bonejawed", 5: "Toothy", 6: "Cheeky", 7: "Intact" }[ charData.facialStyle ], ); setOptionByChoiceId( "Eye Color", { 0: 5337, 1: 5337, 2: 6305, 3: 5337, 4: 5337, 5: 6305, 6: 5337, 7: 5337 }[charData.facialStyle], ); } if (charData.class === 6) { // Death Knight setOptionByChoiceId("Eye Color", charData.gender === 0 ? 5344 : 5345); } break; case 6: // Tauren setOptionByChoiceIndex("Horn Style", charData.hairStyle); setOptionByChoiceIndex("Horn Color", charData.hairColor); setOptionByChoiceName("Foremane", "Short"); setOptionByChoiceName("Face Paint", "None"); setOptionByChoiceName("Headdress", "None"); setOptionByChoiceName("Necklace", "None"); setOptionByChoiceIndex("Jewelry Color", 0); setOptionByChoiceName("Flower", "None"); setOptionByChoiceName("Body Paint", "None"); setOptionByChoiceIndex("Paint Color", 0); if (charData.gender === 0) { setOptionByChoiceName( "Hair", { 0: "Mane", 1: "Braids", 2: "Chops", 3: "Sideburns", 4: "Mane", 5: "Wrapped", 6: "Braids" }[charData.facialStyle], ); setOptionByChoiceName( "Facial Hair", { 0: "Clean", 1: "Braid", 2: "Beard", 3: "Wrapped", 4: "Curtain", 5: "Clean", 6: "Split" }[charData.facialStyle], ); setOptionByChoiceName( "Nose Ring", { 0: "None", 1: "Small", 2: "Open", 3: "None", 4: "None", 5: "Bead", 6: "Open" }[charData.facialStyle], ); setOptionByChoiceIndex("Eye Color", 0); // TODO } else { setOptionByChoiceIndex("Hair", charData.facialStyle); setOptionByChoiceName("Earrings", "None"); setOptionByChoiceName("Nose Ring", "None"); setOptionByChoiceIndex("Eye Color", 0); // TODO } if (charData.class === 6) { // Death Knight setOptionByChoiceId("Eye Color", charData.gender === 0 ? 7281 : 7289); } break; case 8: // Troll setOptionByChoiceName("Body Paint", "None"); setOptionByChoiceName("Body Paint Color", "None"); setOptionByChoiceName("Piercing", "None"); if (charData.gender === 0) { setOptionByChoiceName( "Tusks", { 0: "Tusked", 1: "Gougers", 2: "Mammoth", 3: "Spears", 4: "Bridle", 5: "Tusked", 6: "Gougers", 7: "Mammoth", 8: "Spears", 9: "Bridle", 10: "Gougers", }[charData.facialStyle], ); setOptionByChoiceName( "Face Paint", { 0: "None", 1: "None", 2: "None", 3: "None", 4: "None", 5: "Berserker", 6: "Fangs", 7: "Mask", 8: "Oni", 9: "Prophet", 10: "War", }[charData.facialStyle], ); setOptionByChoiceIndex("Face Paint Color", charData.hairColor + 1); setOptionByChoiceName("Earrings", "None"); setOptionByChoiceIndex("Eye Color", 0); // TODO } else { setOptionByChoiceIndex("Tusks", charData.facialStyle); setOptionByChoiceName("Face Paint", "None"); setOptionByChoiceIndex("Face Paint Color", 0); setOptionByChoiceName("Earrings", "Hoops"); setOptionByChoiceIndex("Eye Color", 0); // TODO } if (charData.class === 6) { // Death Knight setOptionByChoiceId("Eye Color", charData.gender === 0 ? 8451 : 8468); } break; case 10: // Blood Elf setOptionByChoiceName("Ears", "Long"); setOptionByChoiceName("Horns", "None"); setOptionByChoiceName("Blindfold", "None"); setOptionByChoiceName("Tattoo", "None"); setOptionByChoiceIndex("Tattoo Color", 0); if (charData.gender === 0) { setOptionByChoiceIndex("Facial Hair", charData.facialStyle); } else { setOptionByChoiceIndex("Earrings", charData.facialStyle); setOptionByChoiceIndex("Jewelry Color", 0); setOptionByChoiceName("Necklace", "None"); setOptionByChoiceName("Armbands", "None"); setOptionByChoiceName("Bracelets", "None"); } if (charData.class === 6) { // Death Knight setOptionByChoiceId("Eye Color", charData.gender === 0 ? 6586 : 6605); } else { setOptionByChoiceId("Eye Color", charData.gender === 0 ? 6570 : 6589); } break; } if ([4, 6].includes(charData.race)) { // Races that can choose the druid class setOptionByChoiceIndex("Bear Form", 0); setOptionByChoiceIndex("Cat Form", 0); setOptionByChoiceIndex("Aquatic Form", 0); setOptionByChoiceIndex("Travel Form", 0); setOptionByChoiceIndex("Flight Form", 0); setOptionByChoiceIndex("Moonkin Form", 0); } return options; } private async getTalents(realm: string, character: number): Promise { const [rows] = await this.armory.getCharactersDb(realm).query({ sql: ` SELECT spell, specMask FROM character_talent WHERE guid = ? `, values: [character], timeout: this.armory.config.dbQueryTimeout, }); const talents: number[][] = [[], []]; for (const row of rows as RowDataPacket[]) { if (row.specMask === 1 || row.specMask === 3) { talents[0].push(row.spell); } if (row.specMask === 2 || row.specMask === 3) { talents[1].push(row.spell); } } return talents; } private async getSkills(realm: string, character: number): Promise { const [rows] = await this.armory.getCharactersDb(realm).query({ sql: ` SELECT skill, value, max FROM character_skills WHERE guid = ? `, values: [character], timeout: this.armory.config.dbQueryTimeout, }); const skills: { id: number, categoryId: number, skill: string; value: number; max: number }[] = []; for (const row of rows as RowDataPacket[]) { skills.push({ id: row.skill, categoryId: this.skillById[row.skill].categoryId, skill: this.skillById[row.skill].name, value: row.value, max: row.max, }); } return skills; } private async getTalentTrees(classId: number) { const items = await this.armory.dbc .talentTab() .filter((tab) => tab.classMask === Math.pow(2, classId - 1)) .map(async (tab) => { const icon = await this.armory.dbc.spellIcon().find((icon) => icon.id === tab.spellIconId); const spells = await this.armory.dbc .talent() .filter((row) => row.tabId === tab.id) .map(async (row) => { const spell = await this.armory.dbc.spell().find((spell) => spell.id === row.spellRank0); const icon = await this.armory.dbc.spellIcon().find((icon) => icon.id === spell?.spellIconId); return { ...row, icon: this.processSpellIconTexture(icon?.textureFilename ?? "") }; }) .toArray(); return { name: tab.nameLang0, icon: this.processSpellIconTexture(icon.textureFilename), spells: await Promise.all(spells), }; }) .toArray(); return await Promise.all(items); } private processSpellIconTexture(texturePath: string): string { return texturePath.toLowerCase().replace("interface\\icons\\", "").replace("interface\\spellbook\\", "").replace(/\.$/, ""); } private async getGlyphs(realm: string, character: number): Promise { const [rows] = await this.armory.getCharactersDb(realm).query({ sql: ` SELECT guid, talentGroup, glyph1, glyph2, glyph3, glyph4, glyph5, glyph6 FROM character_glyphs WHERE guid = ? `, values: [character], timeout: this.armory.config.dbQueryTimeout, }); const glyphs: number[][] = [[], []]; for (const row of rows as RowDataPacket[]) { const glyphIds = [row.glyph1, row.glyph2, row.glyph3, row.glyph4, row.glyph5, row.glyph6].filter((id) => id !== 0); for (const glyphId of glyphIds) { const glyph = await this.armory.dbc.glyphProperties().find((g) => g.id === glyphId); if (glyph === undefined) { continue; } glyphs[row.talentGroup].push(glyph.spellId); } } return glyphs; } private async getAchievements( realm: string, charData: ICharacterData, ): Promise<{ achievements: IAchievement[]; earned: { [key: number]: number } }> { const promises = await this.armory.dbc .achievement() .filter((ach) => ach.faction === -1 || ach.faction === Utils.getFactionFromRaceId(charData.race)) .map(async (ach) => { const icon = await this.armory.dbc.spellIcon().find((icon) => icon.id === ach.iconId); return { id: ach.id, category: ach.category, title: ach.titleLang0, description: ach.descriptionLang0, points: ach.points, icon: this.processSpellIconTexture(icon?.textureFilename ?? ""), }; }) .toArray(); const achievements = await Promise.all(promises); const [rows] = await this.armory.getCharactersDb(realm).query({ sql: ` SELECT achievement, date FROM character_achievement WHERE guid = ? `, values: [charData.guid], timeout: this.armory.config.dbQueryTimeout, }); const earned: { [key: number]: number } = {}; for (const row of rows as RowDataPacket[]) { earned[row.achievement] = row.date; } return { achievements, earned, }; } private async getPvpKills(realm: string, charGuid: number): Promise<{ total: number; today: number; yesterday: number }> { const [rows] = await this.armory.getCharactersDb(realm).query({ sql: ` SELECT totalKills, todayKills, yesterdayKills FROM characters WHERE guid = ? `, values: [charGuid], timeout: this.armory.config.dbQueryTimeout, }); const row = rows[0]; return { total: row.totalKills, today: row.todayKills, yesterday: row.yesterdayKills, }; } private async getArenaTeams(realm: string, charGuid: number): Promise { const [rows] = await this.armory.getCharactersDb(realm).query({ sql: ` SELECT arena_team.arenaTeamId AS id, arena_team.name, arena_team.type, arena_team.rating, arena_team.seasonWins, arena_team.seasonGames, arena_team.backgroundColor AS background, arena_team.emblemStyle, arena_team.emblemColor, arena_team.borderStyle, arena_team.borderColor FROM arena_team_member LEFT JOIN arena_team ON arena_team_member.arenaTeamId = arena_team.arenaTeamId WHERE guid = ? ORDER BY arena_team.type ASC `, values: [charGuid], timeout: this.armory.config.dbQueryTimeout, }); return (rows as IArenaTeam[]).map((row) => { row.emblem = Utils.makeEmblemObject(row, false); return row; }); } private async getAllCharacters(currentRealm: string): Promise> { const [rows] = await this.armory.getCharactersDb(currentRealm).query({ sql: 'SELECT name, guid FROM characters WHERE level > 1 order by name asc', timeout: this.armory.config.dbQueryTimeout, }); return (rows as RowDataPacket[]).map(row => ({ name: row.name, guid: row.guid, realmName: currentRealm })); } }