+ import { mdiClose, mdiDownload, mdiHelpCircle, mdiLoading } from '@mdi/js';
+ import { onMount, tick } from 'svelte';
+ import dayjs from 'dayjs';
+
+ import { pushToast } from '../stores/toast';
+ import Button from './Button.svelte';
+ import Icon from './Icon.svelte';
+ import Input from './Input.svelte';
+ import Textarea from './Textarea.svelte';
+ import { weaponList } from '../data/weaponList';
+ import { characters } from '../data/characters';
+ import { readSave, updateSave } from '../stores/saveManager';
+
+ export let closeModal;
+
+ const fetchController = new AbortController();
+ const fetchSignal = fetchController.signal;
+
+ let numberFormat = Intl.NumberFormat();
+
+ let showFaq = false;
+ let selectedType = 'pc';
+
+ let generatedTextInput = '';
+ let genshinLink = '';
+
+ let types = {
+ 100: {
+ name: "Beginners' Wish",
+ id: 'beginners',
+ },
+ 200: {
+ name: 'Standard',
+ id: 'standard',
+ },
+ 301: {
+ name: 'Character Event',
+ id: 'character-event',
+ },
+ 302: {
+ name: 'Weapon Event',
+ id: 'weapon-event',
+ },
+ };
+
+ let wishes = {};
+
+ let url;
+
+ let processingLog = false;
+ let fetchingWishes = false;
+ let finishedProcessingLog = false;
+ let calculatingPity = false;
+
+ let cancelled = false;
+
+ let region = '';
+ let currentBanner = '';
+ let currentPage = 1;
+
+ function cancel() {
+ fetchController.abort();
+ cancelled = true;
+
+ setTimeout(() => {
+ closeModal();
+ }, 2000);
+ }
+
+ function getDeviceType() {
+ switch (selectedType) {
+ case 'pc':
+ return 'pc';
+ case 'android':
+ case 'ios':
+ return 'mobile';
+ }
+ }
+
+ async function startImport() {
+ if (selectedType === 'pclocal') {
+ importFromGeneratedText();
+ } else {
+ processLogs();
+ }
+ }
+
+ async function processLogs() {
+ processingLog = true;
+
+ try {
+ url = new URL(genshinLink);
+ } catch (err) {
+ pushToast('Invalid link, please check it again', 'error');
+ }
+
+ try {
+ for (const [wishNumber, type] of Object.entries(types)) {
+ await getLog(wishNumber, type);
+ if (cancelled) return;
+ await sleep(2000);
+ }
+ finishedProcessingLog = true;
+ } catch (err) {
+ wishes = {};
+ processingLog = false;
+ fetchingWishes = false;
+ finishedProcessingLog = false;
+ calculatingPity = false;
+
+ region = '';
+ currentBanner = '';
+ currentPage = 1;
+ }
+ }
+
+ async function getLog(wishNumber, type) {
+ fetchingWishes = true;
+
+ console.log(wishNumber, type);
+ url.searchParams.set('auth_appid', 'webview_gacha');
+ url.searchParams.set('init_type', '301');
+ url.searchParams.set('gacha_id', 'd610857102f9256ba143ccf2e03b964c76a6ed');
+ url.searchParams.set('lang', 'en');
+ url.searchParams.set('device_type', getDeviceType());
+ if (region !== '') url.searchParams.set('region', region);
+ url.searchParams.set('gacha_type', wishNumber);
+ url.searchParams.set('size', 20);
+ url.searchParams.append('lang', 'en-us');
+ url.hash = '';
+ url.host = 'hk4e-api-os.mihoyo.com';
+ url.pathname = 'event/gacha_info/api/getGachaLog';
+
+ currentBanner = type.name;
+
+ const weapons = Object.values(weaponList);
+ const chars = Object.values(characters);
+
+ let page = 1;
+ let result = [];
+ do {
+ if (cancelled) return;
+
+ url.searchParams.set('page', page);
+
+ currentPage = page;
+
+ try {
+ const res = await fetchRetry(
+ __paimon.env.CORS_HOST,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ url: url.toString(),
+ }),
+ signal: fetchSignal,
+ },
+ 2,
+ );
+
+ if (cancelled) return;
+
+ if (!res.ok) {
+ processingLog = false;
+ pushToast('Error code returned from MiHoYo API, please try again later!', 'error');
+ throw 'error code';
+ }
+
+ const dat = await res.json();
+
+ if (dat.retcode !== 0) {
+ processingLog = false;
+
+ if (dat.message === 'authkey timeout') {
+ pushToast('Link expired, please try again!', 'error');
+ throw 'error code';
+ }
+
+ pushToast('Error code returned from MiHoYo API, please try again later!', 'error');
+ throw 'error code';
+ }
+
+ region = dat.data.region;
+ result = dat.data.list;
+ } catch (err) {
+ processingLog = false;
+ pushToast('Connection timeout, please wait a moment and try again later', 'error');
+ throw 'network error';
+ }
+
+ try {
+ for (let row of result) {
+ const code = row.gacha_type;
+ const time = dayjs(row.time);
+ const name = row.name;
+ const type = row.item_type.replace(/ /g, '');
+
+ let id;
+ if (type === 'Weapon') {
+ id = weapons.find((e) => e.name === name).id;
+ } else if (type === 'Character') {
+ id = chars.find((e) => e.name === name).id;
+ }
+
+ if (wishes[code] === undefined) {
+ wishes[code] = [];
+ }
+
+ wishes[code] = [
+ ...wishes[code],
+ {
+ type: type.toLowerCase(),
+ id,
+ time: time.unix(),
+ pity: 0,
+ },
+ ];
+ }
+
+ page = page + 1;
+ await sleep(1000);
+ console.log(wishes);
+ } catch (err) {
+ processingLog = false;
+ pushToast('Invalid data returned from API, try again later!', 'error');
+ throw 'invalid data';
+ }
+ } while (result.length > 0);
+ }
+
+ async function fetchRetry(url, options, n) {
+ let error;
+ for (let i = 0; i < n; i++) {
+ if (cancelled) return;
+
+ try {
+ return await fetch(url, options);
+ } catch (err) {
+ error = err;
+ }
+ }
+ throw error;
+ }
+
+ function sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+ }
+
+ function toggleFaqs(show) {
+ showFaq = show;
+ }
+
+ function changeSelectedType(type) {
+ selectedType = type;
+ }
+
+ function detectPlatform() {
+ const userAgent = navigator.userAgent || navigator.vendor;
+ if (/android/i.test(userAgent)) {
+ selectedType = 'android';
+ }
+
+ if (/iPad|iPhone|iPod/.test(userAgent) && !window.MSStream) {
+ selectedType = 'ios';
+ }
+ }
+
+ function importFromGeneratedText() {
+ if (!generatedTextInput.startsWith('paimonmoeimporterv1###')) {
+ pushToast('Invalid data, please use the latest importer app', 'error');
+ return;
+ }
+
+ processingLog = true;
+
+ const rows = generatedTextInput.substring(22).split(';');
+
+ const weapons = Object.values(weaponList);
+ const chars = Object.values(characters);
+
+ try {
+ for (let row of rows) {
+ if (row === '') continue;
+
+ const cell = row.split(',');
+ const code = Number(cell[0]);
+ const time = dayjs(cell[1]);
+ const name = cell[2];
+ const type = cell[3].replace(/ /g, '');
+
+ let id;
+ if (type === 'Weapon') {
+ id = weapons.find((e) => e.name === name).id;
+ } else if (type === 'Character') {
+ id = chars.find((e) => e.name === name).id;
+ }
+
+ if (wishes[code] === undefined) {
+ wishes[code] = [];
+ }
+
+ wishes[code] = [
+ ...wishes[code],
+ {
+ type: type.toLowerCase(),
+ id,
+ time: time.unix(),
+ pity: 0,
+ },
+ ];
+ }
+ } catch (err) {
+ processingLog = false;
+ pushToast('Invalid data, please use the latest importer app', 'error');
+ return;
+ }
+
+ finishedProcessingLog = true;
+ console.log(wishes);
+ }
+
+ function saveData() {
+ calculatingPity = true;
+ for (let [code, type] of Object.entries(types)) {
+ processWishes(code, type);
+ }
+ calculatingPity = false;
+
+ pushToast('Import success 😀!');
+ closeModal();
+ }
+
+ function processWishes(code, type) {
+ if (wishes[code] === undefined) return;
+
+ const path = `wish-counter-${type.id}`;
+ const localData = readSave(path);
+
+ let localWishes = [];
+ if (localData !== null) {
+ const counterData = JSON.parse(localData);
+ localWishes = counterData.pulls || [];
+ }
+
+ const importedWishes = wishes[code].slice().reverse();
+ const oldestWish = importedWishes[0];
+
+ localWishes = localWishes
+ .slice()
+ .filter((e) => e.time < oldestWish.time)
+ .sort((a, b) => a.time - b.time);
+
+ const combined = [...localWishes, ...importedWishes];
+
+ let rare = 0;
+ let legendary = 0;
+ for (let i = 0; i < combined.length; i++) {
+ if (combined[i].pity !== 0) continue;
+
+ rare++;
+ legendary++;
+
+ let rarity;
+ if (combined[i].type === 'character') {
+ rarity = characters[combined[i].id].rarity;
+ } else if (combined[i].type === 'weapon') {
+ rarity = weaponList[combined[i].id].rarity;
+ }
+
+ if (rarity === 5) {
+ combined[i].pity = legendary;
+ legendary = 0;
+ rare = 0;
+ } else if (rarity === 4) {
+ combined[i].pity = rare;
+ rare = 0;
+ } else {
+ combined[i].pity = 1;
+ }
+ }
+
+ const data = JSON.stringify({
+ total: combined.length,
+ legendary,
+ rare,
+ pulls: combined,
+ });
+
+ updateSave(path, data);
+ }
+
+ onMount(() => {
+ detectPlatform();
+ });
+
+
+{#if processingLog}
+
Import Wish History
+
+ {#if finishedProcessingLog}
+
+ {#each Object.entries(types) as [code, type]}
+ {#if wishes[code] !== undefined}
+
+
+ {type.name} Banner
+
+
+
+
+ {numberFormat.format(wishes[code].length)}
+
+
+
+ {/if}
+ {/each}
+
+
Imported wishes will be appended or replaced accordingly to existing data
+
If you don't have any data saved before, first wish will be counted as pity 1
+
Save the data?
+ {:else if calculatingPity}
+
+ Re-calculating pity...
+ {:else if fetchingWishes}
+
+
+
+
{`Processing ${currentBanner} Banner`}
+
{`Page ${currentPage}`}
+
+
+
+ {#each Object.entries(types) as [code, type]}
+ {#if wishes[code] !== undefined}
+
+
+ {type.name} Banner
+
+
+
+
+ {numberFormat.format(wishes[code].length)}
+
+
+
+ {/if}
+ {/each}
+
+ {:else}
+
+ Parsing...
+ {/if}
+
+
+ {#if finishedProcessingLog && !calculatingPity}
+ Save
+ {/if}
+ {cancelled ? 'Cancelling...' : 'Cancel'}
+
+{:else}
+
+ {#if showFaq}
+
Import Wish History FAQS
+
+
+
How does it work?
+
+ Genshin Impact wish history is basically a web page, so you can access it by opening the web page url. A
+ temporary key will be generated after you open the wish history page or the feedback page, and the importer
+ will automatically use the MiHoYo API to fetch your wish history.
+
+
Is it safe? Will I get banned?
+
+ Paimon.moe use the same request that Genshin Impact use to get the wish history, and Paimon.moe has no way
+ whatsoever to modify any game files or memory, and it should be safe. But use it at your own risk (well I use
+ it on my main account). You still can input your data manually 😀.
+
+
+ Hey I checked the request and stuff, but why it request to your domain instead of MiHoYo API?
+
+
+ Paimon.moe cannot request directly to MiHoYo API because of
+
+ CORS , so the request redirected to a simple cors proxy to make it work. You can see the code
+ here
+
+
Do you store my temporary key?
+
+ Paimon.moe never store your key, and use HTTPS to pass your url to a cors proxy to make the CORS works.
+
+
+
+ {:else}
+
+
Import Wish History
+ toggleFaqs(true)}>
+
+ FAQS
+
+
+
+ changeSelectedType('pc')} class={`pill ${selectedType === 'pc' ? 'active' : ''}`}>
+ PC
+
+
+ changeSelectedType('android')}
+ class={`pill ${selectedType === 'android' ? 'active' : ''}`}
+ >
+ Android
+
+ changeSelectedType('ios')} class={`pill ${selectedType === 'ios' ? 'active' : ''}`}>
+ iOS
+
+
+ {#if selectedType === 'pc'}
+
+
+ Open Paimon menu [ESC]
+ Click Feedback
+ Wait for it to load and a browser page should open
+ Copy & paste the link to the textbox below
+
+
+
+ {:else if selectedType === 'pclocal'}
+
+
+
+ Downlod the importer app toggleFaqs(true)}>
+
+ Download
+
+
+ Open the wish history on your Genshin impact in this PC
+ Press IMPORT
+ Copy & paste the generated text to the textbox below
+
+
+
+ {:else if selectedType === 'android'}
+
+
+ Open Paimon menu
+ Press Feedback
+ Wait for it to load and a feedback page should open
+ Turn off your wifi and data connection
+ Press refresh on top right corner
+ The page should error and show you a link with black font, copy that link
+ Turn on your wifi or data connection
+ Paste the link to the textbox below
+
+
+
+ {:else if selectedType === 'ios'}
+
+ Sorry I don't know yet how to access the link from iOS... If you have the link you can still paste it
+ below.
+
+
+ {/if}
+ {/if}
+
+
+ {#if !showFaq}
+ Import
+ {/if}
+ toggleFaqs(false) : () => closeModal()}>Close
+
+
+{/if}
+
+
diff --git a/src/routes/wish/_counter.svelte b/src/routes/wish/_counter.svelte
index 350d74e4..a48f712a 100644
--- a/src/routes/wish/_counter.svelte
+++ b/src/routes/wish/_counter.svelte
@@ -32,11 +32,21 @@
let legendaryEdit = 0;
let rareEdit = 0;
+ let showRarity = [true, true, false];
+
$: path = `wish-counter-${id}`;
$: if ($fromRemote) {
readLocalData();
}
- $: sortedPull = pulls.sort((a, b) => b.time - a.time);
+ $: sortedPull = pulls
+ .filter((e) => {
+ if (e.type === 'character') {
+ return showRarity[5 - characters[e.id].rarity];
+ } else if (e.type === 'weapon') {
+ return showRarity[5 - weaponList[e.id].rarity];
+ }
+ })
+ .sort((a, b) => b.time - a.time);
onMount(() => {
readLocalData();
@@ -46,6 +56,10 @@
isDetailOpen = !isDetailOpen;
}
+ function toggleShowRarity(index) {
+ showRarity[index] = !showRarity[index];
+ }
+
function openAddModal(pity) {
openModal(
AddModal,
@@ -124,7 +138,7 @@
isEdit = false;
}
- function readLocalData() {
+ export function readLocalData() {
console.log('wish read local');
const data = readSave(path);
if (data !== null) {
@@ -152,21 +166,42 @@
total += val;
- rare += val;
- if (rare >= 10) {
- openAddModal(rare);
- rare = 0;
- } else if (rare < 0) {
- rare = 9;
- }
-
legendary += val;
+ let filler = val;
if (legendary >= legendaryPity) {
- openAddModal(legendary);
+ openAddModal(Math.min(rare, legendaryPity));
legendary = 0;
rare = 0;
+ filler--;
} else if (legendary < 0) {
legendary = 89;
+ } else {
+ rare += val;
+ if (rare >= 10) {
+ openAddModal(Math.min(rare, 10));
+ rare = 0;
+ filler--;
+ } else if (rare < 0) {
+ rare = 9;
+ }
+ }
+
+ if (filler > 0) {
+ pulls = [
+ ...pulls,
+ ...[...new Array(filler)].map((e) => ({
+ type: 'unknown_3_star',
+ id: 'unknown_3_star',
+ time: dayjs().unix(),
+ pity: 1,
+ })),
+ ];
+ }
+
+ if (val < 0) {
+ const cloned = [...pulls];
+ cloned.pop();
+ pulls = cloned;
}
saveData();
@@ -274,6 +309,17 @@