Skip to content

Commit

Permalink
include generating script
Browse files Browse the repository at this point in the history
  • Loading branch information
robojumper committed Mar 17, 2023
1 parent f3dad8f commit 95aa9b1
Show file tree
Hide file tree
Showing 2 changed files with 301 additions and 0 deletions.
191 changes: 191 additions & 0 deletions src/tools/extract-artifact-unlocked-mod-hashes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { getDef, loadLocal, setApiKey } from '@d2api/manifest-node';
import {
DestinyComponentType,
DestinyProfileResponse,
getProfile,
HttpClient,
searchDestinyPlayerByBungieName,
} from 'bungie-api-ts/destiny2';
import { UserInfoCard } from 'bungie-api-ts/user/interfaces.js';
import * as readline from 'readline';
import { annotate, writeFile } from '../helpers.js';
import { createHttpClient } from './http-client.js';

const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const prompt = (query: string) =>
new Promise((resolve: (answer: string) => void) => rl.question(query, resolve));

const setDifference = <T>(a: Set<T>, b: Set<T>): T[] => [...a].filter((entry) => !b.has(entry));

interface RelevantInfo {
unlockedPlugSetItems: Set<number>;
unlockedArtifactItems: Set<number>;
}

const extractProfileInfo = (
response: DestinyProfileResponse,
characterId: string
): RelevantInfo => {
const unlockedPlugSetItems = new Set(
Object.values(response.characterPlugSets.data![characterId].plugs).flatMap((plugSet) =>
plugSet.filter((entry) => entry.enabled).map((entry) => entry.plugItemHash)
)
);
const unlockedArtifactItems = new Set(
response.characterProgressions.data![characterId].seasonalArtifact.tiers.flatMap((tier) =>
tier.items.filter((item) => item.isActive).map((item) => item.itemHash)
)
);

return { unlockedPlugSetItems, unlockedArtifactItems };
};

class ProfileStore {
account: UserInfoCard;
characterId: string;
httpClient: HttpClient;

lastProfile?: DestinyProfileResponse;
lastInfo?: RelevantInfo;

currentProfile?: DestinyProfileResponse;
currentInfo?: RelevantInfo;

constructor(characterId: string, httpClient: HttpClient, account: UserInfoCard) {
this.characterId = characterId;
this.httpClient = httpClient;
this.account = account;
}

async getNewProfile(): Promise<boolean> {
const newProfile = (
await getProfile(this.httpClient, {
membershipType: this.account.membershipType,
destinyMembershipId: this.account.membershipId,
components: [
DestinyComponentType.Characters,
DestinyComponentType.CharacterProgressions,
DestinyComponentType.ItemSockets,
],
})
).Response;
if (
!this.currentProfile ||
new Date(this.currentProfile.responseMintedTimestamp) <
new Date(newProfile.responseMintedTimestamp)
) {
const newInfo = extractProfileInfo(newProfile, this.characterId);
if (
!this.currentInfo ||
newInfo.unlockedArtifactItems.size === this.currentInfo.unlockedArtifactItems.size + 1
) {
this.lastProfile = this.currentProfile;
this.lastInfo = this.currentInfo;
this.currentProfile = newProfile;
this.currentInfo = newInfo;
return true;
} else if (
newInfo.unlockedArtifactItems.size !== this.currentInfo.unlockedArtifactItems.size
) {
throw new Error('fatal error: did not unlock exactly one artifact mod');
}
}
return false;
}
}

(async () => {
try {
const API_KEY = await prompt('Enter API Key: ');

setApiKey(API_KEY);
loadLocal();

const httpClient = createHttpClient(fetch, API_KEY);

let players = undefined;
do {
try {
const [playerName, discriminator] = (await prompt('Enter BungieName#0000: ')).split('#');
players = await searchDestinyPlayerByBungieName(
httpClient,
{ membershipType: -1 },
{ displayName: playerName, displayNameCode: parseInt(discriminator, 10) }
);
} catch (e: any) {
console.log(e.message);
}
} while (!players || !players.Response.length);

const account = players.Response[0];
const profile = await getProfile(httpClient, {
membershipType: account.membershipType,
destinyMembershipId: account.membershipId,
components: [
DestinyComponentType.Characters,
DestinyComponentType.CharacterProgressions,
DestinyComponentType.ItemSockets,
],
});
const characters = Object.entries(profile.Response.characters.data!);
characters.forEach(([, data], index) => {
console.log(`${index + 1}) ${getDef('Class', data.classHash)?.displayProperties.name}`);
});
let index = NaN;
do {
index = parseInt(await prompt('Which character: '), 10);
} while (isNaN(index) || index < 1 || index > characters.length);
const characterId = characters[index - 1][0];

let initialInfo = extractProfileInfo(profile.Response, characterId);
while (initialInfo.unlockedArtifactItems.size !== 3) {
await prompt(
'Please reset your artifact, then unlock exactly 3 first-column mods, then press enter (may take a while for fresh info)'
);
const profile = await getProfile(httpClient, {
membershipType: account.membershipType,
destinyMembershipId: account.membershipId,
components: [
DestinyComponentType.Characters,
DestinyComponentType.CharacterProgressions,
DestinyComponentType.ItemSockets,
],
});
initialInfo = extractProfileInfo(profile.Response, characterId);
}

const store = new ProfileStore(characterId, httpClient, account);
store.getNewProfile();

const unlockedModsPerArtifactUnlock: { [artifactModHash: number]: number[] } = {};
// eslint-disable-next-line no-constant-condition
while (true) {
const result = await prompt('enter q to finish, or n to unlock an additional artifact mod: ');
if (result === 'q') {
break;
} else if (result === 'n') {
while (!(await store.getNewProfile())) {
await prompt('did not get a new profile, wait a bit, then press enter:');
}
const [newArtifactMod] = setDifference(
store.currentInfo!.unlockedArtifactItems,
store.lastInfo!.unlockedArtifactItems
);
const newPlugSetItems = setDifference(
store.currentInfo!.unlockedPlugSetItems,
store.lastInfo!.unlockedPlugSetItems
);

console.log(newArtifactMod, '->', JSON.stringify(newPlugSetItems));
unlockedModsPerArtifactUnlock[newArtifactMod] = newPlugSetItems;
}
}

const pretty = JSON.stringify(unlockedModsPerArtifactUnlock, undefined, 2);
const annotated = annotate(pretty);
const outString = `export const artifactPlugUnlocks: { [artifactModHash: number]: number[] } = ${annotated};`;
writeFile('./data/artifact-mod-unlocks.ts', outString);
} catch (e: any) {
console.error('Error', e.message);
}
})();
110 changes: 110 additions & 0 deletions src/tools/http-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { PlatformErrorCodes, ServerResponse } from 'bungie-api-ts/common';
import { HttpClient, HttpClientConfig } from 'bungie-api-ts/http';

// All of this is just copied from DIM

export function createHttpClient(fetchFunction: typeof fetch, apiKey: string): HttpClient {
return async (config: HttpClientConfig) => {
let url = config.url;
if (config.params) {
// strip out undefined params keys. bungie-api-ts creates them for optional endpoint parameters
for (const key in config.params) {
typeof config.params[key] === 'undefined' && delete config.params[key];
}
url = `${url}?${new URLSearchParams(config.params as Record<string, string>).toString()}`;
}

const fetchOptions = new Request(url, {
method: config.method,
body: config.body ? JSON.stringify(config.body) : undefined,
headers: { 'X-API-Key': apiKey, ...(config.body && { 'Content-Type': 'application/json' }) },
credentials: 'omit',
});

const response = await fetchFunction(fetchOptions);
let data: ServerResponse<unknown> | undefined;
let parseError: Error | undefined;
try {
data = await response.json();
} catch (e: any) {
parseError = e;
}
// try throwing bungie errors, which have more information, first
throwBungieError(data, fetchOptions);
// then throw errors on generic http error codes
throwHttpError(response);
if (parseError) {
throw parseError;
}
return data;
};
}

/**
* an error indicating a non-200 response code
*/
export class HttpStatusError extends Error {
status: number;
constructor(response: Response) {
super(response.statusText);
this.status = response.status;
}
}

/**
* an error indicating the Bungie API sent back a parseable response,
* and that response indicated the request was not successful
*/
export class BungieError extends Error {
code?: PlatformErrorCodes;
status?: string;
endpoint: string;
constructor(response: Partial<ServerResponse<unknown>>, request: Request) {
super(response.Message);
this.name = 'BungieError';
this.code = response.ErrorCode;
this.status = response.ErrorStatus;
this.endpoint = request.url;
}
}

/**
* this is a non-affecting pass-through for successful http requests,
* but throws JS errors for a non-200 response
*/
function throwHttpError(response: Response) {
if (response.status < 200 || response.status >= 400) {
throw new HttpStatusError(response);
}
return response;
}

/**
* sometimes what you have looks like a Response but it's actually an Error
*
* this is a non-affecting pass-through for successful API interactions,
* but throws JS errors for "successful" fetches with Bungie error information
*/
function throwBungieError<T>(
serverResponse: (ServerResponse<T> & { error?: string; error_description?: string }) | undefined,
request: Request
) {
// There's an alternate error response that can be returned during maintenance
const eMessage = serverResponse?.error && serverResponse.error_description;
if (eMessage) {
throw new BungieError(
{
Message: eMessage,
ErrorCode: PlatformErrorCodes.DestinyUnexpectedError,
ErrorStatus: eMessage,
},
request
);
}

if (serverResponse && serverResponse.ErrorCode !== PlatformErrorCodes.Success) {
throw new BungieError(serverResponse, request);
}

return serverResponse;
}

0 comments on commit 95aa9b1

Please sign in to comment.