Merge pull request #13 from smthbh/Feature-Quests

Quests page
This commit is contained in:
Brad 2025-04-28 14:33:52 -04:00 committed by GitHub
commit cf903daa71
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 2728 additions and 2 deletions

View file

@ -10,4 +10,4 @@ jobs:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
- run: npm ci - run: npm ci
- run: npm run lint -- --max-warnings=0 - run: npm run lint -- --max-warnings=3

2308
data/Areas.csv Normal file

File diff suppressed because it is too large Load diff

View file

@ -101,6 +101,12 @@ export class Armory {
helpers: { helpers: {
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
...require("handlebars-helpers")(), ...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/: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/pvp", this.wrapRoute(charsController.pvp.bind(charsController)));
app.get("/character/:realm/:name/reputation", this.wrapRoute(charsController.reputation.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); const guildsController = new GuildController(this);
app.get("/guild/:realm/:name", this.wrapRoute(guildsController.guild.bind(guildsController))); app.get("/guild/:realm/:name", this.wrapRoute(guildsController.guild.bind(guildsController)));

View file

@ -4,7 +4,7 @@ import { RowDataPacket } from "mysql2/promise";
import { Armory } from "../Armory"; import { Armory } from "../Armory";
import { IRealmConfig } from "../Config"; import { IRealmConfig } from "../Config";
import { IEmblem, Utils } from "../Utils"; import { IEmblem, Utils } from "../Utils";
import { IAchievement as IAchievementDbc, ISkillDbc } from "../data/DbcReader"; import { IAchievement as IAchievementDbc, ISkillDbc, IAreas } from "../data/DbcReader";
interface ICharacterData { interface ICharacterData {
guid: number; guid: number;
@ -90,6 +90,15 @@ interface IReputation {
expansionId: number; expansionId: number;
} }
interface IQuest {
id: number;
title: string;
status: 'Completed' | 'In Progress';
minLevel: number;
questLevel: number;
questSortID: number;
}
const ItemClassGem = 3; const ItemClassGem = 3;
const SpellMechanicMounted = 21; const SpellMechanicMounted = 21;
const RaceDisplayName = { const RaceDisplayName = {
@ -119,6 +128,7 @@ const ClassDisplayName = {
export class CharacterController { export class CharacterController {
private armory: Armory; private armory: Armory;
private areaById: { [key: number]: IAreas };
private itemInventoryTypes: { [key: number]: number }; private itemInventoryTypes: { [key: number]: number };
private itemIcons: { [key: number]: number }; private itemIcons: { [key: number]: number };
private gemItems: { [key: number]: boolean }; private gemItems: { [key: number]: boolean };
@ -204,6 +214,10 @@ export class CharacterController {
for await (const skill of this.armory.dbc.skill()) { for await (const skill of this.armory.dbc.skill()) {
this.skillById[skill.id] = 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> { 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[]> { private async getReputations(realm: string, character: number): Promise<IReputation[]> {
const [rows] = await this.armory.getCharactersDb(realm).query({ const [rows] = await this.armory.getCharactersDb(realm).query({
sql: ` sql: `
@ -423,6 +475,145 @@ export class CharacterController {
return 2; 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> { public async achievements(req: express.Request, res: express.Response, next: express.NextFunction): Promise<void> {
const realmName = req.params.realm; const realmName = req.params.realm;
const charName = req.params.name; const charName = req.params.name;

View file

@ -118,6 +118,13 @@ export interface IFactionDbc {
name: string; name: string;
} }
export interface IAreas {
id: number;
zoneName: string;
mapId: number;
areaId: number;
}
interface IAsyncGeneratorWithArrayMethods<T> { interface IAsyncGeneratorWithArrayMethods<T> {
[Symbol.asyncIterator](): AsyncGenerator<T>; [Symbol.asyncIterator](): AsyncGenerator<T>;
toArray(): Promise<T[]>; toArray(): Promise<T[]>;
@ -321,6 +328,7 @@ const dir = path.join(process.cwd(), "data");
export const DbcFiles = { export const DbcFiles = {
achievement: path.join(dir, "Achievement_3.3.5_12340.csv"), achievement: path.join(dir, "Achievement_3.3.5_12340.csv"),
achievementCategory: path.join(dir, "AchievementCategory_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"), faction: path.join(dir, "Factions.csv"),
glyphProperties: path.join(dir, "GlyphProperties_3.3.5_12340.csv"), glyphProperties: path.join(dir, "GlyphProperties_3.3.5_12340.csv"),
item: path.join(dir, "Item_3.3.5_12340.csv"), item: path.join(dir, "Item_3.3.5_12340.csv"),
@ -341,6 +349,7 @@ export const DbcFiles = {
const dbcFields = { const dbcFields = {
achievement: ["id", "faction", "titleLang0", "descriptionLang0", "category", "points", "flags", "iconId"], achievement: ["id", "faction", "titleLang0", "descriptionLang0", "category", "points", "flags", "iconId"],
achievementCategory: ["id", "parent", "nameLang0"], achievementCategory: ["id", "parent", "nameLang0"],
areas: ["id", "zoneName", "mapId", "areaId"],
glyphProperties: ["id", "spellId"], glyphProperties: ["id", "spellId"],
item: ["id", "classId", "subclassId", "displayInfoId", "inventoryType"], item: ["id", "classId", "subclassId", "displayInfoId", "inventoryType"],
itemRetail: ["id", "inventoryType"], itemRetail: ["id", "inventoryType"],
@ -373,6 +382,7 @@ const dbcFields = {
export class DbcManager { export class DbcManager {
private _achievement: IAchievement[]; private _achievement: IAchievement[];
private _achievementCategory: IAchievementCategory[]; private _achievementCategory: IAchievementCategory[];
private _areas: IAreas[];
private _glyphProperties: IGlyphProperties[]; private _glyphProperties: IGlyphProperties[];
private _faction: IFactionDbc[]; private _faction: IFactionDbc[];
private _item: IItemDbc[]; private _item: IItemDbc[];
@ -395,6 +405,7 @@ export class DbcManager {
DbcFiles.achievementCategory, DbcFiles.achievementCategory,
dbcFields.achievementCategory, dbcFields.achievementCategory,
).toArray(); ).toArray();
this._areas = await this.read<IAreas>(DbcFiles.areas).toArray();
this._glyphProperties = await this.read<IGlyphProperties>(DbcFiles.glyphProperties, dbcFields.glyphProperties).toArray(); this._glyphProperties = await this.read<IGlyphProperties>(DbcFiles.glyphProperties, dbcFields.glyphProperties).toArray();
this._item = await this.read<IItemDbc>(DbcFiles.item, dbcFields.item).toArray(); this._item = await this.read<IItemDbc>(DbcFiles.item, dbcFields.item).toArray();
this._itemRetail = await this.read<IItemRetailDbc>(DbcFiles.itemRetail, dbcFields.itemRetail).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); return this.getLoadedDataOrRead(DbcFiles.achievementCategory, this._achievementCategory, dbcFields.achievementCategory);
} }
public areas() {
return this.getLoadedDataOrRead(DbcFiles.areas, this._areas);
}
public faction() { public faction() {
return this.getLoadedDataOrRead(DbcFiles.faction, this._faction, dbcFields.faction); return this.getLoadedDataOrRead(DbcFiles.faction, this._faction, dbcFields.faction);
} }

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

View 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);
}

View file

@ -5,6 +5,7 @@
<a href="{{websiteRoot}}/character/{{realm}}/{{name}}/talents">Talents</a>&emsp; <a href="{{websiteRoot}}/character/{{realm}}/{{name}}/talents">Talents</a>&emsp;
<a href="{{websiteRoot}}/character/{{realm}}/{{name}}/skills">Skills</a>&emsp; <a href="{{websiteRoot}}/character/{{realm}}/{{name}}/skills">Skills</a>&emsp;
<a href="{{websiteRoot}}/character/{{realm}}/{{name}}/reputation">Reputation</a>&emsp; <a href="{{websiteRoot}}/character/{{realm}}/{{name}}/reputation">Reputation</a>&emsp;
<a href="{{websiteRoot}}/character/{{realm}}/{{name}}/quests">Quests</a>&emsp;
<a href="{{websiteRoot}}/character/{{realm}}/{{name}}/achievements">Achievements</a>&emsp; <a href="{{websiteRoot}}/character/{{realm}}/{{name}}/achievements">Achievements</a>&emsp;
<a href="{{websiteRoot}}/character/{{realm}}/{{name}}/pvp">PvP</a>&emsp; <a href="{{websiteRoot}}/character/{{realm}}/{{name}}/pvp">PvP</a>&emsp;
<br> <br>