Skip to content

Commit

Permalink
Merge pull request #4114 from wowsims/apl
Browse files Browse the repository at this point in the history
Support sim links for sharing specific categories of settings (gear, …
  • Loading branch information
jimmyt857 authored Dec 28, 2023
2 parents 9ebff18 + 14a4115 commit 0c09282
Show file tree
Hide file tree
Showing 7 changed files with 412 additions and 163 deletions.
139 changes: 126 additions & 13 deletions ui/core/components/exporters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,30 @@ import { IndividualSimSettings } from '../proto/ui';
import { classNames, raceNames } from '../proto_utils/names';
import { UnitStat } from '../proto_utils/stats';
import { specNames } from '../proto_utils/utils';
import { downloadString, jsonStringifyWithFlattenedPaths } from '../utils';
import { arrayEquals, downloadString, getEnumValues, jsonStringifyWithFlattenedPaths } from '../utils';
import { BaseModal } from './base_modal';
import { IndividualWowheadGearPlannerImporter } from './importers';
import { IndividualLinkImporter, IndividualWowheadGearPlannerImporter } from './importers';
import { RaidSimRequest } from '../proto/api';
import { SimSettingCategories } from '../sim';
import { EventID, TypedEvent } from '../typed_event';
import { BooleanPicker } from './boolean_picker';

import * as Mechanics from '../constants/mechanics';

declare var pako: any;

interface ExporterOptions {
title: string,
header?: boolean,
allowDownload?: boolean,
}

export abstract class Exporter extends BaseModal {
private readonly textElem: HTMLElement;
protected readonly changedEvent: TypedEvent<void> = new TypedEvent();

constructor(parent: HTMLElement, simUI: SimUI, title: string, allowDownload: boolean) {
super(parent, 'exporter', { title: title, footer: true });
constructor(parent: HTMLElement, simUI: SimUI, options: ExporterOptions) {
super(parent, 'exporter', { title: options.title, header: options.header, footer: true });

this.body.innerHTML = `
<textarea spellCheck="false" class="exporter-textarea form-control"></textarea>
Expand All @@ -30,7 +42,7 @@ export abstract class Exporter extends BaseModal {
<i class="fas fa-clipboard"></i>
Copy to Clipboard
</button>
${allowDownload ? `
${options.allowDownload ? `
<button class="exporter-button btn btn-primary download-button">
<i class="fa fa-download"></i>
Download
Expand All @@ -57,7 +69,7 @@ export abstract class Exporter extends BaseModal {
}
});

if (allowDownload) {
if (options.allowDownload) {
const downloadButton = this.rootElem.getElementsByClassName('download-button')[0] as HTMLElement;
downloadButton.addEventListener('click', event => {
const data = this.textElem.textContent!;
Expand All @@ -67,31 +79,132 @@ export abstract class Exporter extends BaseModal {
}

protected init() {
this.changedEvent.on(() => this.updateContent());
this.updateContent();
}

private updateContent() {
this.textElem.textContent = this.getData();
}

abstract getData(): string;
}

export class IndividualLinkExporter<SpecType extends Spec> extends Exporter {
private static readonly exportPickerConfigs: Array<{
category: SimSettingCategories,
label: string,
labelTooltip: string,
}> = [
{
category: SimSettingCategories.Gear,
label: 'Gear',
labelTooltip: 'Also includes bonus stats and weapon swaps.',
},
{
category: SimSettingCategories.Talents,
label: 'Talents',
labelTooltip: 'Talents and Glyphs.',
},
{
category: SimSettingCategories.Rotation,
label: 'Rotation',
labelTooltip: 'Includes everything found in the Rotation tab.',
},
{
category: SimSettingCategories.Consumes,
label: 'Consumes',
labelTooltip: 'Flask, pots, food, etc.',
},
{
category: SimSettingCategories.External,
label: 'Buffs & Debuffs',
labelTooltip: 'All settings which are applied by other raid members.',
},
{
category: SimSettingCategories.Miscellaneous,
label: 'Misc',
labelTooltip: 'Spec-specific settings, front/back of target, distance from target, etc.',
},
{
category: SimSettingCategories.Encounter,
label: 'Encounter',
labelTooltip: 'Fight-related settings.',
},
// Intentionally exclude UISettings category here, because users almost
// never intend to export them and it messes with other users' settings.
// If they REALLY want to export UISettings, just use the JSON exporter.
];

private readonly simUI: IndividualSimUI<SpecType>;
private readonly exportCategories: Record<SimSettingCategories, boolean>;

constructor(parent: HTMLElement, simUI: IndividualSimUI<SpecType>) {
super(parent, simUI, 'Sharable Link', false);
super(parent, simUI, {title: 'Sharable Link', header: true});
this.simUI = simUI;

const exportCategories: Partial<Record<SimSettingCategories, boolean>> = {};
(getEnumValues(SimSettingCategories) as Array<SimSettingCategories>)
.forEach(cat => exportCategories[cat] = IndividualLinkImporter.DEFAULT_CATEGORIES.includes(cat));
this.exportCategories = exportCategories as Record<SimSettingCategories, boolean>;

const pickersContainer = document.createElement('div');
pickersContainer.classList.add('link-exporter-pickers')
this.body.prepend(pickersContainer);

IndividualLinkExporter.exportPickerConfigs.forEach(exportConfig => {
const category = exportConfig.category;
new BooleanPicker(pickersContainer, this, {
label: exportConfig.label,
labelTooltip: exportConfig.labelTooltip,
inline: true,
getValue: () => this.exportCategories[category],
setValue: (eventID: EventID, modObj: IndividualLinkExporter<SpecType>, newValue: boolean) => {
this.exportCategories[category] = newValue;
this.changedEvent.emit(eventID);
},
changedEvent: () => this.changedEvent,
});
});

this.init();
}

getData(): string {
return this.simUI.toLink();
return IndividualLinkExporter.createLink(
this.simUI,
(getEnumValues(SimSettingCategories) as Array<SimSettingCategories>)
.filter(c => this.exportCategories[c]));
}

static createLink(simUI: IndividualSimUI<any>, exportCategories?: Array<SimSettingCategories>): string {
if (!exportCategories) {
exportCategories = IndividualLinkImporter.DEFAULT_CATEGORIES;
}

const proto = simUI.toProto(exportCategories);

const protoBytes = IndividualSimSettings.toBinary(proto);
const deflated = pako.deflate(protoBytes, { to: 'string' });
const encoded = btoa(String.fromCharCode(...deflated));

const linkUrl = new URL(window.location.href);
linkUrl.hash = encoded;
if (arrayEquals(exportCategories, IndividualLinkImporter.DEFAULT_CATEGORIES)) {
linkUrl.searchParams.delete(IndividualLinkImporter.CATEGORY_PARAM);
} else {
const categoryCharString = exportCategories.map(c => IndividualLinkImporter.CATEGORY_KEYS.get(c)).join('');
linkUrl.searchParams.set(IndividualLinkImporter.CATEGORY_PARAM, categoryCharString);
}
return linkUrl.toString();
}
}

export class IndividualJsonExporter<SpecType extends Spec> extends Exporter {
private readonly simUI: IndividualSimUI<SpecType>;

constructor(parent: HTMLElement, simUI: IndividualSimUI<SpecType>) {
super(parent, simUI, 'JSON Export', true);
super(parent, simUI, {title: 'JSON Export', allowDownload: true});
this.simUI = simUI;
this.init();
}
Expand Down Expand Up @@ -119,7 +232,7 @@ export class IndividualWowheadGearPlannerExporter<SpecType extends Spec> extends
private readonly simUI: IndividualSimUI<SpecType>;

constructor(parent: HTMLElement, simUI: IndividualSimUI<SpecType>) {
super(parent, simUI, 'Wowhead Export', true);
super(parent, simUI, {title: 'Wowhead Export', allowDownload: true});
this.simUI = simUI;
this.init();
}
Expand Down Expand Up @@ -228,7 +341,7 @@ export class Individual80UEPExporter<SpecType extends Spec> extends Exporter {
private readonly simUI: IndividualSimUI<SpecType>;

constructor(parent: HTMLElement, simUI: IndividualSimUI<SpecType>) {
super(parent, simUI, '80Upgrades EP Export', true);
super(parent, simUI, {title: '80Upgrades EP Export', allowDownload: true});
this.simUI = simUI;
this.init();
}
Expand Down Expand Up @@ -320,7 +433,7 @@ export class IndividualPawnEPExporter<SpecType extends Spec> extends Exporter {
private readonly simUI: IndividualSimUI<SpecType>;

constructor(parent: HTMLElement, simUI: IndividualSimUI<SpecType>) {
super(parent, simUI, 'Pawn EP Export', true);
super(parent, simUI, {title: 'Pawn EP Export', allowDownload: true});
this.simUI = simUI;
this.init();
}
Expand Down Expand Up @@ -413,7 +526,7 @@ export class IndividualCLIExporter<SpecType extends Spec> extends Exporter {
private readonly simUI: IndividualSimUI<SpecType>;

constructor(parent: HTMLElement, simUI: IndividualSimUI<SpecType>) {
super(parent, simUI, "CLI Export", true);
super(parent, simUI, {title: 'CLI Export', allowDownload: true});
this.simUI = simUI;
this.init();
}
Expand Down
66 changes: 65 additions & 1 deletion ui/core/components/importers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@ import { classNames, nameToClass, nameToRace, nameToProfession } from '../proto_
import { classGlyphsConfig, talentSpellIdsToTalentString } from '../talents/factory';
import { GlyphConfig } from '../talents/glyphs_picker';
import { BaseModal } from './base_modal';
import { buf2hex } from '../utils';
import { buf2hex, getEnumValues } from '../utils';
import { JsonObject } from '@protobuf-ts/runtime';
import { SimSettingCategories } from '../sim';

declare var pako: any;

export abstract class Importer extends BaseModal {
protected readonly textElem: HTMLTextAreaElement;
Expand Down Expand Up @@ -119,6 +122,67 @@ export abstract class Importer extends BaseModal {
}
}

interface UrlParseData {
settings: IndividualSimSettings,
categories: Array<SimSettingCategories>,
}

// For now this just holds static helpers to match the exporter, so it doesn't extend Importer.
export class IndividualLinkImporter {

// Exclude UISettings by default, since most users don't intend to export those.
static readonly DEFAULT_CATEGORIES = getEnumValues(SimSettingCategories).filter(c => c != SimSettingCategories.UISettings) as Array<SimSettingCategories>;

static readonly CATEGORY_PARAM = 'i';
static readonly CATEGORY_KEYS: Map<SimSettingCategories, string> = (() => {
const map = new Map();
// Use single-letter abbreviations since these will be included in sim links.
map.set(SimSettingCategories.Gear, 'g');
map.set(SimSettingCategories.Talents, 't');
map.set(SimSettingCategories.Rotation, 'r');
map.set(SimSettingCategories.Consumes, 'c');
map.set(SimSettingCategories.Miscellaneous, 'm');
map.set(SimSettingCategories.External, 'x');
map.set(SimSettingCategories.Encounter, 'e');
map.set(SimSettingCategories.UISettings, 'u');
return map;
})();

static tryParseUrlLocation(location: Location): UrlParseData|null {
let hash = location.hash;
if (hash.length <= 1) {
return null;
}

// Remove leading '#'
hash = hash.substring(1);
const binary = atob(hash);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = binary.charCodeAt(i);
}

const settingsBytes = pako.inflate(bytes);
const settings = IndividualSimSettings.fromBinary(settingsBytes);

let exportCategories = IndividualLinkImporter.DEFAULT_CATEGORIES;
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has(IndividualLinkImporter.CATEGORY_PARAM)) {
const categoryChars = urlParams.get(IndividualLinkImporter.CATEGORY_PARAM)!.split('');
exportCategories = categoryChars
.map(char => [...IndividualLinkImporter.CATEGORY_KEYS.entries()]
.find(e => e[1] == char))
.filter(e => e)
.map(e => e![0]);
}

return {
settings: settings,
categories: exportCategories,
};
}
}

export class IndividualJsonImporter<SpecType extends Spec> extends Importer {
private readonly simUI: IndividualSimUI<SpecType>;
constructor(parent: HTMLElement, simUI: IndividualSimUI<SpecType>) {
Expand Down
Loading

0 comments on commit 0c09282

Please sign in to comment.