forked from DestinyItemManager/d2-additional-info
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
f3dad8f
commit 95aa9b1
Showing
2 changed files
with
301 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
})(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |