feat(character/talents): add talents page

This commit is contained in:
Axel Cocat 2022-01-11 23:58:21 +01:00
parent f32b70fb0b
commit f979241e37
12 changed files with 5165 additions and 79 deletions

View file

@ -69,6 +69,7 @@ export class Armory {
const charsController = new CharacterController(this);
await charsController.load();
app.get("/character/:realm/:name", charsController.character.bind(charsController));
app.get("/character/:realm/:name/talents", charsController.talents.bind(charsController));
this.gc();
app.listen(listenPort, "0.0.0.0", () => {

View file

@ -2,6 +2,7 @@ import * as express from "express";
import { RowDataPacket } from "mysql2/promise";
import { Armory } from "../Armory";
import { IRealmConfig } from "../Config";
interface ICharacterData {
guid: number;
@ -32,6 +33,30 @@ interface ICustomizationOption {
}
const ITEM_CLASS_GEM = 3;
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;
@ -85,22 +110,24 @@ export class CharacterController {
}
public async character(req: express.Request, res: express.Response): Promise<void> {
const realm = req.params.realm;
const realmName = req.params.realm;
const charName = req.params.name;
if (this.armory.config.realms.find(r => r.name.toLowerCase() === realm.toLowerCase()) === undefined) {
const realm = this.getRealm(realmName);
if (realm === undefined) {
// Could not find realm
res.sendStatus(404);
return;
}
const charData = await this.getCharacterData(realm, charName);
const charData = await this.getCharacterData(realmName, charName);
if (charData === null) {
// Could not find character
res.sendStatus(404);
return;
}
const equipmentData = await this.getEquipmentData(realm, charData.guid);
const equipmentData = await this.getEquipmentData(realmName, charData.guid);
const customization = this.getCustomizationOptions(charData);
const equipment = equipmentData.map(row => {
(row as any).icon = this.itemIcons[row.itemEntry];
@ -111,13 +138,11 @@ export class CharacterController {
res.render("character.html", {
title: `Armory - ${charName}`,
...this.makeSharedDataObject(realm, charData),
data: JSON.stringify({
name: charData.name,
race: charData.race,
class: charData.class,
gender: charData.gender,
level: charData.level,
online: charData.online === 1,
class: charData.class,
characterModelItems: await this.getModelViewerItems(equipmentData, charData.class),
customizationOptions: customization,
equipment,
@ -127,6 +152,50 @@ export class CharacterController {
this.armory.gc();
}
public async talents(req: express.Request, res: express.Response): Promise<void> {
const realmName = req.params.realm;
const charName = req.params.name;
const realm = this.getRealm(realmName);
if (realm === undefined) {
// Could not find realm
res.sendStatus(404);
return;
}
const charData = await this.getCharacterData(realm.name, charName);
if (charData === null) {
// Could not find character
res.sendStatus(404);
return;
}
res.render("character-talents.html", {
title: `Armory - ${charName} - Talents`,
...this.makeSharedDataObject(realm, charData),
data: JSON.stringify({
talents: await this.getTalents(realm.name, charData.guid),
trees: await this.getTalentTrees(charData.class),
glyphs: await this.getGlyphs(realm.name, charData.guid),
}),
});
}
private makeSharedDataObject(realm: IRealmConfig, charData: ICharacterData) {
return {
realm: realm.name,
character: charData.name,
race: RaceDisplayName[charData.race],
class: ClassDisplayName[charData.class],
level: charData.level,
online: charData.online === 1,
};
}
private getRealm(realm: string): IRealmConfig {
return this.armory.config.realms.find(r => r.name.toLowerCase() === realm.toLowerCase());
}
private async getCharacterData(realm: string, charName: string): Promise<ICharacterData> {
const [rows, fields] = await this.armory.getCharactersDb(realm).query(`
SELECT guid, name, race, class, gender, level, skin, face, hairStyle, hairColor, facialStyle, online
@ -468,4 +537,77 @@ export class CharacterController {
return options;
}
private async getTalents(realm: string, character: number): Promise<number[][]> {
const [rows, fields] = await this.armory.getCharactersDb(realm).query(`
SELECT spell, specMask
FROM character_talent
WHERE guid = ?
`, [character]);
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 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<any[][]> {
const [rows, fields] = await this.armory.getCharactersDb(realm).query(`
SELECT guid, talentGroup, glyph1, glyph2, glyph3, glyph4, glyph5, glyph6
FROM character_glyphs
WHERE guid = ?
`, [character]);
const glyphs = [[], []];
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;
}
}

View file

@ -3,6 +3,11 @@ import * as path from "path";
import * as camelCase from "camelcase";
export interface IGlyphProperties {
id: number;
spellId: number;
}
export interface IItemDbc {
id: number;
classId: number;
@ -45,6 +50,7 @@ export interface IMountXDisplayDbc {
export interface ISpellDbc {
id: number;
mechanic: number;
spellIconId: number;
}
export interface ISpellItemEnchantmentDbc {
@ -52,6 +58,32 @@ export interface ISpellItemEnchantmentDbc {
srcItemId: number;
}
export interface ISpellIcon {
id: number;
textureFilename: string;
}
export interface ITalent {
id: number;
tabId: number;
tierId: number;
columnIndex: number;
spellRank0: number;
spellRank1: number;
spellRank2: number;
spellRank3: number;
spellRank4: number;
prereqTalent0: number;
prereqRank0: number;
}
export interface ITalentTab {
id: number;
nameLang0: string;
spellIconId: number;
classMask: number;
}
interface IAsyncGeneratorWithArrayMethods<T> {
[Symbol.asyncIterator](): AsyncGenerator<T>;
toArray(): Promise<T[]>;
@ -161,17 +193,17 @@ class DbcReader<T> {
public async *read(): AsyncGenerator<T> {
const stream = fs.createReadStream(this.filePath);
const itr = this.readLines(stream);
const itr = this.parseCsv(stream);
const headerLine = await itr.next();
if (headerLine.done === true) {
return;
}
const headerCols = this.parseCsvLine(headerLine.value)
const headerCols = headerLine.value
.map(header => camelCase(header).replace(/[\[\]]/g, ""));
for await (const line of itr) {
const cols = this.parseCsvLine(line)
for await (const arr of itr) {
const cols = arr
.map(value => {
const parsed: number = parseInt(value, 10);
return isNaN(parsed) ? value : parsed;
@ -186,78 +218,71 @@ class DbcReader<T> {
}
}
private async *readLines(stream: fs.ReadStream): AsyncGenerator<string> {
let previous = "";
for await (const chunk of stream) {
previous += chunk;
let unixEolIndex: number;
let winEolIndex: number;
while ((unixEolIndex = previous.indexOf("\n")) >= 0 || (winEolIndex = previous.indexOf("\r\n")) >= 0) {
const unix = unixEolIndex >= 0;
const line = previous.slice(0, (unix ? unixEolIndex : winEolIndex) - 1);
yield line;
previous = previous.slice(unix ? (unixEolIndex + 1) : (winEolIndex + 2));
}
}
if (previous.length > 0) {
yield previous;
}
}
private parseCsvLine(line: string): string[] {
private async *parseCsv(stream: fs.ReadStream): AsyncGenerator<string[]> {
// Adapted from https://stackoverflow.com/a/14991797
const arr = [];
let col = 0;
let quote = false; // 'true' means we're inside a quoted field
// Iterate over each character, keep track of current row and column (of the returned array)
for (let col = 0, c = 0; c < line.length; c++) {
let cc = line[c], nc = line[c + 1]; // Current character, next character
arr[col] = arr[col] || ""; // Create a new column (start with empty string) if necessary
for await (const chunk of stream) {
const str = chunk.toString();
// Iterate over each character, keep track of current column (of the returned array)
for (let c = 0; c < str.length; ++c) {
let ch = str[c], nch = str[c + 1]; // Current character, next character
if (!(col in arr)) {
arr[col] = ""; // Create a new column (start with empty string) if necessary
}
// If the current character is a quotation mark, and we're inside a
// quoted field, and the next character is also a quotation mark,
// add a quotation mark to the current column and skip the next character
if (cc == '"' && quote && nc == '"') {
arr[col] += cc; ++c;
continue;
// If the current character is a quotation mark, and we're inside a
// quoted field, and the next character is also a quotation mark,
// add a quotation mark to the current column and skip the next character
if (ch == '"' && quote && nch == '"') {
arr[col] += ch;
++c;
continue;
}
// If it's just one quotation mark, begin/end quoted field
if (ch == '"') {
quote = !quote;
continue;
}
// If it's a comma and we're not in a quoted field, move on to the next column
if (ch == ',' && !quote) {
++col;
continue;
}
// If it's a newline (CRLF) and we're not in a quoted field, skip the next character
// and move on to the next row and move to column 0 of that new row
if (ch == '\r' && nch == '\n' && !quote) {
yield arr;
arr.length = 0; // Clear the row
col = 0;
++c;
continue;
}
// If it's a newline (LF or CR) and we're not in a quoted field,
// move on to the next row and move to column 0 of that new row
if (!quote && (ch == '\r' || ch == '\n')) {
yield arr;
arr.length = 0; // Clear the row
col = 0;
continue;
}
// Otherwise, append the current character to the current column
arr[col] += ch;
}
// If it's just one quotation mark, begin/end quoted field
if (cc == '"') {
quote = !quote;
continue;
}
// If it's a comma and we're not in a quoted field, move on to the next column
if (cc == ',' && !quote) {
++col;
continue;
}
// If it's a newline (CRLF) and we're not in a quoted field, move on to the next row
if (cc == '\r' && nc == '\n' && !quote) {
break;
}
// If it's a newline (LF or CR) and we're not in a quoted field, move on to the next row
if ((cc == '\n' && !quote) ||
(cc == '\r' && !quote)) {
break;
}
// Otherwise, append the current character to the current column
arr[col] += cc;
}
return arr;
}
}
const dir = path.join(process.cwd(), "data");
export const DbcFiles = {
glyphProperties: path.join(dir, "GlyphProperties_3.3.5_12340.csv"),
item: path.join(dir, "Item_3.3.5_12340.csv"),
itemRetail: path.join(dir, "Item_9.2.0_41462.csv"),
itemAppearance: path.join(dir, "ItemAppearance_9.2.0_41462.csv"),
@ -267,9 +292,13 @@ export const DbcFiles = {
mountDisplay: path.join(dir, "MountXDisplay_9.2.0_41462.csv"),
spell: path.join(dir, "Spell_3.3.5_12340.csv"),
spellItemEnchantment: path.join(dir, "SpellItemEnchantment_3.3.5_12340.csv"),
spellIcon: path.join(dir, "SpellIcon_3.3.5_12340.csv"),
talent: path.join(dir, "Talent_3.3.5_12340.csv"),
talentTab: path.join(dir, "TalentTab_3.3.5_12340.csv"),
};
const dbcFields = {
glyphProperties: ["id", "spellId"],
item: ["id", "classId", "displayInfoId", "inventoryType"],
itemRetail: ["id", "inventoryType"],
itemAppearance: ["id", "itemDisplayInfoId"],
@ -277,11 +306,15 @@ const dbcFields = {
itemDisplayInfo: ["id", "inventoryIcon0"],
mount: ["id", "sourceSpellId"],
mountDisplay: ["id", "creatureDisplayInfoId", "mountId"],
spell: ["id", "mechanic"],
spell: ["id", "mechanic", "spellIconId"],
spellItemEnchantment: ["id", "srcItemId"],
spellIcon: ["id", "textureFilename"],
talent: ["id", "tabId", "tierId", "columnIndex", "spellRank0", "spellRank1", "spellRank2", "spellRank3", "spellRank4", "prereqTalent0", "prereqRank0"],
talentTab: ["id", "nameLang0", "spellIconId", "classMask"],
};
export class DbcManager {
private _glyphProperties: IGlyphProperties[];
private _item: IItemDbc[];
private _itemRetail: IItemRetailDbc[];
private _itemAppearance: IItemAppearanceDbc[];
@ -291,8 +324,12 @@ export class DbcManager {
private _mountDisplay: IMountXDisplayDbc[];
private _spell: ISpellDbc[];
private _spellItemEnchantment: ISpellItemEnchantmentDbc[];
private _spellIcon: ISpellIcon[];
private _talent: ITalent[];
private _talentTab: ITalentTab[];
public async loadAllFiles(): Promise<void> {
this._glyphProperties = await this.read<IGlyphProperties>(DbcFiles.glyphProperties, dbcFields.glyphProperties).toArray();
this._item = await this.read<IItemDbc>(DbcFiles.item, dbcFields.item).toArray();
this._itemRetail = await this.read<IItemRetailDbc>(DbcFiles.itemRetail, dbcFields.itemRetail).toArray();
this._itemAppearance = await this.read<IItemAppearanceDbc>(DbcFiles.itemAppearance, dbcFields.itemAppearance).toArray();
@ -302,6 +339,13 @@ export class DbcManager {
this._mountDisplay = await this.read<IMountXDisplayDbc>(DbcFiles.mountDisplay, dbcFields.mountDisplay).toArray();
this._spell = await this.read<ISpellDbc>(DbcFiles.spell, dbcFields.spell).toArray();
this._spellItemEnchantment = await this.read<ISpellItemEnchantmentDbc>(DbcFiles.spellItemEnchantment, dbcFields.spellItemEnchantment).toArray();
this._spellIcon = await this.read<ISpellIcon>(DbcFiles.spellIcon, dbcFields.spellIcon).toArray();
this._talent = await this.read<ITalent>(DbcFiles.talent, dbcFields.talent).toArray();
this._talentTab = await this.read<ITalentTab>(DbcFiles.talentTab, dbcFields.talentTab).toArray();
}
public glyphProperties() {
return this.getLoadedDataOrRead(DbcFiles.glyphProperties, this._glyphProperties, dbcFields.glyphProperties);
}
public item() {
@ -340,6 +384,18 @@ export class DbcManager {
return this.getLoadedDataOrRead(DbcFiles.spellItemEnchantment, this._spellItemEnchantment, dbcFields.spellItemEnchantment);
}
public spellIcon() {
return this.getLoadedDataOrRead(DbcFiles.spellIcon, this._spellIcon, dbcFields.spellIcon);
}
public talent() {
return this.getLoadedDataOrRead(DbcFiles.talent, this._talent, dbcFields.talent);
}
public talentTab() {
return this.getLoadedDataOrRead(DbcFiles.talentTab, this._talentTab, dbcFields.talentTab);
}
private read<T>(file: string, keepFields: string[] = []): AsyncGenWrapper<T> {
const reader = new DbcReader<T>(file, keepFields);
return new AsyncGenWrapper(reader.read());