commit
cf903daa71
8 changed files with 2728 additions and 2 deletions
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
|
|
@ -10,4 +10,4 @@ jobs:
|
|||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
- run: npm ci
|
||||
- run: npm run lint -- --max-warnings=0
|
||||
- run: npm run lint -- --max-warnings=3
|
||||
|
|
|
|||
2308
data/Areas.csv
Normal file
2308
data/Areas.csv
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -101,6 +101,12 @@ export class Armory {
|
|||
helpers: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
...require("handlebars-helpers")(),
|
||||
eq: function(a: any, b: any) {
|
||||
return a === b;
|
||||
},
|
||||
hasInProgressQuests: function(quests: any[]) {
|
||||
return quests.some(quest => quest.status === 'In Progress');
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
@ -155,6 +161,7 @@ export class Armory {
|
|||
app.get("/character/:realm/:character/achievements/data", this.wrapRoute(charsController.achievementsData.bind(charsController)));
|
||||
app.get("/character/:realm/:name/pvp", this.wrapRoute(charsController.pvp.bind(charsController)));
|
||||
app.get("/character/:realm/:name/reputation", this.wrapRoute(charsController.reputation.bind(charsController)));
|
||||
app.get("/character/:realm/:name/quests", this.wrapRoute(charsController.quests.bind(charsController)));
|
||||
|
||||
const guildsController = new GuildController(this);
|
||||
app.get("/guild/:realm/:name", this.wrapRoute(guildsController.guild.bind(guildsController)));
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { RowDataPacket } from "mysql2/promise";
|
|||
import { Armory } from "../Armory";
|
||||
import { IRealmConfig } from "../Config";
|
||||
import { IEmblem, Utils } from "../Utils";
|
||||
import { IAchievement as IAchievementDbc, ISkillDbc } from "../data/DbcReader";
|
||||
import { IAchievement as IAchievementDbc, ISkillDbc, IAreas } from "../data/DbcReader";
|
||||
|
||||
interface ICharacterData {
|
||||
guid: number;
|
||||
|
|
@ -90,6 +90,15 @@ interface IReputation {
|
|||
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 = {
|
||||
|
|
@ -119,6 +128,7 @@ const ClassDisplayName = {
|
|||
|
||||
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 };
|
||||
|
|
@ -204,6 +214,10 @@ export class CharacterController {
|
|||
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<void> {
|
||||
|
|
@ -349,6 +363,44 @@ export class CharacterController {
|
|||
});
|
||||
}
|
||||
|
||||
public async quests(req: express.Request, res: express.Response, next: express.NextFunction): Promise<void> {
|
||||
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;
|
||||
}, {});
|
||||
|
||||
res.render("character-quests.hbs", {
|
||||
title: `Armory - ${charData.name} - Quests`,
|
||||
...this.makeSharedDataObject(realm, charData),
|
||||
data: {
|
||||
categories: questsByCategory
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async getReputations(realm: string, character: number): Promise<IReputation[]> {
|
||||
const [rows] = await this.armory.getCharactersDb(realm).query({
|
||||
sql: `
|
||||
|
|
@ -423,6 +475,145 @@ export class CharacterController {
|
|||
return 2;
|
||||
}
|
||||
|
||||
private async getQuests(realm: string, character: number): Promise<IQuest[]> {
|
||||
// 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<IQuest> {
|
||||
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<void> {
|
||||
const realmName = req.params.realm;
|
||||
const charName = req.params.name;
|
||||
|
|
|
|||
|
|
@ -118,6 +118,13 @@ export interface IFactionDbc {
|
|||
name: string;
|
||||
}
|
||||
|
||||
export interface IAreas {
|
||||
id: number;
|
||||
zoneName: string;
|
||||
mapId: number;
|
||||
areaId: number;
|
||||
}
|
||||
|
||||
interface IAsyncGeneratorWithArrayMethods<T> {
|
||||
[Symbol.asyncIterator](): AsyncGenerator<T>;
|
||||
toArray(): Promise<T[]>;
|
||||
|
|
@ -321,6 +328,7 @@ const dir = path.join(process.cwd(), "data");
|
|||
export const DbcFiles = {
|
||||
achievement: path.join(dir, "Achievement_3.3.5_12340.csv"),
|
||||
achievementCategory: path.join(dir, "AchievementCategory_3.3.5_12340.csv"),
|
||||
areas: path.join(dir, "Areas.csv"),
|
||||
faction: path.join(dir, "Factions.csv"),
|
||||
glyphProperties: path.join(dir, "GlyphProperties_3.3.5_12340.csv"),
|
||||
item: path.join(dir, "Item_3.3.5_12340.csv"),
|
||||
|
|
@ -341,6 +349,7 @@ export const DbcFiles = {
|
|||
const dbcFields = {
|
||||
achievement: ["id", "faction", "titleLang0", "descriptionLang0", "category", "points", "flags", "iconId"],
|
||||
achievementCategory: ["id", "parent", "nameLang0"],
|
||||
areas: ["id", "zoneName", "mapId", "areaId"],
|
||||
glyphProperties: ["id", "spellId"],
|
||||
item: ["id", "classId", "subclassId", "displayInfoId", "inventoryType"],
|
||||
itemRetail: ["id", "inventoryType"],
|
||||
|
|
@ -373,6 +382,7 @@ const dbcFields = {
|
|||
export class DbcManager {
|
||||
private _achievement: IAchievement[];
|
||||
private _achievementCategory: IAchievementCategory[];
|
||||
private _areas: IAreas[];
|
||||
private _glyphProperties: IGlyphProperties[];
|
||||
private _faction: IFactionDbc[];
|
||||
private _item: IItemDbc[];
|
||||
|
|
@ -395,6 +405,7 @@ export class DbcManager {
|
|||
DbcFiles.achievementCategory,
|
||||
dbcFields.achievementCategory,
|
||||
).toArray();
|
||||
this._areas = await this.read<IAreas>(DbcFiles.areas).toArray();
|
||||
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();
|
||||
|
|
@ -425,6 +436,9 @@ export class DbcManager {
|
|||
return this.getLoadedDataOrRead(DbcFiles.achievementCategory, this._achievementCategory, dbcFields.achievementCategory);
|
||||
}
|
||||
|
||||
public areas() {
|
||||
return this.getLoadedDataOrRead(DbcFiles.areas, this._areas);
|
||||
}
|
||||
public faction() {
|
||||
return this.getLoadedDataOrRead(DbcFiles.faction, this._faction, dbcFields.faction);
|
||||
}
|
||||
|
|
|
|||
97
static/character-quests.hbs
Normal file
97
static/character-quests.hbs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<link rel="stylesheet" type="text/css" href="{{websiteRoot}}/css/character-quests.css">
|
||||
{{> icons }}
|
||||
<script type="application/javascript">
|
||||
const aowow_tooltips = { "renamelinks": true, };
|
||||
</script>
|
||||
|
||||
{{> character-header }}
|
||||
|
||||
{{#each data.categories as |quests category|}}
|
||||
<div class="quests-table" style="margin-bottom: 20px;">
|
||||
{{#if (hasInProgressQuests quests)}}
|
||||
<h2 class="is-size-4 category-header collapsible expanded">
|
||||
{{else}}
|
||||
<h2 class="is-size-4 category-header collapsible">
|
||||
{{/if}}
|
||||
{{category}}
|
||||
<span class="collapse-icon">▼</span>
|
||||
</h2>
|
||||
|
||||
{{#if (hasInProgressQuests quests)}}
|
||||
<div class="category-content expanded">
|
||||
{{else}}
|
||||
<div class="category-content">
|
||||
{{/if}}
|
||||
<!-- In Progress Quests -->
|
||||
<h3 class="is-size-5 collapsible">
|
||||
In Progress
|
||||
<span class="collapse-icon">▼</span>
|
||||
</h3>
|
||||
<div class="collapsible-content expanded">
|
||||
<table class="table is-striped is-hoverable is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Quest</th>
|
||||
<th>Level</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each quests}}
|
||||
{{#if (eq this.status "In Progress")}}
|
||||
<tr>
|
||||
<td class="quest-name"><a href="{{@root.aowow}}/?quest={{this.id}}">{{this.title}}</a></td>
|
||||
<td class="quest-level">{{this.questLevel}}</td>
|
||||
<td class="quest-status {{this.status}}">{{this.status}}</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Completed Quests -->
|
||||
<h3 class="is-size-5 collapsible">
|
||||
Completed
|
||||
<span class="collapse-icon">▼</span>
|
||||
</h3>
|
||||
<div class="collapsible-content">
|
||||
<table class="table is-striped is-hoverable is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Quest</th>
|
||||
<th>Level</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each quests}}
|
||||
{{#if (eq this.status "Completed")}}
|
||||
<tr>
|
||||
<td class="quest-name"><a href="{{@root.aowow}}/?quest={{this.id}}">{{this.title}}</a></td>
|
||||
<td class="quest-level">{{this.questLevel}}</td>
|
||||
<td class="quest-status {{this.status}}">{{this.status}}</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.collapsible').forEach(header => {
|
||||
header.addEventListener('click', function(e) {
|
||||
// Stop event from bubbling to parent collapsible elements
|
||||
e.stopPropagation();
|
||||
|
||||
const content = this.nextElementSibling;
|
||||
content.classList.toggle('expanded');
|
||||
this.classList.toggle('expanded');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
108
static/css/character-quests.css
Normal file
108
static/css/character-quests.css
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
.quests-table {
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.quests-table h2 {
|
||||
color: #FFD700;
|
||||
}
|
||||
|
||||
.quest-name {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.quest-level {
|
||||
text-align: center;
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.quest-status {
|
||||
text-align: center;
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.quest-status.completed {
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
.quest-status.in-progress {
|
||||
color: #FFD100;
|
||||
}
|
||||
|
||||
.collapsible {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.collapse-icon {
|
||||
display: inline-block;
|
||||
transition: transform 0.3s ease;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.collapsible.expanded .collapse-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.collapsible-content {
|
||||
display: none;
|
||||
overflow: hidden;
|
||||
transition: height 0.3s ease-out;
|
||||
}
|
||||
|
||||
.collapsible-content.expanded {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.completed-quests.expanded .collapsible-content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.completed-quests.expanded .collapse-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.quest-category {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.quest-category h3 {
|
||||
color: #ccc;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.is-size-5 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.category-header {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.category-content {
|
||||
display: none;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.category-content.expanded {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.category-header .collapse-icon {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.category-header.expanded .collapse-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.category-header:not(.expanded) .collapse-icon {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
<a href="{{websiteRoot}}/character/{{realm}}/{{name}}/talents">Talents</a> 
|
||||
<a href="{{websiteRoot}}/character/{{realm}}/{{name}}/skills">Skills</a> 
|
||||
<a href="{{websiteRoot}}/character/{{realm}}/{{name}}/reputation">Reputation</a> 
|
||||
<a href="{{websiteRoot}}/character/{{realm}}/{{name}}/quests">Quests</a> 
|
||||
<a href="{{websiteRoot}}/character/{{realm}}/{{name}}/achievements">Achievements</a> 
|
||||
<a href="{{websiteRoot}}/character/{{realm}}/{{name}}/pvp">PvP</a> 
|
||||
<br>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue