diff --git a/src/app/features/item/item-template/item-constants.ts b/src/app/features/item/item-template/item-constants.ts new file mode 100644 index 00000000000..17a678282a7 --- /dev/null +++ b/src/app/features/item/item-template/item-constants.ts @@ -0,0 +1,203 @@ +export const ITEM_CONSTANTS = { + 'timeUnits': { + sg: ['year', 'month', 'week', 'day', 'hour', 'minute', 'second', 'millisecond'], + pl: ['years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'milliseconds'], + ab: ['yr', 'mo', 'wk', 'day', 'hr', 'min', 'sec', 'ms'], + }, + 'pvpRank': [ // PVP_RANK_\d_\d(_FEMALE)? + null, 'Private / Scout', 'Corporal / Grunt', + 'Sergeant / Sergeant', 'Master Sergeant / Senior Sergeant', 'Sergeant Major / First Sergeant', + 'Knight / Stone Guard', 'Knight-Lieutenant / Blood Guard', 'Knight-Captain / Legionnare', + 'Knight-Champion / Centurion', 'Lieutenant Commander / Champion', 'Commander / Lieutenant General', + 'Marshal / General', 'Field Marshal / Warlord', 'Grand Marshal / High Warlord' + ], + 'si': { '-1': 'Alliance only', '-2': 'Horde only', 0: null, 1: 'Alliance', 2: 'Horde', 3: 'Both' }, + 'resistances': [/* null, */ 'Holy Resistance', 'Fire Resistance', 'Nature Resistance', 'Frost Resistance', 'Shadow Resistance', 'Arcane Resistance'], // RESISTANCE?_NAME + 'sc': ['Physical', 'Holy', 'Fire', 'Nature', 'Frost', 'Shadow', 'Arcane'], // STRING_SCHOOL_* + 'cl': [null, 'Warrior', 'Paladin', 'Hunter', 'Rogue', 'Priest', 'Death Knight', 'Shaman', 'Mage', 'Warlock', null, 'Druid'], // ChrClasses.dbc + 'ra': { // ChrRaces.dbc + '-2': 'Horde', + '-1': 'Alliance', + 0: null, + 1: 'Human', + 2: 'Orc', + 3: 'Dwarf', + 4: 'Night Elf', + 5: 'Undead', + 6: 'Tauren', + 7: 'Gnome', + 8: 'Troll', + 9: null, + 10: 'Blood Elf', + 11: 'Draenei', + }, + 'armor': '%s Armor', // ARMOR_TEMPLATE + 'block': '%s Block', // SHIELD_BLOCK_TEMPLATE + 'charges': '%d Charges', /* |4Charge: */ // ITEM_SPELL_CHARGES + 'locked': 'Locked', // LOCKED + 'ratingString': '%s @ L%s', + 'fap': 'Feral Attack Power', + 'durability': 'Durability %d / %d', // DURABILITY_TEMPLATE + 'itemLevel': 'Item Level %d', // ITEM_LEVEL + 'randEnchant': '<Random enchantment>', // ITEM_RANDOM_ENCHANT + 'readClick': '<Right Click To Read>', // ITEM_READABLE + 'openClick': '<Right Click To Open>', // ITEM_OPENABLE + 'setBonus': '(%d) Set: %s', // ITEM_SET_BONUS_GRAY + 'setName': '%s (%d/%d)', // ITEM_SET_NAME + 'reqMinLevel': 'Requires Level %d', // ITEM_MIN_LEVEL + 'reqLevelRange': 'Requires level %d to %d (%s)', // ITEM_LEVEL_RANGE_CURRENT + 'unique': ['Unique', 'Unique (%d)', 'Unique: %s (%d)' ], // ITEM_UNIQUE, ITEM_UNIQUE_MULTIPLE, ITEM_LIMIT_CATEGORY + 'uniqueEquipped': ['Unique-Equipped', null, 'Unique-Equipped: %s (%d)'], // ITEM_UNIQUE_EQUIPPABLE, null, ITEM_LIMIT_CATEGORY_MULTIPLE + 'dps': '(%.1f damage per second)', // DPS_TEMPLATE + 'damage': { // *DAMAGE_TEMPLATE* + // basic, basic /w school, add basic, add basic /w school + 'single': ['%d Damage', '%d %s Damage', '+ %d Damage', '+%d %s Damage' ], + 'range': ['%d - %d Damage', '%d - %d %s Damage', '+ %d - %d Damage', '+%d - %d %s Damage' ], + 'ammo': ['Adds %d damage per second', 'Adds %d %s damage per second', '+ %d damage per second', '+ %d %s damage per second' ], + }, + 'socketBonus': 'Socket Bonus: %s', // ITEM_SOCKET_BONUS + 'socket': [ + 'Meta Socket', + 'Red Socket', + 'Yellow Socket', + 'Blue Socket', + // -1 => 'Prismatic Socket' // TODO + ], + 'gemColors': [ // *_GEM + 'meta', + 'red', + 'yellow', + 'blue' + ], + 'gemConditions': { // ENCHANT_CONDITION_* in GlobalStrings.lua + 2: 'less than %s %s gems;', + 3: 'more %s gems than %s gems', + 5: 'at least %s %s gems', + }, + 'reqRating': [ // ITEM_REQ_ARENA_RATING* + 'Requires personal and team arena rating of %d', + 'Requires personal and team arena rating of %d in 3v3 or 5v5 brackets', + 'Requires personal and team arena rating of %d in 5v5 bracket', + ], + 'quality': [ // ITEM_QUALITY?_DESC + 'Poor', + 'Common', + 'Uncommon', + 'Rare', + 'Epic', + 'Legendary', + 'Artifact', + 'Heirloom', + ], + 'trigger': [ // ITEM_SPELL_TRIGGER_* + 'Use: ', + 'Equip: ', + 'Chance on hit: ', + '', + '', + '', + '', + ], + 'bonding': [ // ITEM_BIND_* + 'Binds to account', + 'Binds when picked up', + 'Binds when equipped', + 'Binds when used', + 'Quest Item', + 'Quest Item' + ], + 'bagFamily': [ // ItemSubClass.dbc/1 + 'Bag', + 'Quiver', + 'Ammo Pouch', + 'Soul Bag', + 'Leatherworking Bag', + 'Inscription Bag', + 'Herb Bag', + 'Enchanting Bag', + 'Engineering Bag', + null, /* Key */ + 'Gem Bag', + 'Mining Bag', + ], + 'inventoryType': [ // INVTYPE_* + null, 'Head', 'Neck', 'Shoulder', 'Shirt', + 'Chest', 'Waist', 'Legs', 'Feet', 'Wrist', + 'Hands', 'Finger', 'Trinket', 'One-Hand', 'Off Hand', /*Shield*/ + 'Ranged', 'Back', 'Two-Hand', 'Bag', 'Tabard', + 'Robe', 'Main Hand', 'Off Hand', 'Held In Off-Hand', 'Projectile', + 'Thrown', 'Ranged', 'Quiver', 'Relic' + ], + 'armorSubClass': [ // ItemSubClass.dbc/2 + 'Miscellaneous', 'Cloth', 'Leather', 'Mail', 'Plate', + null, 'Shield', 'Libram', 'Idol', 'Totem', + 'Sigil' + ], + 'weaponSubClass': [ // ItemSubClass.dbc/4 + 'Axe', 'Axe', 'Bow', 'Gun', 'Mace', + 'Mace', 'Polearm', 'Sword', 'Sword', null, + 'Staff', null, null, 'Fist Weapon', 'Miscellaneous', + 'Dagger', 'Thrown', null, 'Crossbow', 'Wand', + 'Fishing Pole', + ], + 'projectileSubClass': [ // ItemSubClass.dbc/6 + null, null, 'Arrow', 'Bullet', null + ], + 'statType': [ // ITEM_MOD_* + 'Mana', + 'Health', + null, + 'Agility', + 'Strength', + 'Intellect', + 'Spirit', + 'Stamina', + null, null, null, null, + 'Increases defense rating by %d.', + 'Increases your dodge rating by %d.', + 'Increases your parry rating by %d.', + 'Increases your shield block rating by %d.', + 'Improves melee hit rating by %d.', + 'Improves ranged hit rating by %d.', + 'Improves spell hit rating by %d.', + 'Improves melee critical strike rating by %d.', + 'Improves ranged critical strike rating by %d.', + 'Improves spell critical strike rating by %d.', + 'Improves melee hit avoidance rating by %d.', + 'Improves ranged hit avoidance rating by %d.', + 'Improves spell hit avoidance rating by %d.', + 'Improves melee critical avoidance rating by %d.', + 'Improves ranged critical avoidance rating by %d.', + 'Improves spell critical avoidance rating by %d.', + 'Improves melee haste rating by %d.', + 'Improves ranged haste rating by %d.', + 'Improves spell haste rating by %d.', + 'Improves hit rating by %d.', + 'Improves critical strike rating by %d.', + 'Improves hit avoidance rating by %d.', + 'Improves critical avoidance rating by %d.', + 'Increases your resilience rating by %d.', + 'Increases your haste rating by %d.', + 'Increases expertise rating by %d.', + 'Increases attack power by %d.', + 'Increases ranged attack power by %d.', + 'Increases attack power by %d in Cat, Bear, Dire Bear, and Moonkin forms only.', + 'Increases damage done by magical spells and effects by up to %d.', + 'Increases healing done by magical spells and effects by up to %d.', + 'Restores %d mana per 5 sec.', + 'Increases your armor penetration rating by %d.', + 'Increases spell power by %d.', + 'Restores %d health per 5 sec.', + 'Increases spell penetration by %d.', + 'Increases the block value of your shield by %d.', + 'Unknown Bonus #%d (%d)', + ], + lockType: [ // lockType.dbc + null, 'Lockpicking', 'Herbalism', 'Mining', 'Disarm Trap', + 'Open', 'Treasure (DND)', 'Calcified Elven Gems (DND)', 'Close', 'Arm Trap', + 'Quick Open', 'Quick Close', 'Open Tinkering', 'Open Kneeling', 'Open Attacking', + 'Gahz\'ridian (DND)', 'Blasting', 'PvP Open', 'PvP Close', 'Fishing (DND)', + 'Inscription', 'Open From Vehicle' + ], + +}; diff --git a/src/app/features/item/item-template/item-preview.service.spec.ts b/src/app/features/item/item-template/item-preview.service.spec.ts new file mode 100644 index 00000000000..5f82d06ff8f --- /dev/null +++ b/src/app/features/item/item-template/item-preview.service.spec.ts @@ -0,0 +1,426 @@ +import { TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { ITEMS_QUALITY } from '@keira-shared/constants/options/item-quality'; +import { MysqlQueryService } from '@keira-shared/services/mysql-query.service'; +import { SqliteQueryService } from '@keira-shared/services/sqlite-query.service'; +import { MockedToastrService } from '@keira-shared/testing/mocks'; +import { ItemTemplate } from '@keira-shared/types/item-template.type'; +import { ToastrService } from 'ngx-toastr'; +import { of } from 'rxjs'; +import { instance } from 'ts-mockito'; +import { ItemHandlerService } from '../item-handler.service'; +import { Lock } from './item-preview'; +import { ItemPreviewService } from './item-preview.service'; +import { ItemTemplateService } from './item-template.service'; +import { ITEM_FLAG } from '@keira-shared/constants/flags/item-flags'; +import { ITEM_TYPE } from '@keira-shared/constants/options/item-class'; + +describe('ItemPreviewService', () => { + beforeEach(() => TestBed.configureTestingModule({ + imports: [ + RouterTestingModule, + ], + providers: [ + { provide: ToastrService, useValue: instance(MockedToastrService) }, + ItemPreviewService, + ItemTemplateService, + ItemHandlerService, + ] + })); + + let service: ItemPreviewService; + let sqliteQueryService: SqliteQueryService; + let mysqlQueryService: MysqlQueryService; + + const mockItemNameById = 'mockItemNameById'; + const mockGetSpellNameById = 'mockGetSpellNameById'; + const mockGetSpellDescriptionById = 'mockGetSpellDescriptionById'; + const mockGetFactionNameById = 'mockGetFactionNameById'; + const mockGetLockById: Lock[] = [null]; + const mockGetMapNameById = 'mockGetMapNameById'; + const mockGetAreaNameById = 'mockGetAreaNameById'; + const mockGetEventNameByHolidayId = 'mockGetEventNameByHolidayId'; + const mockGetSocketBonusById = 'mockGetSocketBonusById'; + + const lockData1 = { + id: 1, type1: 1, type2: 1, type3: 2, type4: 2, type5: 0, + properties1: 1, properties2: 0, properties3: 1, properties4: 0, properties5: 0, + reqSkill1: 0, reqSkill2: 0, reqSkill3: 1, reqSkill4: 0, reqSkill5: 0 + }; + const lockData2 = { + id: 2, type1: 2, type2: 2, type3: 2, type4: 2, type5: 2, + properties1: 25, properties2: 1, properties3: 1, properties4: 1, properties5: 0, + reqSkill1: 0, reqSkill2: 1, reqSkill3: 1, reqSkill4: 0, reqSkill5: 0 + }; + + const locksData = [ + [mockGetLockById], + [lockData2], + [lockData1], + [null], + ]; + + const npcVendor1 = [{ item: 600, entry: 600, eventId: 0, maxcount: 0, extendedCost: 600 }]; + const npcVendor2 = [{ item: 601, entry: 601, eventId: 0, maxcount: 0, extendedCost: 601 }]; + + const mockItemEtendedCost1 = [ + { + id: 600, reqHonorPoints: 20, reqArenaPoints: 4670, reqArenaSlot: 1, reqItemId1: 1, reqItemId2: 2, reqItemId3: 3, reqItemId4: 4, + reqItemId5: 5, itemCount1: 6, itemCount2: 7, itemCount3: 8, itemCount4: 9, itemCount5: 10, reqPersonalRating: 2200 + } + ]; + const mockItemEtendedCost2 = [ + { + id: 601, reqHonorPoints: 0, reqArenaPoints: 0, reqArenaSlot: 0, reqItemId1: 0, reqItemId2: 0, reqItemId3: 0, reqItemId4: 0, + reqItemId5: 0, itemCount1: 6, itemCount2: 0, itemCount3: 0, itemCount4: 0, itemCount5: 0, reqPersonalRating: 0 + } + ]; + + // items and itemset + const mockItems1 = [ + { id: 123, slotBak: 1, itemset: 123 }, + { id: 124, slotBak: 2, itemset: 123 }, + { id: 125, slotBak: 3, itemset: 123 }, + { id: 126, slotBak: 4, itemset: 123 }, + { id: 127, slotBak: 5, itemset: 123 }, + { id: 128, slotBak: 6, itemset: 123 }, + { id: 129, slotBak: 1, itemset: 123 }, + { id: 130, slotBak: 2, itemset: 123 }, + { id: 131, slotBak: 3, itemset: 123 }, + { id: 132, slotBak: 4, itemset: 123 }, + { id: 133, slotBak: 5, itemset: 123 }, + { id: 134, slotBak: 6, itemset: 123 }, + ]; + + const mockItems2 = [ + { id: 135, slotBak: 1, itemset: 138 }, + { id: 136, slotBak: 2, itemset: 138 }, + { id: 137, slotBak: 1, itemset: 138 }, + { id: 138, slotBak: 2, itemset: 138 }, + ]; + const mockItems3 = [{ id: 150, slotBak: 1, itemset: 150 }]; + const mockItems4 = [{ id: 152, slotBak: 1, itemset: 152 }]; + const mockItems5 = [{ id: 153, slotBak: 1, itemset: 153 }, { id: 153, slotBak: 2, itemset: 153 }]; + + const mockItemset1 = [{ id: 123, name: 'Helias itemset', spell1: 1, bonus1: 2, bonus2: 1, skillId: 1, skillLevel: 1 }]; + const mockItemset2 = [{ id: 123, name: 'Shin itemset', spell1: 1, spell2: 2, skillId: 1 }]; + const mockItemset3 = [{ id: 123, name: 'Kalhac itemset' }]; + const mockItemset4 = [{ id: 123, name: null }]; + + const id = 123; + + beforeEach(() => { + mysqlQueryService = TestBed.inject(MysqlQueryService); + spyOn(mysqlQueryService, 'getItemNameById').and.callFake(i => of(i === 1 ? (mockItemNameById + i) : null).toPromise()); + spyOn(mysqlQueryService, 'query').and.callFake((i) => { + + if (i.indexOf('npc_vendor') > -1) { + if (i.indexOf('600') > -1) { return of(npcVendor1); } + if (i.indexOf('601') > -1) { return of(npcVendor2); } + } + + if (i.indexOf('SELECT name FROM item_template') > -1) { + if (i.indexOf('153') > -1) { return of(null); } + + if (i.indexOf('123') > -1) { + const itemsName = [{ name: '' }].concat(Array.from(new Array(11), (_, idx) => ({ name: 'Helias Item' + idx }))); + return of(itemsName); + } + + if (i.indexOf('138') > -1 || i.indexOf('150') > -1) { return of(Array.from(new Array(1), () => ({ name: 'Shin Item' }))); } + if (i.indexOf('152') > -1) { return of([{ name: null }]); } + } + + return of(null); + }); + + sqliteQueryService = TestBed.inject(SqliteQueryService); + spyOn(sqliteQueryService, 'getSpellNameById').and.callFake(i => of(mockGetSpellNameById + i).toPromise()); + spyOn(sqliteQueryService, 'getSpellDescriptionById').and.callFake(i => + of(String(i).indexOf('555') > -1 ? null : mockGetSpellDescriptionById + i + ).toPromise()); + spyOn(sqliteQueryService, 'getFactionNameById').and.callFake(i => of(mockGetFactionNameById + i).toPromise()); + spyOn(sqliteQueryService, 'getMapNameById').and.callFake(i => of( + String(i).indexOf('123') > -1 ? '' : mockGetMapNameById + i + ).toPromise()); + spyOn(sqliteQueryService, 'getAreaNameById').and.callFake(i => of( + String(i).indexOf('123') > -1 ? '' : mockGetAreaNameById + i + ).toPromise()); + spyOn(sqliteQueryService, 'getEventNameByHolidayId').and.callFake(i => of(mockGetEventNameByHolidayId + i).toPromise()); + spyOn(sqliteQueryService, 'getSocketBonusById').and.callFake(i => of(mockGetSocketBonusById + i).toPromise()); + spyOn(sqliteQueryService, 'getLockById').and.callFake(i => of(locksData[i]).toPromise()); + spyOn(sqliteQueryService, 'getSkillNameById').and.callFake(i => of(i === 1 ? 'profession' : null).toPromise()); + spyOn(sqliteQueryService, 'query').and.callFake((i) => { + + if (i.indexOf('item_extended_cost') > -1) { + if (i.indexOf('600') > -1) { return of(mockItemEtendedCost1); } + if (i.indexOf('601') > -1) { return of(mockItemEtendedCost2); } + + return of([]); + } + + if (i.indexOf('SELECT * FROM items ') > -1) { + if (i.indexOf('123') > -1) { return of(mockItems1); } + if (i.indexOf('138') > -1 || i.indexOf('140') > -1) { return of(mockItems2); } + if (i.indexOf('150') > -1 || i.indexOf('151') > -1) { return of(mockItems3); } + if (i.indexOf('152') > -1) { return of(mockItems4); } + if (i.indexOf('153') > -1) { return of(mockItems5); } + } + + if (i.indexOf('SELECT * FROM itemset WHERE id = ') > -1) { + if (i.indexOf('id = 123') > -1) { return of(mockItemset1); } + if (i.indexOf('id = 138') > -1) { return of(mockItemset2); } + if (i.indexOf('id = 140') > -1) { return of(mockItemset3); } + if (i.indexOf('id = 150') > -1 || i.indexOf('id = 153') > -1) { return of(mockItemset4); } + } + + if (i.indexOf('SELECT * FROM item_limit_category') > -1) { + if (i.indexOf('id = 123') > -1) { return of([{name: 'Helias', count: 2, isGem: 1 }]); } + if (i.indexOf('id = 124') > -1) { return of([]); } + + return of([{name: 'Helias', count: 2, isGem: 0 }]); + } + + if (i.indexOf('SELECT * FROM item_enchantment_condition') > -1) { + if (i.indexOf('100') > -1) { return of([ + { + id: 100, color1: 4, color2: 1, color3: 3, comparator1: 4, comparator2: 2, comparator3: 3, comparator4: 4, + cmpColor1: 1, cmpColor2: 2, cmpColor3: 3, cmpColor4: 4, value1: 1, value2: 2, value3: 3, value4: 4, + } + ]); + } + if (i.indexOf('104') > -1) { return of([]); } + if (i.indexOf('105') > -1) { return of([null]); } + } + + if (i.indexOf('SELECT * FROM item_enchantment') > -1) { + if (i.indexOf('100') > -1) { return of([{ id: 100, name: 'Helias', conditionId: 100 }]); } + if (i.indexOf('103') > -1) { return of([{ id: 103, name: '', conditionId: 0 }]); } + if (i.indexOf('104') > -1) { return of([{ id: 104, name: '', conditionId: 104 }]); } + if (i.indexOf('105') > -1) { return of([{ id: 105, name: '', conditionId: 105 }]); } + } + + return of(null); + }); + spyOn(sqliteQueryService, 'queryValue').and.callFake((i) => { + if (i.indexOf('SELECT gemEnchantmentId AS v') > -1) { + if (i.indexOf('id = 100') > -1) { return of(100); } + if (i.indexOf('id = 101') > -1) { return of(null); } + if (i.indexOf('id = 102') > -1) { return of(102); } + if (i.indexOf('id = 103') > -1) { return of(103); } + if (i.indexOf('id = 104') > -1) { return of(104); } + if (i.indexOf('id = 105') > -1) { return of(105); } + } + + return of(null); + }); + + service = TestBed.inject(ItemPreviewService); + }); + + it('getItemExtendedCostFromVendor', () => { + service['getItemExtendedCostFromVendor'](123); + expect(mysqlQueryService.query).toHaveBeenCalledTimes(1); + }); + + it('getItemsetSlotBak', () => { + service['getItemsetSlotBak'](id); + expect(sqliteQueryService.query).toHaveBeenCalledTimes(1); + expect(sqliteQueryService.query).toHaveBeenCalledWith(`SELECT * FROM items WHERE itemset = ${id} ORDER BY slotBak, id`); + }); + + it('getItemNameByIDsASC', () => { + const IDs = [123, 1234]; + service['getItemNameByIDsASC'](IDs); + expect(mysqlQueryService.query).toHaveBeenCalledTimes(1); + expect(mysqlQueryService.query).toHaveBeenCalledWith(`SELECT name FROM item_template WHERE entry IN (${IDs.join(',')}) ORDER BY entry ASC`); + }); + + it('getItemsetById', () => { + service['getItemsetById'](id); + expect(sqliteQueryService.query).toHaveBeenCalledTimes(1); + expect(sqliteQueryService.query).toHaveBeenCalledWith(`SELECT * FROM itemset WHERE id = ${id}`); + }); + + it('getItemLimitCategoryById', () => { + service['getItemLimitCategoryById'](id); + expect(sqliteQueryService.query).toHaveBeenCalledTimes(1); + expect(sqliteQueryService.query).toHaveBeenCalledWith(`SELECT * FROM item_limit_category WHERE id = ${id}`); + }); + + it('getGemEnchantmentIdById', () => { + service['getGemEnchantmentIdById'](id); + expect(sqliteQueryService.queryValue).toHaveBeenCalledTimes(1); + expect(sqliteQueryService.queryValue).toHaveBeenCalledWith(`SELECT gemEnchantmentId AS v FROM items WHERE id = ${id};`); + }); + + it('getItemEnchantmentById', () => { + service['getItemEnchantmentById'](id); + expect(sqliteQueryService.query).toHaveBeenCalledTimes(1); + expect(sqliteQueryService.query).toHaveBeenCalledWith(`SELECT * FROM item_enchantment WHERE id = ${id}`); + }); + + it('getItemExtendedCost', () => { + const IDs = [123, 1234]; + service['getItemExtendedCost'](IDs); + expect(sqliteQueryService.query).toHaveBeenCalledTimes(1); + expect(sqliteQueryService.query).toHaveBeenCalledWith(`SELECT * FROM item_extended_cost WHERE id IN (${IDs.join(',')})`); + }); + + it('getItemEnchantmentConditionById', () => { + service['getItemEnchantmentConditionById'](id); + expect(sqliteQueryService.query).toHaveBeenCalledTimes(1); + expect(sqliteQueryService.query).toHaveBeenCalledWith(`SELECT * FROM item_enchantment_condition WHERE id = ${id}`); + }); + + const cases = [ + { name: 'Empty variables', template: { }, output: '' }, + { name: 'Item Name & Quality', template: { name: 'Helias Item', Quality: 1 }, output: `Helias Item` }, + { name: 'Quality 0', template: { name: 'Helias Item', Quality: 0 }, output: `Helias Item` }, + { name: 'Quality null', template: { name: 'Helias Item' }, output: `Helias Item` }, + { name: 'All Flags & Quality Epic', template: { Quality: ITEMS_QUALITY.EPIC, Flags: -1 }, output: `
Heroic
Conjured Item
Binds to account
Unique-Equipped` }, + { name: 'Empty variables', template: { HolidayId: 140 }, output: `
Requires ${mockGetEventNameByHolidayId}140` }, + { name: 'StartQuest', template: { startquest: 1 }, output: `
This Item Begins a Quest` }, + { name: 'ContainerSlots', template: { ContainerSlots: 1 }, output: `
1 Slot Bag` }, + { name: 'ContainerSlots-2', template: { ContainerSlots: 1, BagFamily: 2 }, output: `
1 Slot Ammo Pouch` }, + { name: 'RandomProperty', template: { RandomProperty: 1 }, output: `
<Random enchantment>` }, + { name: 'RandomSuffix', template: { RandomSuffix: 1 }, output: `
<Random enchantment>` }, + { name: 'Durability', template: { MaxDurability: 100 }, output: `
Durability 100 / 100` }, + { name: 'Sell Price', template: { SellPrice: 123456 }, output: `
Sell Price: 12  34  56  ` }, + { name: 'Sell Price-1', template: { SellPrice: 9999 }, output: `
Sell Price: 99  99  ` }, + { name: 'Sell Price-2', template: { SellPrice: 99 }, output: `
Sell Price: 99  ` }, + { name: 'Sell Price-3', template: { SellPrice: -1 }, output: `` }, + { name: 'Stats 1', template: { stat_type1: 1, stat_value1: 1 }, output: `
+1 Health` }, + { name: 'Stats -1', template: { stat_type1: 1, stat_value1: -1 }, output: `
-1 Health` }, + { name: 'Stats type null', template: { stat_type1: 8, stat_value1: 1 }, output: `` }, + { name: 'Stats 12, ReqLvL', template: { stat_type1: 12, stat_value1: 1, RequiredLevel: 71 }, output: `
Requires Level 71
Equip: Increases defense rating by (0.39 @ L71).` }, + { name: 'Stats 13, ReqLvL', template: { stat_type1: 13, stat_value1: 1, RequiredLevel: 71 }, output: `
Requires Level 71
Equip: Increases your dodge rating by (0.04% @ L71).` }, + { name: 'Stats 13, ReqLvL', template: { stat_type1: 13, stat_value1: 1, RequiredLevel: 61 }, output: `
Requires Level 61
Equip: Increases your dodge rating by (0.07% @ L61).` }, + { name: 'Stats 13, ReqLvL', template: { stat_type1: 13, stat_value1: 1, RequiredLevel: 11 }, output: `
Requires Level 11
Equip: Increases your dodge rating by (0.14% @ L34).` }, + { name: 'Stats 16, ReqLvL', template: { stat_type1: 16, stat_value1: 1, RequiredLevel: 8 }, output: `
Requires Level 8
Equip: Improves melee hit rating by (2.60% @ L8).` }, + { name: 'Stats 38, ReqLvL', template: { stat_type1: 38, stat_value1: 1, RequiredLevel: 8 }, output: `
Requires Level 8
Equip: Increases attack power by 1.` }, + { name: 'Stats 48, ReqLvL', template: { stat_type1: 48, stat_value1: 1, RequiredLevel: 8 }, output: `
Requires Level 8
Equip: Increases the block value of your shield by 1.` }, + { name: 'Stats 39, ReqLvL', template: { stat_type1: 39, stat_value1: 1, RequiredLevel: 8 }, output: `
Requires Level 8
Equip: Increases ranged attack power by (% @ L8).` }, + { name: 'AllowableClass Paladin', template: { AllowableClass: 2 }, output: `
Classes: Paladin` }, + { name: 'AllowableClass All', template: { AllowableClass: -1 }, output: `` }, + { name: 'AllowableRace All', template: { AllowableRace: -1 }, output: `` }, + { name: 'AllowableRace Alliance', template: { AllowableRace: 1101 }, output: `
Races: Alliance` }, + { name: 'AllowableRace Horde', template: { AllowableRace: 690 }, output: `
Races: Horde` }, + { name: 'AllowableRace Human', template: { AllowableRace: 1 }, output: `
Races: Human` }, + { name: 'AllowableRace Orc', template: { AllowableRace: 2 }, output: `
Races: Orc` }, + { name: 'Duration 1 day', template: { duration: 3600 * 24 }, output: `
Duration: 1 day` }, + { name: 'Duration 2 days', template: { duration: 3600 * 24 * 2 }, output: `
Duration: 2 days` }, + { name: 'Duration 1 hour', template: { duration: 3600 }, output: `
Duration: 1 hour` }, + { name: 'Duration 2 hours', template: { duration: 3600 * 2 }, output: `
Duration: 2 hours` }, + { name: 'Duration 1 minute', template: { duration: 60 }, output: `
Duration: 1 minute` }, + { name: 'Duration 2 minutes', template: { duration: 60 * 2 }, output: `
Duration: 2 minutes` }, + { name: 'Duration 1 seconds', template: { duration: 1 }, output: `
Duration: 1 second` }, + { name: 'Duration 10 secs', template: { duration: 10 }, output: `
Duration: 10 seconds` }, + { name: 'Duration -1 ms', template: { duration: 0.001 }, output: `
Duration: 1 millisecond` }, + { name: 'Duration -1 ms', template: { duration: -0.001 }, output: `
Duration: -1 millisecond` }, + { name: 'Duration -50 ms', template: { duration: -0.05 }, output: `
Duration: -50 milliseconds` }, + { name: 'Duration 0 sec', template: { duration: -5 }, output: `
Duration: 0 seconds` }, + { name: 'Duration 1 week', template: { duration: (3600 * 24 * 7) }, output: `
Duration: 1 week` }, + { name: 'Duration 2 weeks', template: { duration: (3600 * 24 * 7 * 2) }, output: `
Duration: 2 weeks` }, + { name: 'Duration 1 month', template: { duration: 3600 * 24 * 30 }, output: `
Duration: 1 month` }, + { name: 'Duration 2 months', template: { duration: 3600 * 24 * 30 * 2 }, output: `
Duration: 2 months` }, + { name: 'Duration 1 year', template: { duration: 3600 * 24 * 364 }, output: `
Duration: 1 year` }, + { name: 'Duration 2 years', template: { duration: 3600 * 24 * 364 * 2 }, output: `
Duration: 2 years` }, + { name: 'Duration (real time)', template: { duration: 1020, flagsCustom: 1 }, output: `
Duration: 17 minutes (real time)` }, + { name: 'Feral Attack Power 1', template: { + dmg_min1: 10, dmg_min2: 10, dmg_max1: 10, dmg_max2: 10, delay: 1000, class: 10 + }, output: `
10 Damage+ 10 Damage` }, + { name: 'Feral Attack Power 2', template: { + dmg_min1: 10, dmg_min2: 10, dmg_max1: 10, dmg_max2: 10, delay: 1000, class: 2, subclass: 2 + }, output: `
Bow
10 Damage    Speed 1.00
+ 10 Damage
(20.00 damage per second)` }, + { name: 'Feral Attack Power 3', template: { + dmg_min1: 100, dmg_min2: 100, dmg_max1: 500, dmg_max2: 500, delay: 1000, class: 2, subclass: 5 + }, output: `
Mace
100 - 500 Damage    Speed 1.00
+ 100 - 500 Damage
(600.00 damage per second)
(7633 Feral Attack Power)` }, + { name: 'Feral Attack Power 4', template: { + dmg_min1: 1, dmg_min2: 1, dmg_max1: 5, dmg_max2: 5, delay: 1000, class: 2, subclass: 5 + }, output: `
Mace
1 - 5 Damage    Speed 1.00
+ 1 - 5 Damage
(6.00 damage per second)` }, + { name: 'Spell ID 1', template: { spellid_1: 1, spellid_2: 1 }, output: `
${mockGetSpellDescriptionById}1` }, + { name: 'Spell ID 2', template: { spellid_2: 1 }, output: `
${mockGetSpellDescriptionById}1` }, + { name: 'Spell ID 483', template: { spellid_1: 483 }, + output: `
${mockGetSpellDescriptionById}483` + }, + { name: 'Spell ID 55884', template: { spellid_1: 55884 }, + output: `
${mockGetSpellDescriptionById}55884` + }, + { name: 'Spell ID 123, 123 charges', template: { spellid_1: 123, spellcharges_1: 123, PageText: 123}, output: `
mockGetSpellDescriptionById123
<Right Click To Read>
123 Charges` }, + { name: 'Spell ID 123, 1 charge', template: { spellid_1: 123, spellcharges_1: 1, description: 'spellDesc' }, output: `
mockGetSpellDescriptionById123
"spellDesc"
1 Charge` }, + { name: 'Spell ID 123, no charges', template: { spellid_1: 123, }, output: `
mockGetSpellDescriptionById123` }, + { name: 'LockId 1', template: { Flags: 1, lockid: 1 }, output: `
Locked
Requires Lockpicking (1)
Requires Lockpicking (1)
Requires Lockpicking
` }, + { name: 'LockId 2', template: { Flags: 1, lockid: 2 }, output: `
Locked
Requires mockItemNameById1
Requires Lockpicking (1)
` }, + { name: 'LockId 3', template: { Flags: 1, lockid: 3 }, output: `` }, + { name: 'LockId 4', template: { Flags: ITEM_FLAG.OPENABLE, lockid: 3 }, output: `
<Right Click To Open>` }, + { name: 'Req HonorRank', template: { requiredhonorrank: 1 }, output: `
Requires Private / Scout` }, + { name: 'Req Level Range', template: { Quality: ITEMS_QUALITY.HEIRLOOM, Flags: ITEM_FLAG.ACCOUNTBOUND }, output: `
Binds to account
Requires level 1 to 80 (80)` }, + { name: 'Damage 1', template: { + class: ITEM_TYPE.AMMUNITION, dmg_min1: 10, dmg_min2: 10, dmg_max1: 10, dmg_max2: 10, delay: 1000, + }, output: `Adds 20 damage per second` }, + { name: 'Damage 2', template: { + class: ITEM_TYPE.AMMUNITION, dmg_min1: 10, dmg_min2: 10, dmg_max1: 10, dmg_max2: 10, delay: 1000, dmg_type1: 1, + }, output: `Adds 20 Holy damage per second` }, + { name: 'Damage 3', template: { + dmg_min1: 10, dmg_min2: 10, dmg_max1: 10, dmg_max2: 10, delay: 1000, dmg_type1: 1, dmg_type2: 2, + }, output: `
10 Holy Damage+10 Fire Damage` }, + { name: 'Damage 4', template: { + dmg_min1: 5, dmg_min2: 5, dmg_max1: 10, dmg_max2: 10, delay: 1000, dmg_type1: 1, dmg_type2: 2, + }, output: `
5 - 10 Holy Damage+5 - 10 Fire Damage` }, + { name: 'Itemset full success', template: { itemset: 123, entry: 123 }, output: `

Helias itemset (0/12)
Requires profession (1)

Helias Item0
Helias Item1
Helias Item2
Helias Item3
Helias Item4
Helias Item5
Helias Item6
Helias Item7
Helias Item8
Helias Item9
Helias Item10

(2) Set: mockGetSpellDescriptionById1
` }, + { name: 'Itemset, items < 10, no skillLevel', template: { itemset: 138 }, output: `

Shin itemset (0/1)
Requires profession
Shin Item
` }, + { name: 'Itemset - no bonus and skill', template: { itemset: 140 }, output: `

Kalhac itemset (0/1)
Shin Item
` }, + { name: 'Itemset - no itemset name', template: { itemset: 150, }, output: `

(0/1)
Shin Item
` }, + { name: 'Itemset - no itemset', template: { itemset: 151, }, output: `` }, + { name: 'Itemset - itemsName with null name', template: { itemset: 152, }, output: `` }, + { name: 'Itemset - no itemsName', template: { itemset: 153, }, output: `` }, + { name: 'Itemset - no itemsetPieces', template: { itemset: 154, }, output: `` }, + { name: 'Bonding, unique', template: { bonding: 1, maxcount: 1 }, output: `
Binds when picked up
Unique` }, + { name: 'Maxcount cases', template: { maxcount: 2, BagFamily: 1 }, output: `
Unique (2)` }, + { name: 'Item Limit Category, is Gem', template: { ItemLimitCategory: 123 }, output: `
Unique-Equipped: Helias (2)` }, + { name: 'Item Limit Category, not Gem', template: { ItemLimitCategory: 1 }, output: `
Unique: Helias (2)` }, + { name: 'Item Limit Category, no data', template: { ItemLimitCategory: 124 }, output: `` }, + { + name: 'inventoryType, class ARMOR, armorDamager', + template: { class: ITEM_TYPE.ARMOR, subclass: 1, InventoryType: 1, ArmorDamageModifier: 1, armor: 1 }, + output: `
HeadCloth

1 Armor` + }, + { name: 'inventoryType - 1', template: { class: 3, subclass: 2, InventoryType: 2 }, output: `
Neck` }, + { name: 'inventoryType, armor', template: { class: ITEM_TYPE.WEAPON, armor: 1, ItemLevel: 10, }, output: `
1 Armor
Item Level 10` }, + { name: 'inventoryType, block', template: { class: ITEM_TYPE.AMMUNITION, block: 1 }, output: `
1 Block` }, + { name: 'inventoryType, subclass', template: { class: ITEM_TYPE.AMMUNITION, subclass: 2 }, output: `
Arrow
` }, + { name: 'Skill, SkillRank', template: { + RequiredSkill: 1, RequiredReputationFaction: 1, RequiredReputationRank: 1, RequiredSkillRank: 10 + }, output: `
Requires: profession (10)
Requires mockGetFactionNameById1 (1)` }, + { name: 'Skill, Spell, Map, Area', template: { RequiredSkill: 1, requiredspell: 1, RequiredReputationFaction: 1, Map: 1, area: 1 }, + output: `
mockGetMapNameById1
mockGetAreaNameById1
Requires: profession
Requires mockGetSpellNameById1
Requires mockGetFactionNameById1` + }, + { name: 'Map, Area null check', template: { Map: 123, area: 123 }, output: `` }, + { name: 'Holy & Arcane resistances', template: { holy_res: 1, arcane_res: 1 }, output: `
+1 Holy Resistance
+1 Arcane Resistance` }, + { name: 'Gem Enchantment - success', template: { entry: 100, }, output: `
Helias
Requires less than 2 meta gems;
Requires more yellow gems than yellow gems` }, + { name: 'Gem Enchantment null check - 1 ', template: { entry: 101, }, output: `` }, + { name: 'Gem Enchantment null check - 2 ', template: { entry: 102, }, output: `` }, + { name: 'Gem Enchantment null check - 3 ', template: { entry: 103, }, output: `` }, + { name: 'Gem Enchantment null check - 4 ', template: { entry: 104, }, output: `` }, + { name: 'Gem Enchantment null check - 5 ', template: { entry: 105, }, output: `` }, + { name: 'Sockets ', template: { socketColor_1: 0, socketColor_2: 1, socketColor_3: 2, socketBonus: 1 }, output: `
Meta Socket
Red Socket
Socket Bonus: mockGetSocketBonusById1` }, + { name: 'getSpellDesc ', template: { + spellid_1: 1, spellid_2: 2, spellcooldown_1: 2, spellcooldown_2: 1, spellcategory_1: 1, spellcategory_2: 2 + }, output: `
mockGetSpellDescriptionById1
mockGetSpellDescriptionById2` }, + { name: 'getSpellDesc null check - 1', template: { spellid_1: 1, spellid_2: 2 }, output: `
mockGetSpellDescriptionById1
mockGetSpellDescriptionById2` }, + { name: 'getSpellDesc null check - 2', template: { spellid_1: 483, spellid_2: 555 }, output: `` }, + { name: 'getLearnSpellText', template: { spellid_1: 483, spellid_2: 124 }, output: `
Use: mockGetSpellDescriptionById124` }, + { name: 'Extended Cost - 1', template: { entry: 600, FlagsExtra: 0x04, BuyPrice: 1234 }, output: `
Requires personal and team arena rating of 2200 in 3v3 or 5v5 brackets`}, + { name: 'Extended Cost - 2', template: { entry: 600, FlagsExtra: 0x04 }, output: `
Requires personal and team arena rating of 2200 in 3v3 or 5v5 brackets`}, + { name: 'Extended Cost - 3', template: { entry: 600 }, output: `
Requires personal and team arena rating of 2200 in 3v3 or 5v5 brackets`}, + { name: 'Extended Cost - 4', template: { entry: 601 }, output: ``}, + ]; + + for (const { name, template, output } of cases) { + it(`Case ${name}`, async() => { + expect(await service.calculatePreview(template as ItemTemplate)).toEqual(output); + }); + } + +}); diff --git a/src/app/features/item/item-template/item-preview.service.ts b/src/app/features/item/item-template/item-preview.service.ts new file mode 100644 index 00000000000..bbb8e04ce75 --- /dev/null +++ b/src/app/features/item/item-template/item-preview.service.ts @@ -0,0 +1,1399 @@ +import { Injectable } from '@angular/core'; +import { MysqlQueryService } from '@keira-shared/services/mysql-query.service'; +import { SqliteQueryService } from '@keira-shared/services/sqlite-query.service'; +import { ITEM_TYPE, ITEM_MOD } from '@keira-shared/constants/options/item-class'; +import { ITEM_CONSTANTS } from './item-constants'; +import { MAX_LEVEL, lvlIndepRating, gtCombatRatings, CLASSES, RACE, resistanceFields } from './item-preview'; +import { ITEM_FLAG } from '@keira-shared/constants/flags/item-flags'; +import { ITEMS_QUALITY } from '@keira-shared/constants/options/item-quality'; +import { ItemTemplate } from '@keira-shared/types/item-template.type'; + +@Injectable() +export class ItemPreviewService { + private readonly ITEM_CONSTANTS = ITEM_CONSTANTS; + + /* istanbul ignore next */ // because of: https://github.com/gotwarlost/istanbul/issues/690 + constructor( + private readonly sqliteQueryService: SqliteQueryService, + private readonly mysqlQueryService: MysqlQueryService, + ) { } + + /** + * query functions + */ + + private getItemsetSlotBak(itemset: number | string): Promise { + return this.sqliteQueryService.query(`SELECT * FROM items WHERE itemset = ${itemset} ORDER BY slotBak, id`).toPromise(); + } + + private getItemNameByIDsASC(IDs: number[]): Promise { + return this.mysqlQueryService.query(`SELECT name FROM item_template WHERE entry IN (${IDs.join(',')}) ORDER BY entry ASC`).toPromise(); + } + + private getItemsetById(ID: number | string): Promise { + return this.sqliteQueryService.query(`SELECT * FROM itemset WHERE id = ${ID}`).toPromise(); + } + + private getItemLimitCategoryById(id: number | string): Promise { + return this.sqliteQueryService.query(`SELECT * FROM item_limit_category WHERE id = ${id}`).toPromise(); + } + + private getGemEnchantmentIdById(id: number | string): Promise { + return this.sqliteQueryService.queryValue(`SELECT gemEnchantmentId AS v FROM items WHERE id = ${id};`).toPromise(); + } + + private getItemEnchantmentById(id: number | string): Promise { + return this.sqliteQueryService.query(`SELECT * FROM item_enchantment WHERE id = ${id}`).toPromise(); + } + + private getItemExtendedCost(IDs: number[]): Promise { + return this.sqliteQueryService.query(`SELECT * FROM item_extended_cost WHERE id IN (${IDs.join(',')})`).toPromise(); + } + + private getItemEnchantmentConditionById(id: number | string): Promise { + return this.sqliteQueryService.query(`SELECT * FROM item_enchantment_condition WHERE id = ${id}`).toPromise(); + } + + private getItemExtendedCostFromVendor(id: number | string): Promise { + return this.mysqlQueryService.query(`SELECT + nv.item, + nv.entry, + 0 AS eventId, + nv.maxcount, + nv.extendedCost + FROM + npc_vendor nv + WHERE + nv.item = ${id} + UNION SELECT + genv.item, + c.id AS \`entry\`, + ge.eventEntry AS eventId, + genv.maxcount, + genv.extendedCost + FROM + game_event_npc_vendor genv + LEFT JOIN + game_event ge ON genv.eventEntry = ge.eventEntry + JOIN + creature c ON c.guid = genv.guid + WHERE + genv.item = ${id};`).toPromise(); + } + + /** + * utils + */ + + private setRatingLevel(level: number, type: number, val: number): string { + let result = ''; + const rating = [ + ITEM_MOD.DEFENSE_SKILL_RATING, + ITEM_MOD.DODGE_RATING, + ITEM_MOD.PARRY_RATING, + ITEM_MOD.BLOCK_RATING, + ITEM_MOD.RESILIENCE_RATING + ]; + + if (rating.includes(type) && level < 34) { + level = 34; + } + + if (gtCombatRatings[type]) { + let c: number; + if (level > 70) { + c = 82 / 52 * Math.pow(131 / 63, (level - 70) / 10); + } else if (level > 60) { + c = 82 / (262 - 3 * level); + } else if (level > 10) { + c = (level - 8) / 52; + } else { + c = 2 / 52; + } + // do not use localized number format here! + result = (val / gtCombatRatings[type] / c).toFixed(2); + } + + if (![ITEM_MOD.DEFENSE_SKILL_RATING, ITEM_MOD.EXPERTISE_RATING].includes(type)) { + result += '%'; + } + + return ITEM_CONSTANTS.ratingString + .replace('%s', `${result}`) + .replace('%s', `${level}`); + } + + private parseRating(type: number, value: number, requiredLevel: number): string { + // clamp level range + const level = requiredLevel > 1 ? requiredLevel : MAX_LEVEL; + + // unknown rating + if ([2, 8, 9, 10, 11].includes(type) || type > ITEM_MOD.BLOCK_VALUE || type < 0 || !ITEM_CONSTANTS.statType[type]) { + return ''; + } + + if (lvlIndepRating.includes(type)) { // level independant Bonus + return ITEM_CONSTANTS.trigger[1] + ITEM_CONSTANTS.statType[type].replace('%d', `${value}`); + } + + // rating-Bonuses + const js = ` (${this.setRatingLevel(level, type, value)})`; + return ITEM_CONSTANTS.trigger[1] + ITEM_CONSTANTS.statType[type].replace('%d', `${value}${js}`); + } + + private getRequiredClass(classMask: number): string[] { + classMask &= CLASSES.MASK_ALL; // clamp to available classes.. + + if (classMask === CLASSES.MASK_ALL) { // available to all classes + return null; + } + + const tmp = []; + let i = 1; + while (classMask) { + if (classMask & (1 << (i - 1))) { + const tmpClass = ITEM_CONSTANTS.cl[i]; + /* istanbul ignore else */ + if (!!tmpClass) { + tmp.push(`${tmpClass}`); + } + + classMask &= ~(1 << (i - 1)); + } + i++; + } + + return tmp; + } + + private getRaceString(raceMask: number): string[] { + // clamp to available races + raceMask &= RACE.MASK_ALL; + // available to all races (we don't display 'both factions') + if (!raceMask || raceMask === RACE.MASK_ALL) { + return null; + } + + if (raceMask === RACE.MASK_HORDE) { + return [ITEM_CONSTANTS.ra['-2']]; + } + + if (raceMask === RACE.MASK_ALLIANCE) { + return [ITEM_CONSTANTS.ra['-1']]; + } + + const tmp = []; + let i = 1; + while (raceMask) { + if (raceMask & (1 << (i - 1))) { + const tmpRace = ITEM_CONSTANTS.ra[i]; + /* istanbul ignore else */ + if (!!tmpRace) { + tmp.push(tmpRace); + } + raceMask &= ~(1 << (i - 1)); + } + i++; + } + + return tmp; + } + + private formatMoney(qty: number): string { + let money = ''; + + if (qty >= 10000) { + const g = Math.floor(qty / 10000); + money += `${g}  `; + qty -= g * 10000; + } + + if (qty >= 100) { + const s = Math.floor(qty / 100); + money += `${s}  `; + qty -= s * 100; + } + + money += `${qty}  `; + + return money; + } + + private parseTime(sec: number) { + const time = { + d: 0, + h: 0, + m: 0, + s: 0, + ms: 0, + }; + + if (sec >= 3600 * 24) { + time['d'] = Math.floor(sec / 3600 / 24); + sec -= time['d'] * 3600 * 24; + } + + if (sec >= 3600) { + time.h = Math.floor(sec / 3600); + sec -= time['h'] * 3600; + } + + if (sec >= 60) { + time.m = Math.floor(sec / 60); + sec -= time.m * 60; + } + + if (sec >= 1) { + time['s'] = sec; + sec -= time['s']; + } + + if ((sec * 1000) % 1000) { + time.ms = sec * 1000; + } + + return time; + } + + private formatTime(base: number) { + const s = this.parseTime(base / 1000); + let tmp: number; + + tmp = s.d + s.h / 24; + if (tmp > 1 && !(tmp % 364)) { // whole years + return Math.round((s.d + s.h / 24) / 364) + ' ' + ITEM_CONSTANTS.timeUnits[Math.abs(s.d / 364) === 1 && !s.h ? 'sg' : 'pl'][0]; + } + if (tmp > 1 && !(tmp % 30)) { // whole month + return Math.round((s.d + s.h / 24) / 30) + ' ' + ITEM_CONSTANTS.timeUnits[Math.abs(s.d / 30) === 1 && !s.h ? 'sg' : 'pl'][1]; + } + if (tmp > 1 && !(tmp % 7)) { // whole weeks + return Math.round((s.d + s.h / 24) / 7) + ' ' + ITEM_CONSTANTS.timeUnits[Math.abs(s.d / 7) === 1 && !s.h ? 'sg' : 'pl'][2]; + } + if (s.d !== 0) { + return Math.round(s.d + s.h / 24) + ' ' + ITEM_CONSTANTS.timeUnits[Math.abs(s.d) === 1 && !s.h ? 'sg' : 'pl'][3]; + } + if (s.h !== 0) { + return Math.round(s.h + s.m / 60) + ' ' + ITEM_CONSTANTS.timeUnits[Math.abs(s.h) === 1 && !s.m ? 'sg' : 'pl'][4]; + } + if (s.m !== 0) { + return Math.round(s.m + s.s / 60) + ' ' + ITEM_CONSTANTS.timeUnits[Math.abs(s.m) === 1 && !s.s ? 'sg' : 'pl'][5]; + } + if (s.s !== 0) { + return Math.round(s.s + s.ms / 1000) + ' ' + ITEM_CONSTANTS.timeUnits[Math.abs(s.s) === 1 && !s.ms ? 'sg' : 'pl'][6]; + } + if (s.ms !== 0) { + return s.ms + ' ' + ITEM_CONSTANTS.timeUnits[ Math.abs(s.ms) === 1 ? 'sg' : 'pl'][7]; + } + + return '0 ' + ITEM_CONSTANTS.timeUnits.pl[6]; + } + + private getFeralAP(itemClass: number, subclass: number, dps: number): number { + // must be weapon + if (itemClass !== ITEM_TYPE.WEAPON) { + return 0; + } + + // must be 2H weapon (2H-Mace, Polearm, Staff, ..Fishing Pole) + if (![5, 6, 10, 20].includes(subclass)) { + return 0; + } + + // must have enough damage + if (dps < 54.8) { + return 0; + } + + return Math.round((dps - 54.8) * 14); + } + + private canTeachSpell(spellId1: number, spellId2: number = null): boolean { + // 483: learn recipe; + // 55884: learn mount/pet + if (![483, 55884].includes(spellId1)) { + return false; + } + + // needs learnable spell + return !!spellId2; + } + + private async getLocks(lockId: number): Promise { + /* istanbul ignore next */ + if (!lockId) { return ['']; } + + const lock = (await this.sqliteQueryService.getLockById(lockId))[0]; + + if (!lock) { + return []; + } + + const locks = []; + + for (let i = 1; i <= 5; i++) { + const prop = lock['properties' + i]; + const rank = lock['reqSkill' + i]; + let name = ''; + + const lockType = Number(lock['type' + i]); + + if (lockType === 1) { // opened by item + name = await this.mysqlQueryService.getItemNameById(prop); + + if (!name) { + continue; + } + } else if (lockType === 2) { // opened by skill + + // exclude unusual stuff + if (!ITEM_CONSTANTS.lockType[prop] || ![1, 2, 3, 4, 9, 16, 20].includes(Number(prop))) { + continue; + } + + name = ITEM_CONSTANTS.lockType[prop]; + + if (rank > 0) { + name += ` (${rank})`; + } + } else { + continue; + } + + locks.push(`
Requires ${name}`); + } + + return locks; + } + + // todo (med): information will get lost if one vendor sells one item multiple times with different costs (e.g. for item 54637) + // wowhead seems to have had the same issues + private async getExtendedCost(entry: number, flagsExtra: number, buyPrice: number): Promise { + + if (!entry) { return []; } + + const itemz = {}; + let xCostData = []; + const xCostDataArr = {}; + const rawEntries = await this.getItemExtendedCostFromVendor(entry); + + if (!rawEntries) { + return []; + } + + for (const costEntry of rawEntries) { + /* istanbul ignore else */ + if (costEntry.extendedCost) { + xCostData.push(costEntry.extendedCost); + } + + /* istanbul ignore next */ + if (itemz[costEntry.item] && itemz[costEntry.item][costEntry.entry]) { + itemz[costEntry.item][costEntry.entry] = [costEntry]; + } else { + itemz[costEntry.item] = {}; + itemz[costEntry.item][costEntry.entry] = []; + itemz[costEntry.item][costEntry.entry].push(costEntry); + } + } + + if ( + /* istanbul ignore next */ + !!xCostData + && xCostData.length > 0) { + xCostData = Array.from(new Set(xCostData)); // filter duplicates + xCostData = await this.getItemExtendedCost(xCostData); + + /* istanbul ignore else */ + if (!!xCostData && xCostData.length > 0) { + + // converting xCostData to ARRAY_KEY structure + for (const xCost of xCostData) { + xCostDataArr[xCost.id] = xCost; + } + } else { + /* istanbul ignore next */ + return []; + } + } + + const cItems = []; + + for (const [k, vendors] of Object.entries(itemz)) { + for (const [l, vendor] of Object.entries(vendors)) { + for (const [m, vInfo] of Object.entries(vendor)) { + + let costs = []; + /* istanbul ignore else */ + if (xCostDataArr[vInfo['extendedCost']] && Object.keys(xCostDataArr[vInfo['extendedCost']]).length > 0) { + costs = xCostDataArr[vInfo['extendedCost']]; + } + + const data = { + stock: vInfo['maxcount'] ?? + /* istanbul ignore next */ + -1, + event: vInfo['eventId'], + reqRating: costs + ? costs['reqPersonalRating'] + /* istanbul ignore next */ + : 0, + /* istanbul ignore next */ + reqBracket: costs + ? costs['reqArenaSlot'] + /* istanbul ignore next */ + : 0 + }; + + // hardcode arena(103) & honor(104) + if (costs['reqArenaPoints'] > 0) { + data[-103] = costs['reqArenaPoints']; + } + + if (costs['reqHonorPoints'] > 0) { + data[-104] = costs['reqHonorPoints']; + } + + for (let i = 1; i < 6; i++) { + if (costs['reqItemId' + i] /* && costs['reqItemId' + i].length > 0 */ + && costs['itemCount' + i] && costs['itemCount' + i] > 0) { + data[costs['reqItemId' + i]] = costs['itemCount' + i]; + cItems.push(costs['reqItemId' + i]); + } + } + + // no extended cost or additional gold required + if (!costs || flagsExtra & 0x04) { + if (!!buyPrice) { + data[0] = buyPrice; + } + } + + vendor[m] = data; + } + vendors[l] = vendor; + } + + itemz[k] = vendors; + } + + // convert items to currency if possible + /* istanbul ignore else */ + if (!!cItems) { + + for (const [itemId, vendors] of Object.entries(itemz)) { + for (const [npcId, costData] of Object.entries(vendors)) { + for (const [itr, cost] of Object.entries(costData)) { + for (const [k, v] of Object.entries(cost)) { + if (cItems.includes(Number(k))) { + let found = false; + for (const item of cItems) { + if (item === Number(k)) { + delete cost[Number(k)]; + cost[-item.id] = v; + found = true; + break; + } + } + } + } + costData[itr] = cost; + } + vendors[npcId] = costData; + } + itemz[itemId] = vendors; + } + } + + const result = itemz; + + let reqRating = []; + for (const [itemId, data] of Object.entries(result)) { + reqRating = []; + for (const [npcId, entries] of Object.entries(data)) { + for (const costs of entries) { + // reqRating isn't really a cost .. so pass it by ref instead of return + // use highest total value + if (data[npcId] && + costs['reqRating'] && + /* istanbul ignore next */ + (reqRating.length === 0 || (reqRating && reqRating[0] < costs['reqRating'])) + ) { + reqRating = [costs['reqRating'], costs['reqBracket']]; + } + } + } + + /* istanbul ignore next */ + if (!(data)) { + delete result[itemId]; + } + } + + return [result, reqRating]; + } + + /** + * get item preview text + */ + + private getDamageText(itemTemplate: ItemTemplate): string { + // Weapon/Ammunition Stats (not limited to weapons (see item:1700)) + const itemClass: number = Number(itemTemplate.class); + const subclass: number = Number(itemTemplate.subclass); + const dmgmin1: number = Number(itemTemplate.dmg_min1); + const dmgmin2: number = Number(itemTemplate.dmg_min2); + const dmgmax1: number = Number(itemTemplate.dmg_max1); + const dmgmax2: number = Number(itemTemplate.dmg_max2); + const speed = itemTemplate.delay / 1000; + const sc1 = itemTemplate.dmg_type1; + const sc2 = itemTemplate.dmg_type2; + const dmgmin = dmgmin1 + dmgmin2; + const dmgmax = dmgmax1 + dmgmax2; + const dps = speed ? (dmgmin + dmgmax) / (2 * speed) : 0; + + let damageText = ''; + let dmg = ''; + + if (itemClass === ITEM_TYPE.AMMUNITION && dmgmin && dmgmax) { + if (sc1) { + damageText += ITEM_CONSTANTS.damage.ammo[1].replace('%d', ((dmgmin + dmgmax) / 2).toString()).replace('%s', ITEM_CONSTANTS.sc[sc1]); + } else { + damageText += ITEM_CONSTANTS.damage.ammo[0].replace('%d', ((dmgmin + dmgmax) / 2).toString()); + } + } else if (dps) { + if (dmgmin1 === dmgmax1) { + dmg = ITEM_CONSTANTS.damage.single[sc1 ? 1 : 0] + .replace('%d', String(dmgmin1)) + .replace('%s', (sc1 ? ITEM_CONSTANTS.sc[sc1] : '')); + } else { + dmg = ITEM_CONSTANTS.damage.range[sc1 ? 1 : 0] + .replace('%d', String(dmgmin1)) + .replace('%d', String(dmgmax1)) + .replace('%s', sc1 ? ITEM_CONSTANTS.sc[sc1] : ''); + } + + if (itemClass === ITEM_TYPE.WEAPON) { + damageText += `
${dmg}    Speed ${speed.toFixed(2)}
`; + } else { + damageText += `
${dmg}`; + } + + // secondary damage is set + /* istanbul ignore next */ + if ((dmgmin2 || dmgmax2) && dmgmin2 !== dmgmax2) { + damageText += ITEM_CONSTANTS.damage.range[sc2 ? 3 : 2] + .replace('%d', String(dmgmin2)) + .replace('%d', String(dmgmax2)) + .replace('%s', sc2 ? ITEM_CONSTANTS.sc[sc2] : ''); + } else if (dmgmin2) { + damageText += ITEM_CONSTANTS.damage.single[sc2 ? 3 : 2] + .replace('%d', String(dmgmin2)) + .replace('%s', sc2 ? ITEM_CONSTANTS.sc[sc2] : ''); + } + + if (itemClass === ITEM_TYPE.WEAPON) { + damageText += `
${ITEM_CONSTANTS.dps.replace('%.1f', dps.toFixed(2))}`; + } + + // display FeralAttackPower if set + const fap = this.getFeralAP(itemClass, subclass, dps); + if (fap) { + damageText += `
(${fap} ${ITEM_CONSTANTS.fap})`; + } + } + + return damageText; + } + + private getStats(itemTemplate: ItemTemplate, greenText: string[]): string { + let stats = ''; + + const requiredLevel = Number(itemTemplate.RequiredLevel); + + for (let i = 1; i <= 10; i++) { + const type = Number(itemTemplate['stat_type' + i]); + const qty = Number(itemTemplate['stat_value' + i]); + + if (!qty || !type || type <= 0) { + continue; + } + // base stat + switch (type) { + case ITEM_MOD.MANA: + case ITEM_MOD.HEALTH: + // type += 1; // offsets may be required somewhere + case ITEM_MOD.AGILITY: + case ITEM_MOD.STRENGTH: + case ITEM_MOD.INTELLECT: + case ITEM_MOD.SPIRIT: + case ITEM_MOD.STAMINA: + stats += `
${(qty > 0 ? '+' : '-') + Math.abs(qty)} ${ITEM_CONSTANTS.statType[type]}`; + break; + default: // rating with % for reqLevel + greenText.push(this.parseRating(type, qty, requiredLevel)); + } + } + + return stats; + } + + private async getItemSet(entry: number, itemset: number): Promise { + if (!itemset) { + return ''; + } + + let itemsetText = ''; + + const itemsetPieces = await this.getItemsetSlotBak(itemset); + + if (!itemsetPieces || itemsetPieces.length === 0) { return ''; } + + // check if there are multiple itemset with the same itemset ID + let multipleItemset = false; + + if (itemsetPieces && itemsetPieces.length > 10) { + multipleItemset = true; + } else { + const slotBak = []; + for (const p of itemsetPieces) { + if (slotBak.includes(p.slotBak)) { + multipleItemset = true; + break; + } else { + slotBak.push(p.slotBak); + } + } + } + + // get pieces IDs + const piecesIDs = []; + + if (multipleItemset) { + // detect position of current item + let position = 0; + let currentSlotBak = itemsetPieces[0].slotBak; + for (let i = 0; i < itemsetPieces.length; i++) { + if (currentSlotBak !== itemsetPieces[i].slotBak) { + currentSlotBak = itemsetPieces[i].slotBak; + position = 0; + } + + position++; + if (itemsetPieces[i].id === entry) { + break; + } + } + + // get itemsetPieces IDs + let pos = 0; + for (const p of itemsetPieces) { + if (p.slotBak !== currentSlotBak) { + currentSlotBak = p.slotBak; + pos = 0; + } + + pos++; + + /* istanbul ignore else */ + if (pos === position) { + piecesIDs.push(p.id); + } + } + } else { + for (const p of itemsetPieces) { + piecesIDs.push(p.id); + } + } + + piecesIDs.sort(); + + // get items name + const itemsName: any[] = await this.getItemNameByIDsASC(piecesIDs); + + if (!itemsName || (itemsName && itemsName.length === 0)) { + return ''; + } + + for (let i = 0; i < itemsName.length; i++) { + itemsName[i] = itemsName[i].name ?? ''; + } + + let itemsetAttr = await this.getItemsetById(itemset); + + if (!itemsetAttr || itemsName.join('') === '' || !itemsetAttr[0]) { + return ''; + } else { + itemsetAttr = itemsetAttr[0]; + } + + itemsetText += '

' + ITEM_CONSTANTS.setName + .replace('%s', `${itemsetAttr['name'] ?? ''}`) + .replace('%d', '0') + .replace('%d', itemsName.length.toString()) + + ''; + + // if require skill + if (!!itemsetAttr['skillId']) { + itemsetText += `
Requires ${await this.sqliteQueryService.getSkillNameById(itemsetAttr['skillId'])}`; + + if (!!itemsetAttr['skillLevel']) { + itemsetText += ` (${itemsetAttr['skillLevel']})`; + } + } + + // list pieces + itemsetText += `
${itemsName.join('
')}
`; + + // get bonuses + const setSpellsAndIdx = []; + + for (let j = 1; j <= 8; j++) { + const spell = itemsetAttr['spell' + j]; + + if (!!spell) { + setSpellsAndIdx[spell] = j; + } + } + + const setSpells = []; + if (setSpellsAndIdx && setSpellsAndIdx.length > 0) { + const spellsIDs = Object.keys(setSpellsAndIdx); + for (const s of spellsIDs) { + setSpells.push({ + tooltip: await this.sqliteQueryService.getSpellDescriptionById(s), + entry: itemsetAttr['spell' + setSpellsAndIdx[s]], + bonus: itemsetAttr['bonus' + setSpellsAndIdx[s]], + }); + } + } + + // sort and list bonuses + let tmpBonus = ''; + for (let i = 0; i < setSpells.length; i++) { + for (let j = i; j < setSpells.length; j++) { + if (setSpells[j]['bonus'] >= setSpells[i]['bonus']) { + continue; + } + + const tmp = setSpells[i]; + setSpells[i] = setSpells[j]; + setSpells[j] = tmp; + } + + const bonusText = setSpells[i]['bonus'] && setSpells[i]['tooltip'] + ? ITEM_CONSTANTS.setBonus + .replace('%d', setSpells[i]['bonus']) + .replace('%s', setSpells[i]['tooltip']) + : ''; + + if (!!bonusText) { + tmpBonus += `
${bonusText}`; + } + } + + if (tmpBonus !== '') { + itemsetText += `${tmpBonus}`; + } + + return itemsetText; + } + + private async getBonding(itemTemplate: ItemTemplate): Promise { + let bondingText = ''; + + const flags = itemTemplate.Flags; + const bonding: number = Number(itemTemplate.bonding); + const maxcount: number = Number(itemTemplate.maxcount); + const bagFamily: number = Number(itemTemplate.BagFamily); + const itemLimitCategory = itemTemplate.ItemLimitCategory; + + // bonding + if (flags & ITEM_FLAG.ACCOUNTBOUND) { + bondingText += '
' + this.ITEM_CONSTANTS.bonding[0]; + } else if (bonding) { + bondingText += '
' + this.ITEM_CONSTANTS.bonding[bonding]; + } + + // unique || unique-equipped || unique-limited + if (maxcount === 1) { + bondingText += '
' + this.ITEM_CONSTANTS['unique'][0]; + } else if (!!maxcount && bagFamily !== 8192) { // not for currency tokens + bondingText += '
' + this.ITEM_CONSTANTS['unique'][1].replace('%d', maxcount.toString()); + } else if (flags & ITEM_FLAG.UNIQUEEQUIPPED) { + bondingText += '
' + this.ITEM_CONSTANTS['uniqueEquipped'][0]; + } else if (!!itemLimitCategory) { + let limit: any = await this.getItemLimitCategoryById(itemLimitCategory); + + if (limit && limit.length > 0) { + limit = limit[0]; + + const index = limit && limit.isGem ? 'uniqueEquipped' : 'unique'; + bondingText += `
${ + ITEM_CONSTANTS[index][2].replace('%s', limit.name).replace('%d', limit.count) + }`; + } + } + + return bondingText; + } + + private getClassText(inventoryType: number, itemClass: number, subclass: number): string { + let classText = ''; + + let textRight = ''; + if ([ITEM_TYPE.ARMOR, ITEM_TYPE.WEAPON, ITEM_TYPE.AMMUNITION].includes(itemClass)) { + let classTmpText = ''; + + // Class + if (inventoryType) { + classTmpText += ``; + textRight = ' style="text-align: right;"'; + } + + // Subclass + /* istanbul ignore else */ + if (itemClass === ITEM_TYPE.ARMOR && subclass > 0) { + classTmpText += `${ITEM_CONSTANTS.armorSubClass[subclass]}`; + } else if (itemClass === ITEM_TYPE.WEAPON) { + classTmpText += ITEM_CONSTANTS.weaponSubClass[subclass] ? `${ITEM_CONSTANTS.weaponSubClass[subclass]}` : ''; + } else if (itemClass === ITEM_TYPE.AMMUNITION) { + classTmpText += ITEM_CONSTANTS.projectileSubClass[subclass] + ? `${ITEM_CONSTANTS.projectileSubClass[subclass]}` + : ''; + } + + classTmpText += '
${ITEM_CONSTANTS.inventoryType[inventoryType]}
'; + + if (classTmpText !== '
') { + classText += classTmpText; + } + + // inventoryType/slot can occur on random items and is then also displayed <_< .. excluding Bags >_> + } else if (inventoryType && itemClass !== ITEM_TYPE.CONTAINER && !!ITEM_CONSTANTS.inventoryType[subclass]) { + classText += `
${ITEM_CONSTANTS.inventoryType[subclass]}`; + } + + return classText; + } + + private getArmorText(itemTemplate: ItemTemplate): string { + let armorText = ''; + + // Armor + const armorDamageModifier = itemTemplate.ArmorDamageModifier; + const armor = itemTemplate.armor; + const itemClass: number = Number(itemTemplate.class); + if (itemClass === ITEM_TYPE.ARMOR && armorDamageModifier > 0 && !!armor) { + armorText += `
${ITEM_CONSTANTS.armor.replace('%s', String(armor))}`; + } else if (armor) { + armorText += `
${ITEM_CONSTANTS.armor.replace('%s', String(armor))}`; + } + + // Block (note: block value from field block and from field stats or parsed from itemSpells are displayed independently) + const block = itemTemplate.block; + if (block) { + armorText += `
${ITEM_CONSTANTS.block.replace('%s', String(block))}`; + } + + return armorText; + } + + private async getRequiredText(itemTemplate: ItemTemplate): Promise { + let requiredText = ''; + + // required classes + const classes = this.getRequiredClass(itemTemplate.AllowableClass); + if (classes != null && classes.length > 0) { + requiredText += `
Classes: ${classes.join(', ')}`; + } + + // required races + const races = this.getRaceString(itemTemplate.AllowableRace); + if (races) { + requiredText += `
Races: ${races.join(', ')}`; + } + + // required honorRank (not used anymore) + if (!!itemTemplate.requiredhonorrank) { + requiredText += `
Requires ${ITEM_CONSTANTS.pvpRank[itemTemplate.requiredhonorrank]}`; + } + + // required CityRank -> the value is always 0 + + // required level + if ((itemTemplate.Flags & ITEM_FLAG.ACCOUNTBOUND) && itemTemplate.Quality === ITEMS_QUALITY.HEIRLOOM) { + + requiredText += '
' + ITEM_CONSTANTS.reqLevelRange + .replace('%d', '1') + .replace('%d', MAX_LEVEL.toString()) + .replace('%s', MAX_LEVEL.toString()); + + } else if (itemTemplate.RequiredLevel > 1) { + requiredText += '
' + ITEM_CONSTANTS.reqMinLevel.replace('%d', String(itemTemplate.RequiredLevel)); + } + + // required arena team rating / personal rating / todo (low): sort out what kind of rating + const [res, reqRating] = await this.getExtendedCost(itemTemplate.entry, itemTemplate.FlagsExtra, itemTemplate.BuyPrice); + + if (!!res && !!reqRating && res[itemTemplate.entry] && Object.keys(res[itemTemplate.entry]).length > 0 && reqRating.length > 0) { + requiredText += '
' + ITEM_CONSTANTS.reqRating[reqRating[1]].replace('%d', reqRating[0]); + } + + // item level + const itemClass = Number(itemTemplate.class); + const itemLevel = itemTemplate.ItemLevel; + if (itemLevel > 0 && [ITEM_TYPE.ARMOR, ITEM_TYPE.WEAPON].includes(itemClass)) { + requiredText += `
${ITEM_CONSTANTS.itemLevel.replace('%d', String(itemLevel))}`; + } + + // required skill + const requiredSkill = itemTemplate.RequiredSkill; + const requiredSkillRank = itemTemplate.RequiredSkillRank; + if (!!requiredSkill && requiredSkill > 0) { + let reqSkill = await this.sqliteQueryService.getSkillNameById(requiredSkill); + if (requiredSkillRank > 0) { + reqSkill += ` (${requiredSkillRank})`; + } + + requiredText += `
Requires: ${reqSkill}`; + } + + // required spell + const requiredSpell = itemTemplate.requiredspell; + if (!!requiredSpell && requiredSpell > 0) { + requiredText += `
Requires ${await this.sqliteQueryService.getSpellNameById(requiredSpell)}`; + } + + // required reputation w/ faction + const requiredFaction = itemTemplate.RequiredReputationFaction; + const requiredFactionRank = itemTemplate.RequiredReputationRank; + if (!!requiredFaction && requiredFaction > 0) { + let reqFaction = await this.sqliteQueryService.getFactionNameById(requiredFaction); + if (requiredFactionRank > 0) { + reqFaction += ` (${requiredFactionRank})`; + } + requiredText += `
Requires ${reqFaction}`; + } + + return requiredText; + } + + private async getRequiredZone(map: number, area: number): Promise { + let requiredZone = ''; + + // require map + if (!!map) { + const mapName = await this.sqliteQueryService.getMapNameById(map); + requiredZone += mapName && mapName !== '' ? `
${mapName}` : ''; + } + + // require area + if (!!area) { + const areaName = await this.sqliteQueryService.getAreaNameById(area); + requiredZone += areaName && areaName !== '' ? `
${areaName}` : ''; + } + + return requiredZone; + } + + private getDuration(duration: number, flagsCustom: number): string { + let durationText = ''; + + // max duration + if (duration) { + let rt = ''; + if (flagsCustom & 0x1) { // if CU_DURATION_REAL_TIME + rt = ' (real time)'; + } + durationText += `
Duration: ${this.formatTime(duration * 1000)}${rt}`; + } + + return durationText; + } + + private getMagicResistances(itemTemplate: ItemTemplate): string { + let magicRsistances = ''; + + // magic resistances + resistanceFields.forEach((rowName, idx) => { + const resField = itemTemplate[rowName + '_res']; + if (rowName != null && resField != null && resField !== 0) { + magicRsistances += `
+${resField} ${ITEM_CONSTANTS.resistances[idx]}`; + } + }); + + return magicRsistances; + } + + private getMisc(itemTemplate: ItemTemplate): string { + const xMisc = []; + + const spellId1 = itemTemplate.spellid_1; + const spellId2 = itemTemplate.spellid_2; + const description = itemTemplate.description; + + // yellow text at the bottom, omit if we have a recipe + if (!!description && !this.canTeachSpell(spellId1, spellId2)) { + xMisc.push(`
"${description}"`); + } + + // readable + const PageText = itemTemplate.PageText; + if (PageText > 0) { + xMisc.push(`
${ITEM_CONSTANTS.readClick}`); + } + + // charges (I guess, checking first spell is enough) + const spellCharges1 = itemTemplate.spellcharges_1; + if (!!spellCharges1) { + + let charges = ITEM_CONSTANTS.charges.replace('%d', Math.abs(spellCharges1).toString()); + if (Math.abs(spellCharges1) === 1) { + charges = charges.replace('Charges', 'Charge'); + } + + /* istanbul ignore else */ + if (!!charges && charges !== '') { + xMisc.push(`
${charges}`); + } + } + + return xMisc.length > 0 ? xMisc.join('') : ''; + } + + private async getGemEnchantment(entry: number): Promise { + if (!entry) { return ''; } + + let gemEnchantmentText = ''; + const gemEnchantmentId = await this.getGemEnchantmentIdById(entry); + + if (!!gemEnchantmentId) { + let gemEnch = await this.getItemEnchantmentById(gemEnchantmentId); + if (!gemEnch || (gemEnch && gemEnch.length === 0)) { return ''; } + + gemEnch = gemEnch[0]; + + if (!!gemEnch['name'] && gemEnch['name'] !== '') { + gemEnchantmentText += `
${gemEnch['name']}`; + } + + // activation conditions for meta gems + if (!!gemEnch['conditionId']) { + + let gemCnd = await this.getItemEnchantmentConditionById(gemEnch['conditionId']); + if (!gemCnd || (gemCnd && gemCnd.length === 0)) { return ''; } + + gemCnd = gemCnd[0]; + + if (!!gemCnd) { + + for (let i = 1; i < 6; i++) { + const gemCndColor = Number(gemCnd[`color${i}`]); + + if (!gemCndColor) { + continue; + } + + const gemCndCmpColor = Number(gemCnd[`cmpColor${i}`]); + const gemCndComparator = Number(gemCnd[`comparator${i}`]); + const gemCndValue = Number(gemCnd[`value${i}`]); + + let vspfArgs: any = ['', '']; + + switch (gemCndComparator) { + case 2: // requires less than ( || ) gems + case 5: // requires at least than ( || ) gems + vspfArgs = [ + gemCndValue, + ITEM_CONSTANTS['gemColors'][gemCndColor - 1], + ]; + break; + case 3: // requires more than ( || ) gems + vspfArgs = [ + ITEM_CONSTANTS['gemColors'][gemCndColor - 1], + ITEM_CONSTANTS['gemColors'][gemCndCmpColor - 1], + ]; + break; + default: + break; + } + + if (vspfArgs[0] === '' && vspfArgs[1] === '') { + continue; + } + + let gemEnchText = ITEM_CONSTANTS['gemConditions'][gemCndComparator]; + + /* istanbul ignore next */ + if (!!vspfArgs[0] && !!vspfArgs[1]) { + gemEnchText = gemEnchText.replace('%s', vspfArgs[0]).replace('%s', vspfArgs[1]); + } + + gemEnchantmentText += `
Requires ${gemEnchText}`; + } + } + } + } + + return gemEnchantmentText; + } + + private async getSocketEnchantment(itemTemplate: ItemTemplate): Promise { + + let socketText = ''; + + // fill native sockets + for (let j = 1; j <= 3; j++) { + const socketColor = Number(itemTemplate['socketColor_' + j]); + + if (!socketColor) { + continue; + } + + let colorId = 0; + for (let i = 0; i < 4; i++) { + if (socketColor & (1 << i)) { + colorId = i; + } + } + + socketText += `
${ITEM_CONSTANTS.socket[colorId]}`; + } + + const socketBonus = itemTemplate.socketBonus; + if (!!socketBonus) { + const sbonus = await this.sqliteQueryService.getSocketBonusById(socketBonus); + const socketBonusText = `${ITEM_CONSTANTS.socketBonus.replace('%s', sbonus)}`; + socketText += `
${socketBonusText}`; + } + + return socketText; + } + + private async getSpellDesc(itemTemplate: ItemTemplate, green: string[]) { + const spellId1 = itemTemplate.spellid_1; + const spellId2 = itemTemplate.spellid_2; + + if (!this.canTeachSpell(spellId1, spellId2)) { + const itemSpellsAndTrigger = []; + for (let j = 1; j <= 5; j++) { + const spellid = itemTemplate['spellid_' + j]; + + if (spellid > 0) { + let cooldown = itemTemplate['spellcooldown_' + j]; + const cooldownCategory = itemTemplate['spellcategory_' + j]; + + if (cooldown < cooldownCategory) { + cooldown = cooldownCategory; + } + + cooldown = cooldown < 5000 ? '' : ` ( ${this.formatTime(Number(cooldown))} cooldown)`; + + itemSpellsAndTrigger[spellid] = [itemTemplate['spelltrigger_' + j], cooldown]; + } + } + + if (itemSpellsAndTrigger && itemSpellsAndTrigger.length > 0) { + const spellIDs = Object.keys(itemSpellsAndTrigger); + for (const spellID of spellIDs) { + const spellTrigger = itemSpellsAndTrigger[spellID]; + const parsed = await this.sqliteQueryService.getSpellDescriptionById(spellID); // TODO: parseText correctly + + /* istanbul ignore next */ + if (spellTrigger[0] || parsed || spellTrigger[1]) { + /* istanbul ignore next */ + green.push(ITEM_CONSTANTS.trigger[spellTrigger[0]] ?? '' + parsed ?? '' + ' ' + ITEM_CONSTANTS.trigger[spellTrigger[1]] ?? ''); + } + } + + } + } + } + + // TODO: recipes, vanity pets, mounts + private async getLearnSpellText(itemTemplate: ItemTemplate): Promise { + /* TODO - WIP */ + + let spellDesc = ''; + + // const bagFamily: number = Number(itemTemplate.BagFamily); + // const itemClass: number = Number(itemTemplate.class); + const spellId1 = itemTemplate.spellid_1; + const spellId2 = itemTemplate.spellid_2; + + if (!spellId1 || !spellId2) { return ''; } + + if (this.canTeachSpell(spellId1, spellId2)) { + const craftSpell = spellId2; + + if ( + /* istanbul ignore next */ + !!craftSpell + ) { + // let xCraft = ''; + + const desc = await this.sqliteQueryService.getSpellDescriptionById(spellId2); + + if (!!desc) { + spellDesc += `
${ITEM_CONSTANTS.trigger[0]} ${desc}`; + } + + // TODO: spell description for recipe + // // recipe handling (some stray Techniques have subclass == 0), place at bottom of tooltipp + // if (itemClass === ITEM_CLASS_RECIPE || bagFamily === 16) { + // let craftItem = craftSpell->curTpl['effect1CreateItemId']; + + // if (!!craftItem) { + + // const reagentItems = {}; + + // for (let i = 1; i <= 8; i++) { + // if (rId = craftSpell->getField('reagent' + i)) { + // reagentItems[rId] = craftSpell->getField('reagentCount' + i); + // } + // } + + // if (!!xCraft && !!reagentItems) { + // let reagents = Object.keys(reagentItems); + // let reqReag = []; + + // for (const _ of reagents) { + // reqReag.push(`${reagents->getField('name', true)} (${reagentItems[reagents->id]})`); + // } + + // xCraft += '

Requires: ' + reqReag.join(', ') + '
'; + // } + // } + // } + } + } + + return spellDesc; + } + + // locked or openable + private async getLockText(flags: number, lockid: number): Promise { + let lockText = ''; + + if (!!lockid) { + const lockData = await this.getLocks(lockid); + + if (!!lockData && lockData.length > 0) { + lockText += `
Locked${lockData.join('')}`; + } else if (flags & ITEM_FLAG.OPENABLE) { + lockText += `
${ITEM_CONSTANTS.openClick}`; + } + } + + return lockText; + } + + public async calculatePreview(itemTemplate: ItemTemplate): Promise { + let tmpItemPreview = ''; + const green: string[] = []; + + const flags = itemTemplate.Flags; + const bagFamily: number = Number(itemTemplate.BagFamily); + const quality: number = Number(itemTemplate.Quality); + + // ITEM NAME + const itemName = itemTemplate.name; + if (itemName) { + tmpItemPreview += `${itemName}`; + } + + // heroic tag + if (flags & ITEM_FLAG.HEROIC && quality === ITEMS_QUALITY.EPIC) { + tmpItemPreview += '
Heroic'; + } + + tmpItemPreview += await this.getRequiredZone(Number(itemTemplate.Map), Number(itemTemplate.area)); + + // conjured + if (flags & ITEM_FLAG.CONJURED) { + tmpItemPreview += '
Conjured Item'; + } + + tmpItemPreview += await this.getBonding(itemTemplate); + tmpItemPreview += this.getDuration(itemTemplate.duration, itemTemplate.flagsCustom); + + // required holiday + const holiday = itemTemplate.HolidayId; + if (!!holiday) { + const eventName = await this.sqliteQueryService.getEventNameByHolidayId(holiday); + tmpItemPreview += `
Requires ${eventName}`; + } + + // item begins a quest + const startquest: number = Number(itemTemplate.startquest); + if (!!startquest && startquest > 0) { + tmpItemPreview += `
This Item Begins a Quest`; + } + + // containerType (slotCount) + const containerSlots: number = Number(itemTemplate.ContainerSlots); + if (containerSlots > 0) { + const fam = bagFamily ? Math.log2(bagFamily) + 1 : 0; + tmpItemPreview += `
${containerSlots} Slot ${ITEM_CONSTANTS.bagFamily[fam]}`; + } + + tmpItemPreview += this.getClassText(itemTemplate.InventoryType, itemTemplate.class, itemTemplate.subclass); + tmpItemPreview += this.getDamageText(itemTemplate); + tmpItemPreview += this.getArmorText(itemTemplate); + + // Item is a gem (don't mix with sockets) + tmpItemPreview += await this.getGemEnchantment(itemTemplate.entry); + + // Random Enchantment - if random enchantment is set, prepend stats from it + const RandomProperty: number = itemTemplate.RandomProperty; + const RandomSuffix: number = itemTemplate.RandomSuffix; + if (!!RandomProperty || !!RandomSuffix) { + tmpItemPreview += `
${ITEM_CONSTANTS.randEnchant}`; + } + + // itemMods (display stats and save ratings for later use) + tmpItemPreview += this.getStats(itemTemplate, green); + + tmpItemPreview += this.getMagicResistances(itemTemplate); + + // Socket & Enchantment (TODO) + tmpItemPreview += await this.getSocketEnchantment(itemTemplate); + + // durability + const durability = itemTemplate.MaxDurability; + if (durability) { + tmpItemPreview += `
${ITEM_CONSTANTS.durability.replace(/%d/g, durability.toString())}`; + } + + tmpItemPreview += await this.getRequiredText(itemTemplate); + + const lockid = itemTemplate.lockid; + tmpItemPreview += await this.getLockText(flags, lockid); + + // spells on item + await this.getSpellDesc(itemTemplate, green); + + if (!!green && green.length > 0) { + for (const bonus of green) { + if (!!bonus) { + tmpItemPreview += `
${bonus}`; + } + } + } + + tmpItemPreview += await this.getItemSet(itemTemplate.entry, itemTemplate.itemset); + + // recipes, vanity pets, mounts + tmpItemPreview += await this.getLearnSpellText(itemTemplate); + + tmpItemPreview += this.getMisc(itemTemplate); + + const sellPrice = itemTemplate.SellPrice; + if (!!sellPrice && sellPrice > 0) { + tmpItemPreview += '
Sell Price: ' + this.formatMoney(sellPrice); + } + + return tmpItemPreview; + } + +} diff --git a/src/app/features/item/item-template/item-preview.ts b/src/app/features/item/item-template/item-preview.ts new file mode 100644 index 00000000000..bdf0d1a73c0 --- /dev/null +++ b/src/app/features/item/item-template/item-preview.ts @@ -0,0 +1,104 @@ +import { ITEM_MOD } from '@keira-shared/constants/options/item-class'; +import { TableRow } from '@keira-shared/types/general'; + +export class Lock extends TableRow { + id: number; + type1: number; + type2: number; + type3: number; + type4: number; + type5: number; + properties1: number; + properties2: number; + properties3: number; + properties4: number; + properties5: number; + reqSkill1: number; + reqSkill2: number; + reqSkill3: number; + reqSkill4: number; + reqSkill5: number; +} + +export const MAX_LEVEL = 80; + +export const lvlIndepRating = [ // rating doesn't scale with level + ITEM_MOD.MANA, + ITEM_MOD.HEALTH, + ITEM_MOD.ATTACK_POWER, + ITEM_MOD.MANA_REGENERATION, + ITEM_MOD.SPELL_POWER, + ITEM_MOD.HEALTH_REGEN, + ITEM_MOD.SPELL_PENETRATION, + ITEM_MOD.BLOCK_VALUE, +]; + +export const gtCombatRatings = { + 12: 1.5, + 13: 13.8, + 14: 13.8, + 15: 5, + 16: 10, + 17: 10, + 18: 8, + 19: 14, + 20: 14, + 21: 14, + 22: 10, + 23: 10, + 24: 8, + 25: 0, + 26: 0, + 27: 0, + 28: 10, + 29: 10, + 30: 10, + 31: 10, + 32: 14, + 33: 0, + 34: 0, + 35: 28.75, + 36: 10, + 37: 2.5, + 44: 4.26, +}; + +export const resistanceFields = [ + // null, + 'holy', + 'fire', + 'nature', + 'frost', + 'shadow', + 'arcane', +]; + +export enum CLASSES { + WARRIOR = 0x001, + PALADIN = 0x002, + HUNTER = 0x004, + ROGUE = 0x008, + PRIEST = 0x010, + DEATHKNIGHT = 0x020, + SHAMAN = 0x040, + MAGE = 0x080, + WARLOCK = 0x100, + DRUID = 0x400, + MASK_ALL = 0x5FF, +} + +export enum RACE { + HUMAN = 0x001, + ORC = 0x002, + DWARF = 0x004, + NIGHTELF = 0x008, + UNDEAD = 0x010, + TAUREN = 0x020, + GNOME = 0x040, + TROLL = 0x080, + BLOODELF = 0x200, + DRAENEI = 0x400, + MASK_ALLIANCE = 0x44D, + MASK_HORDE = 0x2B2, + MASK_ALL = 0x6FF, +} diff --git a/src/app/features/item/item-template/item-template.component.html b/src/app/features/item/item-template/item-template.component.html index 90a38916eec..de810065793 100644 --- a/src/app/features/item/item-template/item-template.component.html +++ b/src/app/features/item/item-template/item-template.component.html @@ -1,11 +1,12 @@ -
+
Loading...
+
@@ -75,9 +76,9 @@ = 0 + && editorService.form.controls.class.value < ITEM_SUBCLASS.length" [control]="editorService.form.controls.subclass" [config]="{ options: ITEM_SUBCLASS[editorService.form.controls.class.value], name: 'subclass' }" [modalClass]="'modal-md'" @@ -89,7 +90,7 @@
- +
- +
- + - Requirements - + Requirements +
@@ -420,7 +421,7 @@
- +
- +
- +
- +
- +
- +
- +
- +
- +
- Resistance - + Resistance +
- Stats - + Stats +
@@ -614,9 +615,9 @@
- - Socket - + + Socket +
@@ -668,8 +669,8 @@
- Weapon - + Weapon +
@@ -696,7 +697,7 @@
- + - Spell - + Spell +
+
+ +
+
+
+ + {{ editorService.form.controls.stackable.value }} + {{ editorService.form.controls.stackable.value }} +
+ + + + +
+ +
+
+
+
+
+
+ +
+
diff --git a/src/app/features/item/item-template/item-template.component.scss b/src/app/features/item/item-template/item-template.component.scss index e69de29bb2d..0b3f0293267 100644 --- a/src/app/features/item/item-template/item-template.component.scss +++ b/src/app/features/item/item-template/item-template.component.scss @@ -0,0 +1,51 @@ +.item-preview { + text-align: center; + margin: auto; + + .icon { + width: 100px; + height: 100px; + position: relative; + margin: auto; + margin-top: 5px; + border: 1px solid #6b6d78; + + img { + width: 90px; + height: 90px; + margin-top: 3px; + } + } + + .item-stats, .icon { + border-radius: 3px; + } + + .item-stats { + text-align: left; + font-family: Verdana, "Open Sans", Arial, "Helvetica Neue", Helvetica, sans-serif; + font-size: 15px; + + line-height: 25px; + width: auto; + min-width: 200px; + padding: 12px; + + color: #ccc; + } + + .stackable { + position: absolute; + margin-left: -33px; + bottom: -5px; + font-weight: 700; + font-size: 30px; + white-space: nowrap; + text-shadow: 2px 2px #000; + } + + .stackable-100 { + margin-left: -48px; + } + +} diff --git a/src/app/features/item/item-template/item-template.component.ts b/src/app/features/item/item-template/item-template.component.ts index 8d636cd3c47..d11fe626d9e 100644 --- a/src/app/features/item/item-template/item-template.component.ts +++ b/src/app/features/item/item-template/item-template.component.ts @@ -1,34 +1,38 @@ -import { Component } from '@angular/core'; - +import { Component, OnInit } from '@angular/core'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { SingleRowEditorComponent } from '@keira-abstract/components/editors/single-row-editor.component'; -import { ItemTemplate } from '@keira-types/item-template.type'; -import { ItemTemplateService } from './item-template.service'; -import { ItemHandlerService } from '../item-handler.service'; -import { ITEM_CLASS, ITEM_SUBCLASS } from '@keira-constants/options/item-class'; -import { ITEM_QUALITY } from '@keira-constants/options/item-quality'; -import { ITEM_FLAGS } from '@keira-constants/flags/item-flags'; -import { ITEM_FLAGS_EXTRA } from '@keira-constants/flags/item-flags-extra'; -import { INVENTORY_TYPE } from '@keira-constants/options/inventory-type'; import { ALLOWABLE_CLASSES } from '@keira-constants/flags/allowable-classes'; import { ALLOWABLE_RACES } from '@keira-constants/flags/allowable-races'; -import { FACTION_RANK } from '@keira-constants/options/faction-rank'; import { BAG_FAMILY } from '@keira-constants/flags/bag-family'; +import { ITEM_FLAGS } from '@keira-constants/flags/item-flags'; +import { ITEM_FLAGS_CUSTOM } from '@keira-constants/flags/item-flags-custom'; +import { ITEM_FLAGS_EXTRA } from '@keira-constants/flags/item-flags-extra'; import { SOCKET_COLOR } from '@keira-constants/flags/socket-color'; +import { DAMAGE_TYPE } from '@keira-constants/options/damage-type'; +import { FACTION_RANK } from '@keira-constants/options/faction-rank'; +import { FOOD_TYPE } from '@keira-constants/options/foot-type'; +import { INVENTORY_TYPE } from '@keira-constants/options/inventory-type'; import { ITEM_BONDING } from '@keira-constants/options/item-bonding'; +import { ITEM_CLASS, ITEM_SUBCLASS } from '@keira-constants/options/item-class'; import { ITEM_MATERIAL } from '@keira-constants/options/item-material'; +import { ITEM_QUALITY } from '@keira-constants/options/item-quality'; import { ITEM_SHEAT } from '@keira-constants/options/item-sheath'; -import { TOTEM_CATEGORY } from '@keira-constants/options/totem-category'; -import { FOOD_TYPE } from '@keira-constants/options/foot-type'; -import { ITEM_FLAGS_CUSTOM } from '@keira-constants/flags/item-flags-custom'; -import { DAMAGE_TYPE } from '@keira-constants/options/damage-type'; import { STAT_TYPE } from '@keira-constants/options/stat-type'; +import { TOTEM_CATEGORY } from '@keira-constants/options/totem-category'; +import { SqliteQueryService } from '@keira-shared/services/sqlite-query.service'; +import { ItemTemplate } from '@keira-types/item-template.type'; +import { Observable } from 'rxjs'; +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; +import { ItemHandlerService } from '../item-handler.service'; +import { ItemPreviewService } from './item-preview.service'; +import { ItemTemplateService } from './item-template.service'; @Component({ selector: 'keira-item-template', templateUrl: './item-template.component.html', styleUrls: ['./item-template.component.scss'] }) -export class ItemTemplateComponent extends SingleRowEditorComponent { +export class ItemTemplateComponent extends SingleRowEditorComponent implements OnInit { public readonly ITEM_CLASS = ITEM_CLASS; public readonly ITEM_SUBCLASS = ITEM_SUBCLASS; @@ -50,11 +54,49 @@ export class ItemTemplateComponent extends SingleRowEditorComponent; + public itemPreview: SafeHtml = this.sanitizer.bypassSecurityTrustHtml('loading...'); + + private async loadItemPreview() { + this.itemPreview = this.sanitizer.bypassSecurityTrustHtml( + await this.itemPreviewService.calculatePreview(this.editorService.form.getRawValue()) + ); + } + + ngOnInit() { + super.ngOnInit(); + this.loadItemPreview(); + this.icon = this.sqliteQueryService.getIconByItemDisplayId(this.editorService.form.controls.displayid.value); + + this.subscriptions.push( + this.editorService.form.controls.displayid.valueChanges.subscribe((icon: number) => { + this.icon = this.sqliteQueryService.getIconByItemDisplayId(icon); + }) + ); + + this.subscriptions.push( + this.editorService.form.valueChanges.pipe( + debounceTime(600), + /* TODO */ + distinctUntilChanged( + /* istanbul ignore next */ + (a, b) => JSON.stringify(a) === JSON.stringify(b) + ), + ).subscribe(this.loadItemPreview.bind(this)) + ); + } + } diff --git a/src/app/features/item/item-template/item-template.module.ts b/src/app/features/item/item-template/item-template.module.ts index c5507b050b6..e51c4f1c540 100644 --- a/src/app/features/item/item-template/item-template.module.ts +++ b/src/app/features/item/item-template/item-template.module.ts @@ -3,6 +3,7 @@ import { BrowserModule } from '@angular/platform-browser'; import { ReactiveFormsModule } from '@angular/forms'; import { TooltipModule } from 'ngx-bootstrap'; import { ToastrModule } from 'ngx-toastr'; +import { PerfectScrollbarModule } from 'ngx-perfect-scrollbar'; import { toastrConfig } from '@keira-config/toastr.config'; import { TopBarModule } from '@keira-shared/modules/top-bar/top-bar.module'; @@ -12,6 +13,7 @@ import { SingleValueSelectorModule } from '@keira-shared/modules/selectors/singl import { FlagsSelectorModule } from '@keira-shared/modules/selectors/flags-selector/flags-selector.module'; import { ItemTemplateService } from './item-template.service'; import { SpellSelectorModule } from '@keira-shared/modules/selectors/spell-selector/spell-selector.module'; +import { ItemPreviewService } from './item-preview.service'; import { FactionSelectorModule } from '@keira-shared/modules/selectors/faction-selector/faction-selector.module'; import { MapSelectorModule } from '@keira-shared/modules/selectors/map-selector/map-selector.module'; import { AreaSelectorModule } from '@keira-shared/modules/selectors/area-selector/area-selector.module'; @@ -21,6 +23,7 @@ import { LanguageSelectorModule } from '@keira-shared/modules/selectors/language import { ItemLimitCategorySelectorModule } from '@keira-shared/modules/selectors/item-limit-category-selector/item-limit-category-selector.module'; import { QuestSelectorModule } from '@keira-shared/modules/selectors/quest-selector/quest-selector.module'; + @NgModule({ declarations: [ ItemTemplateComponent, @@ -32,6 +35,7 @@ import { QuestSelectorModule } from '@keira-shared/modules/selectors/quest-selec QueryOutputModule, TooltipModule.forRoot(), ToastrModule.forRoot(toastrConfig), + PerfectScrollbarModule, SingleValueSelectorModule, FlagsSelectorModule, SpellSelectorModule, @@ -49,6 +53,7 @@ import { QuestSelectorModule } from '@keira-shared/modules/selectors/quest-selec ], providers: [ ItemTemplateService, + ItemPreviewService, ], }) export class ItemTemplateModule {} diff --git a/src/app/main/main-window/sidebar/sidebar.component.html b/src/app/main/main-window/sidebar/sidebar.component.html index 1ff4a0468f6..be1f1df569e 100644 --- a/src/app/main/main-window/sidebar/sidebar.component.html +++ b/src/app/main/main-window/sidebar/sidebar.component.html @@ -1,11 +1,11 @@