Last active
November 19, 2025 16:17
-
-
Save escalonn/07805044602fe18983a6c32bbe3a2b10 to your computer and use it in GitHub Desktop.
This file contains hidden or 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
| function getOwnedCharacters() { | |
| // in the bookmarklet: | |
| // const chars = o.data.avatars.map(a => [a.name, a.actived_constellation_num]); | |
| // const url = 'https://genshin.wife4.dev/teams#' + new URLSearchParams(chars); | |
| // fallback to test data for node testing | |
| const paramsString = | |
| typeof window !== 'undefined' | |
| ? window.location.hash.substring(1) | |
| : 'Jean=1&Kaedehara+Kazuha=0&Raiden+Shogun=0&Yelan=0&Tighnari=0&Nahida=0&Furina=0&Kirara=1&Manekin=0&Manekina=0&Neuvillette=0&Arlecchino=0&Bennett=4&Sucrose=0&Kuki+Shinobu=1&Chevreuse=6&Skirk=0&Lauma=0&Xianyun=0&Lisa=1&Xiangling=6&Xingqiu=6&Fischl=4&Traveler=6&Zhongli=0&Hu+Tao=1&Navia=0&Citlali=0&Rosaria=4&Gaming=5&Escoffier=0&Ororon=6&Iansan=0&Diluc=3&Mona=0&Clorinde=0&Beidou=2&Layla=6&Yaoyao=6&Lan+Yan=5&Keqing=1&Diona=5&Kujou+Sara=0&Yun+Jin=3&Collei=4&Lynette=0&Sethos=3&Kachina=2&Aino=0&Ifa=2&Noelle=6&Barbara=6&Shikanoin+Heizou=5&Faruzan=3&Kaeya=0&Amber=1&Ningguang=0&Dehya=0&Razor=2&Chongyun=0&Xinyan=1&Yanfei=3&Thoma=5&Sayu=0&Gorou=2&Dori=0&Candace=2&Mika=1&Kaveh=0&Yumemizuki+Mizuki=0&Freminet=0&Dahlia=1'; | |
| const chars = []; | |
| for (const [name, constellation] of new URLSearchParams(paramsString)) { | |
| chars[name] = parseInt(constellation); | |
| } | |
| return chars; | |
| } | |
| const ENDPOINTS = { | |
| abyss: | |
| 'https://api.yshelper.com/ys/getAbyssRank.php?star=all&role=all&lang=en', | |
| stygian: | |
| 'https://api.yshelper.com/ys/getAbyssRank2.php?star=all&role=all&lang=en', | |
| dire: 'https://api.yshelper.com/ys/getAbyssRank2.php?star=only_nandu6&role=all&lang=en' | |
| }; | |
| async function fetchData(endpoint, timeout = 10000) { | |
| const controller = new AbortController(); | |
| const timeoutId = setTimeout(() => controller.abort(), timeout); | |
| try { | |
| const response = await fetch(endpoint, { signal: controller.signal }); | |
| clearTimeout(timeoutId); | |
| return await response.json(); | |
| } catch (error) { | |
| clearTimeout(timeoutId); | |
| throw error; | |
| } | |
| } | |
| function buildCharacterMap(data) { | |
| const misspellings = { Ambor: 'Amber', Ayaka: 'Kamisato Ayaka' }; | |
| // There is no entry for Traveler's avatar, so it will be hardcoded | |
| const extraAvatars = { | |
| 'https://upload-bbs.mihoyo.com/game_record/genshin/character_icon/UI_AvatarIcon_PlayerGirl.png': | |
| 'Traveler' | |
| }; | |
| const chars = {}; | |
| data.result[0].forEach(group => { | |
| group.list.forEach(char => { | |
| // The api gives non-cumulative constellation ownership percentages like: | |
| // c0_rate: 78.2, c1_rate: 6.6, c2_rate: 9.5, etc. | |
| // Now calculate, for each con, what proportion of owners of the character | |
| // have a con lesser or equal. like: | |
| // [0.782, 0.848, ..., 1] | |
| // This is used to compute the adjusted score for teams. | |
| const conRates = [char.c0_rate]; | |
| for (let i = 1; i <= 5; i++) { | |
| conRates.push(conRates[conRates.length - 1] + char[`c${i}_rate`]); | |
| } | |
| const normalizedRates = [...conRates.map(x => x / 100), 1]; | |
| // In .result[3], team members are only identified by their avatar images, | |
| // so character data will be indexed by the avatar. | |
| chars[char.avatar] = { | |
| name: misspellings[char.name] || char.name, | |
| conRates: normalizedRates | |
| }; | |
| }); | |
| }); | |
| Object.entries(extraAvatars).forEach(([avatar, name]) => { | |
| chars[avatar] = Object.values(chars).find(c => c.name === name); | |
| }); | |
| return chars; | |
| } | |
| function validateOwnedCharacters(ownedChars, charMap) { | |
| const expectedMissingChars = new Set(['Manekin', 'Manekina']); | |
| const charNames = new Set(Object.values(charMap).map(c => c.name)); | |
| const missingChars = Object.keys(ownedChars).filter( | |
| name => !charNames.has(name) && !expectedMissingChars.has(name) | |
| ); | |
| // This is for future-proofing; if YSHelper can have inconsistent names for | |
| // Amber and Ayaka, it could do it with another character in the future, | |
| // and without this output, they would be silently considered unowned. | |
| if (missingChars.length > 0) { | |
| console.log( | |
| `YSHelper has no match for these owned characters: ${missingChars.join( | |
| ', ' | |
| )}` | |
| ); | |
| } | |
| } | |
| function processTeams(data, charMap, ownedChars, stageCount) { | |
| const teams = []; | |
| data.result[3].forEach(team => { | |
| const avatars = team.role.map(c => c.avatar); | |
| const members = avatars.map(a => charMap[a].name); | |
| // Filter out teams with unowned characters | |
| if (!members.every(c => ownedChars.hasOwnProperty(c))) { | |
| return; | |
| } | |
| // Calculate constellation adjustment factor | |
| const conFactor = members.reduce((factor, char, i) => { | |
| return factor * charMap[avatars[i]].conRates[ownedChars[char]]; | |
| }, 1); | |
| const teamData = { | |
| members, | |
| conFactor, | |
| time: team.time // Include time data for stygian endpoints | |
| }; | |
| // Add scores for each stage | |
| for (let i = 0; i < stageCount; i++) { | |
| let scoreKey; | |
| if (stageCount === 2) { | |
| scoreKey = ['up_use_num', 'down_use_num'][i]; // Abyss | |
| } else { | |
| scoreKey = ['up_use_num', 'mid_use_num', 'down_use_num'][i]; // Stygian | |
| } | |
| const score = team[scoreKey] / team.has; | |
| // The "Score" is simply the proportion of people who used this team for | |
| // this specific Abyss half or Stygian enemy out of all the people who | |
| // own the characters in the team. | |
| // That is, what % of people who COULD use it DID use it. | |
| teamData[`stage${i}Score`] = score; | |
| // The "Adj Score" (adjusted score) penalizes teams based on whether | |
| // the current user has the constellations that the sampled users did. | |
| // If the user has all C6 for this team, Adj Score = Score. | |
| // If the user has all C0 for this team, and 40% of sampled users have | |
| // C1 or higher, for each of 4 characters, then Adj Score will be | |
| // 60%*60%*60%*60% = 12.96% of Score. | |
| // Since constellation relevance varies too much, the results will | |
| // be sorted by Score, not Adj Score. | |
| teamData[`stage${i}AdjScore`] = score * conFactor; | |
| } | |
| teams.push(teamData); | |
| }); | |
| return teams; | |
| } | |
| function formatTable(teams, stageName, stageIndex, hasTimeData = false) { | |
| const scoreKey = `stage${stageIndex}Score`; | |
| const adjScoreKey = `stage${stageIndex}AdjScore`; | |
| console.log(`\nTop ${stageName} teams:`); | |
| const tableData = teams.map(team => { | |
| const row = { | |
| Score: (team[scoreKey] * 100).toFixed(1) + '%', | |
| 'Adj Score': (team[adjScoreKey] * 100).toFixed(3) + '%' | |
| }; | |
| // Add Time column only if time data is available | |
| if (hasTimeData) { | |
| row['Avg Clear'] = `${team.time} s`; | |
| } | |
| row['Team'] = team.members.join(' + '); | |
| return row; | |
| }); | |
| console.table(tableData); | |
| } | |
| async function analyzeEndpoint(endpointName, endpointUrl, stageConfig) { | |
| try { | |
| console.log(`\n=== ${endpointName.toUpperCase()} ANALYSIS ===`); | |
| const ownedChars = getOwnedCharacters(); | |
| const data = await fetchData(endpointUrl); | |
| const charMap = buildCharacterMap(data); | |
| validateOwnedCharacters(ownedChars, charMap); | |
| const teams = processTeams(data, charMap, ownedChars, stageConfig.length); | |
| console.log(`\nSamples: ${data.top_own}`); | |
| // The API response says things like "Version 6.1" but it's always out of | |
| // date. This .update property is the most reliable, and reflects the 2-3 | |
| // day period at the beginning of the cycle when data is collected. | |
| // This could be dynamically cross-referenced with wiki or predicted | |
| // hardcoded version/cycle data. | |
| console.log(data.update); | |
| // Check if this is a stygian endpoint (has time data) | |
| const hasTimeData = endpointName.includes('Stygian'); | |
| // Generate top 10 teams for each stage | |
| stageConfig.forEach((stageName, index) => { | |
| const scoreKey = `stage${index}Score`; | |
| const topTeams = teams | |
| .sort((a, b) => b[scoreKey] - a[scoreKey]) | |
| .slice(0, 10); | |
| formatTable(topTeams, stageName, index, hasTimeData); | |
| }); | |
| } catch (error) { | |
| console.error(`${endpointName} Error:`, error); | |
| } | |
| } | |
| async function main() { | |
| const ownedChars = getOwnedCharacters(); | |
| console.log(`Processing ${Object.keys(ownedChars).length} owned characters`); | |
| const abyssStages = ['first-half', 'second-half']; | |
| const stygianStages = ['room-1', 'room-2', 'room-3']; | |
| await analyzeEndpoint('Abyss', ENDPOINTS.abyss, abyssStages); | |
| await analyzeEndpoint( | |
| 'Stygian (Fearless & Dire)', | |
| ENDPOINTS.stygian, | |
| stygianStages | |
| ); | |
| await analyzeEndpoint('Stygian (Dire)', ENDPOINTS.dire, stygianStages); | |
| } | |
| main(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment