mirror of
https://github.com/MadeBaruna/paimon-moe.git
synced 2025-03-14 19:48:59 +01:00
Add wish auto import
This commit is contained in:
parent
439ca8fa71
commit
5e96df8aa9
8 changed files with 1029 additions and 26 deletions
39
src/components/Textarea.svelte
Normal file
39
src/components/Textarea.svelte
Normal file
|
@ -0,0 +1,39 @@
|
|||
<script>
|
||||
export let className = '';
|
||||
export let placeholder = '';
|
||||
export let step = undefined;
|
||||
export let min = Math.min();
|
||||
export let max = Math.max();
|
||||
|
||||
export let value = '';
|
||||
|
||||
const handleInput = (event) => {
|
||||
value = event.target.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={`container overflow-hidden flex flex-1 relative items-center bg-background rounded-2xl focus-within:border-primary border-2 border-transparent ease-in duration-100 ${className}`}
|
||||
>
|
||||
<textarea
|
||||
{placeholder}
|
||||
{value}
|
||||
{min}
|
||||
{max}
|
||||
{step}
|
||||
on:change
|
||||
on:input={handleInput}
|
||||
class={`w-full min-h-full pr-4 text-white placeholder-gray-500 leading-none bg-transparent border-none focus:outline-none`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 100px;
|
||||
@apply p-4;
|
||||
}
|
||||
</style>
|
|
@ -9,7 +9,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed left-0 right-0 bottom-0 text-center px-4 z-50 md:left-auto md:max-w-screen-sm">
|
||||
<div class="fixed left-0 right-0 bottom-0 text-center px-4 md:left-auto md:max-w-screen-sm" style="z-index:99999;">
|
||||
{#each $toasts as toast (toast._id)}
|
||||
<div
|
||||
class={`rounded-xl px-4 py-2 mb-4 w-full bg-black bg-opacity-75 ${types[toast.type]}`}
|
||||
|
|
615
src/components/WishImportModal.svelte
Normal file
615
src/components/WishImportModal.svelte
Normal file
|
@ -0,0 +1,615 @@
|
|||
<script>
|
||||
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();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if processingLog}
|
||||
<h1 class="font-display text-white text-xl mb-2">Import Wish History</h1>
|
||||
<div class="bg-background rounded-xl px-4 py-2 text-white mt-2">
|
||||
{#if finishedProcessingLog}
|
||||
<table class="min-w-full md:min-w-0">
|
||||
{#each Object.entries(types) as [code, type]}
|
||||
{#if wishes[code] !== undefined}
|
||||
<tr>
|
||||
<td class="border-b border-gray-700 py-1">
|
||||
<span class="text-white mr-2 whitespace-no-wrap">{type.name} Banner</span>
|
||||
</td>
|
||||
<td class="border-b border-gray-700 py-1">
|
||||
<span class="text-white mr-2 whitespace-no-wrap">
|
||||
<Icon size={0.5} path={mdiClose} />
|
||||
{numberFormat.format(wishes[code].length)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</table>
|
||||
<p class="mt-4">Imported wishes will be appended or replaced accordingly to existing data</p>
|
||||
<p>If you don't have any data saved before, first wish will be counted as pity 1</p>
|
||||
<p class="font-semibold">Save the data?</p>
|
||||
{:else if calculatingPity}
|
||||
<Icon path={mdiLoading} spin color="white" />
|
||||
Re-calculating pity...
|
||||
{:else if fetchingWishes}
|
||||
<div class="flex">
|
||||
<Icon path={mdiLoading} spin color="white" />
|
||||
<div class="ml-2">
|
||||
<p>{`Processing ${currentBanner} Banner`}</p>
|
||||
<p>{`Page ${currentPage}`}</p>
|
||||
</div>
|
||||
</div>
|
||||
<table class="min-w-full md:min-w-0 mt-2">
|
||||
{#each Object.entries(types) as [code, type]}
|
||||
{#if wishes[code] !== undefined}
|
||||
<tr>
|
||||
<td class="border-b border-gray-700 py-1">
|
||||
<span class="text-white mr-2 whitespace-no-wrap">{type.name} Banner</span>
|
||||
</td>
|
||||
<td class="border-b border-gray-700 py-1">
|
||||
<span class="text-white mr-2 whitespace-no-wrap">
|
||||
<Icon size={0.5} path={mdiClose} />
|
||||
{numberFormat.format(wishes[code].length)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</table>
|
||||
{:else}
|
||||
<Icon path={mdiLoading} spin color="white" />
|
||||
Parsing...
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex justify-end mt-4">
|
||||
{#if finishedProcessingLog && !calculatingPity}
|
||||
<Button on:click={saveData} color="green" className="mr-4">Save</Button>
|
||||
{/if}
|
||||
<Button on:click={cancel} disabled={calculatingPity || cancelled}>{cancelled ? 'Cancelling...' : 'Cancel'}</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
{#if showFaq}
|
||||
<h1 class="font-display text-white text-xl mb-2">Import Wish History FAQS</h1>
|
||||
|
||||
<div class="font-body">
|
||||
<p class="text-white font-semibold">How does it work?</p>
|
||||
<p class="text-gray-400">
|
||||
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.
|
||||
</p>
|
||||
<p class="text-white font-semibold mt-4">Is it safe? Will I get banned?</p>
|
||||
<p class="text-gray-400">
|
||||
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 😀.
|
||||
</p>
|
||||
<p class="text-white font-semibold mt-4">
|
||||
Hey I checked the request and stuff, but why it request to your domain instead of MiHoYo API?
|
||||
</p>
|
||||
<p class="text-gray-400">
|
||||
Paimon.moe cannot request directly to MiHoYo API because of
|
||||
<a
|
||||
class="text-primary hover:underline"
|
||||
href="https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS"
|
||||
target="__blank"
|
||||
>
|
||||
CORS</a
|
||||
>, so the request redirected to a simple cors proxy to make it work. You can see the code
|
||||
<a
|
||||
class="text-primary hover:underline"
|
||||
href="https://gist.github.com/MadeBaruna/64785ae992c924e0cbfe575e404b7155"
|
||||
target="__blank">here</a
|
||||
>
|
||||
</p>
|
||||
<p class="text-white font-semibold mt-4">Do you store my temporary key?</p>
|
||||
<p class="text-gray-400">
|
||||
Paimon.moe never store your key, and use HTTPS to pass your url to a cors proxy to make the CORS works.
|
||||
<!-- If you don't want any passing around your url, you can use the small importer app to process the wish
|
||||
history on your local PC (PC Local option) -->
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center">
|
||||
<h1 class="font-display text-white text-xl mb-2 mr-2">Import Wish History</h1>
|
||||
<Button size="sm" on:click={() => toggleFaqs(true)}>
|
||||
<Icon path={mdiHelpCircle} color="white" />
|
||||
FAQS
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex mt-4 flex-wrap">
|
||||
<button on:click={() => changeSelectedType('pc')} class={`pill ${selectedType === 'pc' ? 'active' : ''}`}>
|
||||
PC
|
||||
</button>
|
||||
<!-- <button
|
||||
on:click={() => changeSelectedType('pclocal')}
|
||||
class={`pill ${selectedType === 'pclocal' ? 'active' : ''}`}
|
||||
>
|
||||
PC Local
|
||||
</button> -->
|
||||
<button
|
||||
on:click={() => changeSelectedType('android')}
|
||||
class={`pill ${selectedType === 'android' ? 'active' : ''}`}
|
||||
>
|
||||
Android
|
||||
</button>
|
||||
<button on:click={() => changeSelectedType('ios')} class={`pill ${selectedType === 'ios' ? 'active' : ''}`}>
|
||||
iOS
|
||||
</button>
|
||||
</div>
|
||||
{#if selectedType === 'pc'}
|
||||
<div class="bg-background rounded-xl px-4 py-2 text-white mb-4 mt-2">
|
||||
<ol class="list-decimal ml-4">
|
||||
<li class="my-2">Open Paimon menu [ESC]</li>
|
||||
<li class="my-2">Click Feedback</li>
|
||||
<li class="my-2">Wait for it to load and a browser page should open</li>
|
||||
<li class="my-2">Copy & paste the link to the textbox below</li>
|
||||
</ol>
|
||||
</div>
|
||||
<Input bind:value={genshinLink} placeholder="Paste link here... https://webstatic..." />
|
||||
{:else if selectedType === 'pclocal'}
|
||||
<div class="bg-background rounded-xl px-4 py-2 text-white mb-4 mt-2">
|
||||
<ol class="list-decimal ml-4">
|
||||
<li class="mt-2 mb-0">
|
||||
Downlod the importer app <Button size="sm" on:click={() => toggleFaqs(true)}>
|
||||
<Icon path={mdiDownload} color="white" />
|
||||
Download
|
||||
</Button>
|
||||
</li>
|
||||
<li class="my-2">Open the wish history on your Genshin impact in this PC</li>
|
||||
<li class="my-2">Press IMPORT</li>
|
||||
<li class="my-2">Copy & paste the generated text to the textbox below</li>
|
||||
</ol>
|
||||
</div>
|
||||
<Textarea bind:value={generatedTextInput} placeholder="Paste the generated text here..." />
|
||||
{:else if selectedType === 'android'}
|
||||
<div class="bg-background rounded-xl px-4 py-2 text-white mb-4 mt-2">
|
||||
<ol class="list-decimal ml-4">
|
||||
<li class="my-2">Open Paimon menu</li>
|
||||
<li class="my-2">Press Feedback</li>
|
||||
<li class="my-2">Wait for it to load and a feedback page should open</li>
|
||||
<li class="my-2">Turn off your wifi and data connection</li>
|
||||
<li class="my-2">Press refresh on top right corner</li>
|
||||
<li class="my-2">The page should error and show you a link with black font, copy that link</li>
|
||||
<li class="my-2">Turn on your wifi or data connection</li>
|
||||
<li class="my-2">Paste the link to the textbox below</li>
|
||||
</ol>
|
||||
</div>
|
||||
<Input bind:value={genshinLink} placeholder="Paste link here... https://webstatic..." />
|
||||
{:else if selectedType === 'ios'}
|
||||
<div class="bg-background rounded-xl px-4 py-2 text-white mb-4 mt-2">
|
||||
Sorry I don't know yet how to access the link from iOS...<br />If you have the link you can still paste it
|
||||
below.
|
||||
</div>
|
||||
<Input bind:value={genshinLink} placeholder="Paste link here... https://webstatic..." />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
{#if !showFaq}
|
||||
<Button on:click={startImport} color="green" className="mr-4">Import</Button>
|
||||
{/if}
|
||||
<Button on:click={showFaq ? () => toggleFaqs(false) : () => closeModal()}>Close</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.pill {
|
||||
@apply rounded-2xl;
|
||||
@apply border-2;
|
||||
@apply border-white;
|
||||
@apply border-opacity-25;
|
||||
@apply text-white;
|
||||
@apply px-4;
|
||||
@apply py-1;
|
||||
@apply mr-2;
|
||||
@apply mb-2;
|
||||
@apply outline-none;
|
||||
@apply transition;
|
||||
@apply duration-100;
|
||||
|
||||
&:hover {
|
||||
@apply border-primary;
|
||||
}
|
||||
|
||||
&.active {
|
||||
@apply bg-primary;
|
||||
@apply border-primary;
|
||||
@apply text-background;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -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 @@
|
|||
</div>
|
||||
<Button size="sm" className="w-16" on:click={() => openAddModal(0)}>Add</Button>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<button on:click={() => toggleShowRarity(0)} class={`pill legendary ${showRarity[0] ? 'active' : ''}`}>
|
||||
5 <Icon path={mdiStar} size={0.75} className="mb-1" />
|
||||
</button>
|
||||
<button on:click={() => toggleShowRarity(1)} class={`pill rare ${showRarity[1] ? 'active' : ''}`}>
|
||||
4 <Icon path={mdiStar} size={0.75} className="mb-1" />
|
||||
</button>
|
||||
<button on:click={() => toggleShowRarity(2)} class={`pill normal ${showRarity[2] ? 'active' : ''}`}>
|
||||
3 <Icon path={mdiStar} size={0.75} className="mb-1" />
|
||||
</button>
|
||||
</div>
|
||||
<table class="w-full">
|
||||
<tr>
|
||||
<th class="border-b border-gray-700 text-gray-400 font-display text-left pl-2">Name</th>
|
||||
|
@ -285,17 +331,27 @@
|
|||
{#if pull.type === 'character'}
|
||||
<td
|
||||
class={`border-b border-gray-700 py-1 pl-2 font-semibold ${
|
||||
characters[pull.id].rarity === 5 ? 'text-legendary-from' : 'text-rare-from'
|
||||
characters[pull.id].rarity === 5
|
||||
? 'text-legendary-from'
|
||||
: characters[pull.id].rarity === 4
|
||||
? 'text-rare-from'
|
||||
: 'text-primary'
|
||||
}`}>{characters[pull.id].name}</td
|
||||
>
|
||||
{:else}
|
||||
{:else if pull.type === 'weapon'}
|
||||
<td
|
||||
class={`border-b border-gray-700 py-1 pl-2 font-semibold ${
|
||||
weaponList[pull.id].rarity === 5 ? 'text-legendary-from' : 'text-rare-from'
|
||||
weaponList[pull.id].rarity === 5
|
||||
? 'text-legendary-from'
|
||||
: weaponList[pull.id].rarity === 4
|
||||
? 'text-rare-from'
|
||||
: 'text-primary'
|
||||
}`}>{weaponList[pull.id].name}</td
|
||||
>
|
||||
{/if}
|
||||
<td class="border-b border-gray-700 text-sm py-1 px-2 whitespace-no-wrap" style="font-family: monospace;">{dayjs.unix(pull.time).format('YYYY-MM-DD HH:mm:ss')}</td>
|
||||
<td class="border-b border-gray-700 text-sm py-1 px-2 whitespace-no-wrap" style="font-family: monospace;"
|
||||
>{dayjs.unix(pull.time).format('YYYY-MM-DD HH:mm:ss')}</td
|
||||
>
|
||||
<td class="text-right border-b border-gray-700 py-1">{pull.pity}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
|
@ -303,3 +359,40 @@
|
|||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pill {
|
||||
@apply rounded-2xl;
|
||||
@apply border-2;
|
||||
@apply border-white;
|
||||
@apply border-opacity-25;
|
||||
@apply text-white;
|
||||
@apply px-4;
|
||||
@apply py-1;
|
||||
@apply mr-2;
|
||||
@apply mb-2;
|
||||
@apply outline-none;
|
||||
@apply transition;
|
||||
@apply duration-100;
|
||||
|
||||
&:hover {
|
||||
@apply border-primary;
|
||||
}
|
||||
|
||||
&.active {
|
||||
@apply bg-primary;
|
||||
@apply border-primary;
|
||||
@apply text-background;
|
||||
|
||||
&.legendary {
|
||||
@apply bg-legendary-from;
|
||||
@apply border-legendary-from;
|
||||
}
|
||||
|
||||
&.rare {
|
||||
@apply bg-rare-from;
|
||||
@apply border-rare-from;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
120
src/routes/wish/_summary.svelte
Normal file
120
src/routes/wish/_summary.svelte
Normal file
|
@ -0,0 +1,120 @@
|
|||
<script>
|
||||
import { mdiStar } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import Icon from '../../components/Icon.svelte';
|
||||
import { characters } from '../../data/characters';
|
||||
import { weaponList } from '../../data/weaponList';
|
||||
|
||||
import { readSave, updateTime, fromRemote } from '../../stores/saveManager';
|
||||
import SummaryItem from './_summaryItem.svelte';
|
||||
|
||||
const types = [
|
||||
{
|
||||
name: 'Character Event',
|
||||
id: 'character-event',
|
||||
},
|
||||
{
|
||||
name: 'Weapon Event',
|
||||
id: 'weapon-event',
|
||||
},
|
||||
{
|
||||
name: 'Standard',
|
||||
id: 'standard',
|
||||
},
|
||||
{
|
||||
name: "Beginners' Wish",
|
||||
id: 'beginners',
|
||||
},
|
||||
];
|
||||
|
||||
let loading = true;
|
||||
const avg = {};
|
||||
|
||||
$: if ($fromRemote) {
|
||||
readLocalData();
|
||||
}
|
||||
|
||||
$: if ($updateTime) {
|
||||
readLocalData();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
readLocalData();
|
||||
});
|
||||
|
||||
export function readLocalData() {
|
||||
console.log('wish summary read local');
|
||||
|
||||
for (let type of types) {
|
||||
const path = `wish-counter-${type.id}`;
|
||||
const data = readSave(path);
|
||||
if (data !== null) {
|
||||
const counterData = JSON.parse(data);
|
||||
const pulls = counterData.pulls || [];
|
||||
const total = counterData.total;
|
||||
|
||||
let legendary = 0;
|
||||
let legendaryPity = 0;
|
||||
let legendaryPulls = [];
|
||||
let rare = 0;
|
||||
let rarePity = 0;
|
||||
for (let pull of pulls) {
|
||||
let rarity;
|
||||
let itemName;
|
||||
if (pull.type === 'character') {
|
||||
rarity = characters[pull.id].rarity;
|
||||
itemName = characters[pull.id].name;
|
||||
} else if (pull.type === 'weapon') {
|
||||
rarity = weaponList[pull.id].rarity;
|
||||
itemName = weaponList[pull.id].name;
|
||||
}
|
||||
|
||||
if (rarity === 5) {
|
||||
legendary++;
|
||||
legendaryPity += pull.pity;
|
||||
legendaryPulls.push({ name: itemName, pity: pull.pity });
|
||||
} else if (rarity === 4) {
|
||||
rare++;
|
||||
rarePity += pull.pity;
|
||||
}
|
||||
}
|
||||
|
||||
avg[type.id] = {
|
||||
rare: {
|
||||
total: rare,
|
||||
percentage: total > 0 ? rare / total : 0,
|
||||
pity: rare > 0 ? rarePity / rare : 0,
|
||||
},
|
||||
legendary: {
|
||||
total: legendary,
|
||||
percentage: total > 0 ? legendary / total : 0,
|
||||
pity: legendary > 0 ? legendaryPity / legendary : 0,
|
||||
pulls: legendaryPulls,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
console.log(avg);
|
||||
loading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !loading}
|
||||
<div class="flex flex-col">
|
||||
{#if avg[types[0].id]}
|
||||
<SummaryItem avg={avg[types[0].id]} type={types[0]} withBottomSpace />
|
||||
{/if}
|
||||
{#if avg[types[1].id]}
|
||||
<SummaryItem avg={avg[types[1].id]} type={types[1]} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
{#if avg[types[2].id]}
|
||||
<SummaryItem avg={avg[types[2].id]} type={types[2]} withBottomSpace />
|
||||
{/if}
|
||||
{#if avg[types[3].id]}
|
||||
<SummaryItem avg={avg[types[3].id]} type={types[3]} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
90
src/routes/wish/_summaryItem.svelte
Normal file
90
src/routes/wish/_summaryItem.svelte
Normal file
|
@ -0,0 +1,90 @@
|
|||
<script>
|
||||
import { mdiStar } from '@mdi/js';
|
||||
|
||||
import Icon from '../../components/Icon.svelte';
|
||||
|
||||
export let withBottomSpace;
|
||||
export let avg;
|
||||
export let type;
|
||||
|
||||
let numberFormat = Intl.NumberFormat('en', {
|
||||
maximumFractionDigits: 1,
|
||||
minimumFractionDigits: 0,
|
||||
});
|
||||
|
||||
function calculateColor(percentage) {
|
||||
const a = [255, 177, 63];
|
||||
const b = [255, 77, 77];
|
||||
|
||||
const av = percentage;
|
||||
const bv = 1 - percentage;
|
||||
|
||||
const color = [a[0] * av + b[0] * bv, a[1] * av + b[1] * bv, a[2] * av + b[2] * bv];
|
||||
|
||||
return `color: rgb(${color[0]},${color[1]},${color[2]});`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={`bg-item rounded-xl p-4 flex flex-col w-full ${withBottomSpace ? 'mb-4' : ''}`} style="height: min-content;">
|
||||
<table>
|
||||
<tr>
|
||||
<td class="text-white text-md font-semibold pr-2 md:pr-4 flex-1 w-full">{type.name}</td>
|
||||
<td class="text-gray-400 text-sm font-display pr-2 md:pr-4 text-right">Total</td>
|
||||
<td class="text-gray-400 text-sm font-display pr-2 md:pr-4 text-right">Percent</td>
|
||||
<td class="text-gray-400 text-sm font-display text-right whitespace-no-wrap">Pity AVG</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-legendary-from font-semibold pr-2 md:pr-4 border-t border-gray-700">
|
||||
5 <Icon path={mdiStar} color="#FFB13F" size="0.6" />
|
||||
</td>
|
||||
<td class="text-legendary-from font-semibold pr-2 md:pr-4 text-right border-t border-gray-700">
|
||||
{numberFormat.format(avg.legendary.total)}
|
||||
</td>
|
||||
<td class="text-legendary-from font-semibold pr-2 md:pr-4 text-right border-t border-gray-700">
|
||||
{numberFormat.format(avg.legendary.percentage * 100)}%
|
||||
</td>
|
||||
<td class="text-legendary-from font-semibold text-right border-t border-gray-700">
|
||||
{numberFormat.format(avg.legendary.pity)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-rare-from font-semibold pr-2 md:pr-4 border-t border-gray-700">
|
||||
4 <Icon path={mdiStar} color="#AD76B0" size="0.6" />
|
||||
</td>
|
||||
<td class="text-rare-from font-semibold pr-2 md:pr-4 text-right border-t border-gray-700">
|
||||
{numberFormat.format(avg.rare.total)}
|
||||
</td>
|
||||
<td class="text-rare-from font-semibold pr-2 md:pr-4 text-right border-t border-gray-700">
|
||||
{numberFormat.format(avg.rare.percentage * 100)}%
|
||||
</td>
|
||||
<td class="text-rare-from font-semibold text-right border-t border-gray-700">
|
||||
{numberFormat.format(avg.rare.pity)}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{#if avg.legendary.pulls.length > 0}
|
||||
<div class="flex flex-wrap mt-2">
|
||||
{#each avg.legendary.pulls as pull}
|
||||
<span class="pity">{pull.name} <span style={calculateColor((90 - pull.pity) / 90)}>{pull.pity}</span></span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
span.pity {
|
||||
@apply rounded-xl;
|
||||
@apply text-gray-400;
|
||||
@apply border;
|
||||
@apply border-legendary-from;
|
||||
@apply whitespace-no-wrap;
|
||||
@apply px-2;
|
||||
@apply mb-1;
|
||||
@apply mr-1;
|
||||
|
||||
& > span {
|
||||
@apply font-semibold;
|
||||
@apply pl-1;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,14 +1,22 @@
|
|||
<script>
|
||||
import { mdiHelpCircle } from '@mdi/js';
|
||||
import { mdiDatabaseImport, mdiHelpCircle } from '@mdi/js';
|
||||
import { getContext } from 'svelte';
|
||||
|
||||
import Button from '../../components/Button.svelte';
|
||||
import Icon from '../../components/Icon.svelte';
|
||||
import HowToModal from '../../components/WishCounterHowToModal.svelte';
|
||||
|
||||
import ImportModal from '../../components/WishImportModal.svelte';
|
||||
|
||||
import Summary from './_summary.svelte';
|
||||
import Counter from './_counter.svelte';
|
||||
|
||||
const { open: openModal } = getContext('simple-modal');
|
||||
const { open: openModal, close: closeModal } = getContext('simple-modal');
|
||||
|
||||
let counter1;
|
||||
let counter2;
|
||||
let counter3;
|
||||
let counter4;
|
||||
let summary;
|
||||
|
||||
function openHowTo() {
|
||||
openModal(
|
||||
|
@ -20,30 +28,68 @@
|
|||
},
|
||||
);
|
||||
}
|
||||
|
||||
function openImport() {
|
||||
openModal(
|
||||
ImportModal,
|
||||
{
|
||||
closeModal: closeImportModal,
|
||||
},
|
||||
{
|
||||
closeButton: false,
|
||||
closeOnOuterClick: false,
|
||||
styleWindow: { background: '#25294A', width: '800px' },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function closeImportModal() {
|
||||
closeModal();
|
||||
counter1.readLocalData();
|
||||
counter2.readLocalData();
|
||||
counter3.readLocalData();
|
||||
counter4.readLocalData();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Wish Counter - Paimon.moe</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Genshin Impact Wish Counter to track your pity counter and track when you get the character or weapon"
|
||||
content="Genshin Impact Wish Counter to track your pity counter and track when you get the character or weapon. You can also auto import the logs from your PC."
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Genshin Impact Wish Counter to track your pity counter and track when you get the character or weapon"
|
||||
content="Genshin Impact Wish Counter to track your pity counter and track when you get the character or weapon. You can also auto import the logs from your PC."
|
||||
/>
|
||||
</svelte:head>
|
||||
<div class="pt-20 lg:ml-64 lg:pt-8 px-4 md:px-8">
|
||||
<div class="flex flex-col md:flex-row mb-4 items-center">
|
||||
<h1 class="font-display font-black text-5xl text-white text-center md:text-left md:mr-4">Wish Counter</h1>
|
||||
<Button on:click={openHowTo}>
|
||||
<Button on:click={openHowTo} className="hidden md:block">
|
||||
<Icon size={0.8} path={mdiHelpCircle} />
|
||||
How To Use
|
||||
</Button>
|
||||
<Button className="ml-2 hidden md:block" on:click={openImport}>
|
||||
<Icon size={0.8} path={mdiDatabaseImport} />
|
||||
Auto Import
|
||||
</Button>
|
||||
<div class="md:hidden flex flex-wrap justify-center">
|
||||
<Button className="m-1" on:click={openHowTo}>
|
||||
<Icon size={0.8} path={mdiHelpCircle} />
|
||||
How To Use
|
||||
</Button>
|
||||
<Button className="m-1" on:click={openImport}>
|
||||
<Icon size={0.8} path={mdiDatabaseImport} />
|
||||
Auto Import
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-4 grid-cols-1 md:grid-cols-2 xl:grid-cols-3 max-w-screen-xl">
|
||||
<Counter id="character-event" name="Character Event" />
|
||||
<Counter id="weapon-event" name="Weapon Event" legendaryPity={80} />
|
||||
<Counter id="standard" name="Standard" />
|
||||
<Counter bind:this={counter1} id="character-event" name="Character Event" />
|
||||
<Counter bind:this={counter2} id="weapon-event" name="Weapon Event" legendaryPity={80} />
|
||||
<Counter bind:this={counter3} id="standard" name="Standard" />
|
||||
<Counter bind:this={counter4} id="beginners" name="Beginners' Wish" />
|
||||
<Summary bind:this={summary} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -25,7 +25,7 @@ module.exports = {
|
|||
to: '#665680',
|
||||
},
|
||||
legendary: {
|
||||
from: '#B9812E',
|
||||
from: '#FFB13F',
|
||||
to: '#846332',
|
||||
},
|
||||
},
|
||||
|
|
Loading…
Add table
Reference in a new issue