diff --git a/src/components/tabs/glyphs/sidebar/GlyphSetSavePanel.vue b/src/components/tabs/glyphs/sidebar/GlyphSetSavePanel.vue index 49b377bf0..e2bde3f07 100644 --- a/src/components/tabs/glyphs/sidebar/GlyphSetSavePanel.vue +++ b/src/components/tabs/glyphs/sidebar/GlyphSetSavePanel.vue @@ -60,65 +60,98 @@ export default { const name = this.names[id] === "" ? "" : `: ${this.names[id]}`; return `Glyph Preset #${id + 1}${name}`; }, - // Let the player load if the currently equipped glyphs are a subset of the preset - canLoadSet(set) { - let toLoad = [...set]; - let currActive = [...Glyphs.active.filter(g => g)]; - for (const targetGlyph of currActive) { - const matchingGlyph = Glyphs.findByValues(targetGlyph, toLoad, { - level: this.level ? -1 : 0, - strength: this.rarity ? -1 : 0, - effects: this.effects ? -1 : 0 - }); - if (!matchingGlyph) return false; - toLoad = toLoad.filter(g => g !== matchingGlyph); - currActive = currActive.filter(g => g !== targetGlyph); - } - return toLoad.length > 0; - }, saveGlyphSet(id) { if (!this.hasEquipped || player.reality.glyphs.sets[id].glyphs.length) return; player.reality.glyphs.sets[id].glyphs = Glyphs.active.compact(); this.refreshGlyphSets(); EventHub.dispatch(GAME_EVENT.GLYPH_SET_SAVE_CHANGE); }, - loadGlyphSet(set) { - if (!this.canLoadSet(set) || !this.setLengthValid(set)) return; + // A proper full solution to this turns out to contain an NP-hard problem as a subproblem, so instead we do + // somwthing which should work in most cases - we match greedily when it won't obviously lead to an incomplete + // preset match, and leniently when matching greedily may lead to an incomplete set being loaded + loadGlyphSet(set, id) { + if (!this.setLengthValid(set)) return; + let glyphsToLoad = [...set]; + const activeGlyphs = [...Glyphs.active.filter(g => g)]; - // If we already have a subset of the preset loaded, don't try to load glyphs from that subset again - let toLoad = [...set]; - let currActive = [...Glyphs.active.filter(g => g)]; - for (const targetGlyph of currActive) { - const matchingGlyph = Glyphs.findByValues(targetGlyph, toLoad, { + // Create an array where each entry contains a single active glyph and all its matches in the preset which it + // could fill in for, based on the preset loading settings + const activeOptions = []; + for (const glyph of activeGlyphs) { + const options = Glyphs.findByValues(glyph, glyphsToLoad, { level: this.level ? -1 : 0, strength: this.rarity ? -1 : 0, effects: this.effects ? -1 : 0 }); - if (!matchingGlyph) continue; - toLoad = toLoad.filter(g => g !== matchingGlyph); - currActive = currActive.filter(g => g !== targetGlyph); + activeOptions.push({ glyph, options }); } - // Try to load the rest from the inventory - let missingGlyphs = 0; - for (const targetGlyph of toLoad) { - const matchingGlyph = Glyphs.findByValues(targetGlyph, Glyphs.sortedInventoryList, { - level: this.level ? 1 : 0, - strength: this.rarity ? 1 : 0, - effects: this.effects ? 1 : 0 + // Using the active glyphs one by one, select matching to-be-loaded preset glyphs to be removed from the list. + // This makes sure the inventory doesn't attempt to match a glyph which is already satisfied by an equipped one + const selectedFromActive = this.findSelectedGlyphs(activeOptions, 5); + for (const glyph of selectedFromActive) glyphsToLoad = glyphsToLoad.filter(g => g !== glyph); + + // For the remaining glyphs to load from the preset, find all their appropriate matches within the inventory. + // This is largely the same as earlier with the equipped glyphs + const remainingOptions = []; + for (let index = 0; index < glyphsToLoad.length; index++) { + const glyph = glyphsToLoad[index]; + const options = Glyphs.findByValues(glyph, Glyphs.sortedInventoryList, { + level: this.level ? -1 : 0, + strength: this.rarity ? -1 : 0, + effects: this.effects ? -1 : 0 }); - if (!matchingGlyph) { - missingGlyphs++; - continue; - } + remainingOptions[index] = { glyph, options }; + } + + // This is scanned through similarly to the active slot glyphs, except we need to make sure we don't try to + // match more glyphs than we have room for + const selectedFromInventory = this.findSelectedGlyphs(remainingOptions, + Glyphs.active.countWhere(g => g === null)); + for (const glyph of selectedFromInventory) glyphsToLoad = glyphsToLoad.filter(g => g !== glyph); + + // Actually equip the glyphs and then notify how successful (or not) the loading was + let missingGlyphs = glyphsToLoad.length; + for (const glyph of selectedFromInventory) { const idx = Glyphs.active.indexOf(null); - if (idx !== -1) Glyphs.equip(matchingGlyph, idx); + if (idx !== -1) { + Glyphs.equip(glyph, idx); + missingGlyphs--; + } } - if (missingGlyphs) { - GameUI.notify.error(`Could not find ${missingGlyphs} ${pluralize("Glyph", missingGlyphs)} to load from - Glyph preset.`); + if (missingGlyphs > 0) { + GameUI.notify.error(`Could not find or equip ${missingGlyphs} ${pluralize("Glyph", missingGlyphs)} from + ${this.setName(id)}.`); + } else { + GameUI.notify.success(`Successfully loaded ${this.setName(id)}.`); } - EventHub.dispatch(GAME_EVENT.GLYPH_SET_SAVE_CHANGE); + }, + // Given a list of options for suitable matches to those glyphs and a maximum glyph count to match, returns the + // set of glyphs which should be loaded. This is a tricky matching process to do since on one hand we don't want + // early matches to prevent later ones, but on the other hand matching too leniently can cause any passed-on later + // requirements to be too strict (eg. preset 1234 and equipped 234 could match 123, leaving an unmatchable 4). + // The compromise solution here is to check how many choices the next-strictest option list has - if it only has + // one choice then we pick conservatively (the weakest glyph) - otherwise we pick greedily (the strongest glyph). + findSelectedGlyphs(optionList, maxGlyphs) { + // We do a weird composite sorting function here in order to make sure that glyphs get treated by type first, and + // with in type are generally ordered in strictest to most lenient in terms of matches. Note that the options + // are sorted internally starting with the strictest match first + optionList.sort((a, b) => 100 * (a.glyph.type.charCodeAt(0) - b.glyph.type.charCodeAt(0)) + + (a.options.length - b.options.length)); + const toLoad = []; + let slotsLeft = maxGlyphs; + for (let index = 0; index < optionList.length; index++) { + if (slotsLeft === 0) break; + const entry = optionList[index]; + const greedyPick = index === optionList.length - 1 || optionList[index + 1].options.length > 1; + + const filteredOptions = entry.options.filter(g => !toLoad.includes(g)); + if (filteredOptions.length === 0) continue; + const selectedGlyph = filteredOptions[greedyPick ? 0 : (filteredOptions.length - 1)]; + toLoad.push(selectedGlyph); + slotsLeft--; + } + return toLoad; }, deleteGlyphSet(id) { if (!player.reality.glyphs.sets[id].glyphs.length) return; @@ -137,6 +170,11 @@ export default { setLengthValid(set) { return set.length && set.length <= Glyphs.activeSlotCount; }, + loadingTooltip(set) { + return this.setLengthValid(set) && this.hasEquipped + ? "This set may not load properly because you already have some Glyphs equipped" + : null; + }, glyphSetKey(set, index) { return `${index} ${Glyphs.hash(set)}`; } @@ -219,9 +257,10 @@ export default { Save @@ -254,20 +293,4 @@ export default { .c-glyph-set-preview-area { width: 18rem; } - -.l-glyph-sacrifice-options__help { - position: absolute; - top: 0; - left: calc(100% - 1.8rem); - z-index: 2; -} - -.c-glyph-sacrifice-options__help { - font-size: 1.2rem; - color: var(--color-reality-dark); -} - -.s-base--dark .c-glyph-sacrifice-options__help { - color: var(--color-reality-light); -} diff --git a/src/core/glyph-effects.js b/src/core/glyph-effects.js index 57d150bcb..35aa62769 100644 --- a/src/core/glyph-effects.js +++ b/src/core/glyph-effects.js @@ -214,12 +214,6 @@ export function getGlyphIDsFromBitmask(bitmask) { return getGlyphEffectsFromBitmask(bitmask).map(x => x.id); } -export function hasAtLeastGlyphEffects(needleBitmask, haystackBitmask) { - const needle = getGlyphIDsFromBitmask(needleBitmask); - const haystack = getGlyphIDsFromBitmask(haystackBitmask); - return haystack.every(x => needle.includes(x)); -} - class FunctionalGlyphType { /** * @param {Object} setup diff --git a/src/core/glyphs/glyph-core.js b/src/core/glyphs/glyph-core.js index f119ce989..dfa3a5d8d 100644 --- a/src/core/glyphs/glyph-core.js +++ b/src/core/glyphs/glyph-core.js @@ -192,40 +192,66 @@ export const Glyphs = { this.validate(); EventHub.dispatch(GAME_EVENT.GLYPHS_CHANGED); }, + // This compares targetGlyph to all the glyphs in searchList, returning a subset of them which fulfills the comparison + // direction specified by the parameters in fuzzyMatch: + // -1: Will find glyphs which are equal to or worse than targetGlyph + // 0: Will only return glyphs which have identical values + // +1: Will find glyphs which are equal to or better than targetGlyph findByValues(targetGlyph, searchList, fuzzyMatch = { level, strength, effects }) { // We need comparison to go both ways for normal matching and subset matching for partially-equipped sets const compFn = (op, comp1, comp2) => { switch (op) { case -1: - return comp1 <= comp2; + return comp2 - comp1; case 0: - return comp1 === comp2; + return comp1 === comp2 ? 0 : -1; case 1: - return comp1 >= comp2; + return comp1 - comp2; } return false; }; + // Returns a number based on how much the small mask is found inside of the large mask. Returns a non-negative + // number if small contains all of large, with a value equal to the number of extra bits. Otherwise, returns a + // negative number equal to the negative of the number of bits that large has which small doesn't. + const matchedEffects = (large, small) => { + if ((large & small) === large) return countValuesFromBitmask(small - large); + return -countValuesFromBitmask(large - (large & small)); + }; + + // Make an array containing all glyphs which match the given criteria, with an additional "quality" prop in order + // to determine roughly how good the glyph itself is relative to other matches + const allMatches = []; for (const glyph of searchList) { const type = glyph.type === targetGlyph.type; - let eff = false; + let eff; switch (fuzzyMatch.effects) { case -1: - eff = hasAtLeastGlyphEffects(targetGlyph.effects, glyph.effects); + eff = matchedEffects(targetGlyph.effects, glyph.effects); break; case 0: - eff = glyph.effects === targetGlyph.effects; + eff = glyph.effects === targetGlyph.effects ? 0 : -1; break; case 1: - eff = hasAtLeastGlyphEffects(glyph.effects, targetGlyph.effects); + eff = matchedEffects(glyph.effects, targetGlyph.effects); break; } - const str = compFn(fuzzyMatch.strength, glyph.strength, targetGlyph.strength); - const lvl = compFn(fuzzyMatch.level, glyph.level, targetGlyph.level); + const str = compFn(fuzzyMatch.strength, glyph.strength, targetGlyph.strength) / 2.5; + const lvl = compFn(fuzzyMatch.level, glyph.level, targetGlyph.level) / 5000; const sym = glyph.symbol === targetGlyph.symbol; - if (type && eff && str && lvl && sym) return glyph; + if (type && eff >= 0 && str >= 0 && lvl >= 0 && sym) { + allMatches.push({ + glyph, + // Flatten glyph qualities, with 10% rarity, 500 levels, and an extra effect all being equal value. This + // is used to sort the options by some rough measure of distance from the target glyph + gap: str + lvl + eff / 10 + }); + } } - return undefined; + + // Sort by increasing gap, then discard the value as it's not directly used anywhere else + allMatches.sort((a, b) => a.gap - b.gap); + return allMatches.map(m => m.glyph); }, findById(id) { return player.reality.glyphs.inventory.find(glyph => glyph.id === id);