After quitting Genshin Impact, Honkai: Star Rail, and Zenless Zone Zero, I recently got hooked on Wuthering Waves (yes, the “xxx just has to xxx, while xxx has so much more to worry about” game). When it comes to these anime-style gacha games, the cursed gacha system is an unavoidable part of the experience. And where there’s gacha, there’s always the question of whether you’ll win the 50/50 for a character or weapon — which has spawned a bunch of third-party mini-apps like “xxx Assistant” and “xxx Workshop.” One of their key features is gacha analysis.
The whole thing started when a friend wanted to check my Honkai: Star Rail warp history. But when we opened the mini-app we’d used before, all the previously imported records were gone. We tried downloading the Star Rail cloud game to re-import the warp link and restore the data, but after messing around for ages, the mini-app kept saying the warp URL was invalid. In the end, we never got the history back. That stuck with me, so I decided to build my own gacha analysis tool — one where I’d keep the data in my own hands. That’s how Astrionyx came to be.

Astrionyx
Astrionyx is a Next.js web app. Astrionyx is currently a Next.js frontend + Go backend project. It supports analyzing different types of banner records. You can manually import or update data, or import it via the API. Besides Wuthering Waves, it now also supports Genshin Impact, Honkai: Star Rail, Zenless Zone Zero, and Arknights: Endfield. The frontend can be tossed straight onto Vercel / EdgeOne Pages for hosting, while the backend deploys to your own server via Docker. Setup is ridiculously easy.
While building Astrionyx, I ran into quite a few interesting problems. If you’re thinking about writing a similar app, I hope this helps.
Data Import and Updates
Data Import
Like most gacha games, Wuthering Waves doesn’t provide an official API for exporting pull data. The way it shows your pull history is: when you tap the “History” button, the game generates a temporary link and opens it in the in-game browser to display the history. You can capture this link — which starts with https://aki-gm-resources.aki-game.com/aki/gacha/index.html — by sniffing network traffic or pulling it from log files.
That page sends a POST request to the API at https://gmserver-api.aki-game2.com/gacha/record/query to fetch the pull history for each banner. The request includes key info like the player ID (playerId), server ID (serverId), and banner ID (gachaId), all of which can be extracted from the URL parameters:
const url = new URL(sanitizedInput);
const params = new URLSearchParams(url.hash.substring(url.hash.indexOf("?") + 1));
const playerId = params.get("player_id") || "";
const gachaId = params.get("resources_id") || "";
const gachaType = params.get("gacha_type") || "";
const serverId = params.get("svr_id") || "";
const languageCode = params.get("lang") || "";
const recordId = params.get("record_id") || "";
The banner type parameter cardPoolType takes values in [1, 13], with the following mapping:
// 2026.06.09: Added type 10, 11 collaboration banners
// 2026.07.21: Added type 12, 13 memory journey banners
// Holy crap, why are there more and more banner types 😅
export const POOL_TYPES = [
{ type: 1, name: "角色活动唤取" },
{ type: 2, name: "武器活动唤取" },
{ type: 3, name: "角色常驻唤取" },
{ type: 4, name: "武器常驻唤取" },
{ type: 5, name: "新手唤取" },
{ type: 6, name: "新手自选唤取" },
{ type: 7, name: "感恩定向唤取" },
{ type: 8, name: "角色新旅唤取" },
{ type: 9, name: "武器新旅唤取" },
{ type: 10, name: "角色联动唤取" },
{ type: 11, name: "武器联动唤取" },
{ type: 12, name: "角色忆旅唤取" },
{ type: 13, name: "武器忆旅唤取" },
];
Just create a backend API that proxies requests to the official API to fetch the pull history for each banner.
Data Updates
In games like Wuthering Waves, you can do ten-pulls.

Ten-pull
As you can see, a ten-pull can yield two identical items — meaning two records with exactly the same attributes, including the pull timestamp. The JSON result looks like this:
{
"code": 0,
"message": "success",
"data": [
{
"cardPoolType": "角色精准调谐",
"resourceId": 21050043,
"qualityLevel": 3,
"resourceType": "武器",
"name": "远行者矩阵·探幽",
"count": 1,
"time": "2025-05-29 01:47:36"
},
...
{
"cardPoolType": "角色精准调谐",
"resourceId": 21050043,
"qualityLevel": 3,
"resourceType": "武器",
"name": "远行者矩阵·探幽",
"count": 1,
"time": "2025-05-29 01:47:36"
},
...
]
}
This makes it impossible to tell which records have already been imported when updating later, which messes up the accuracy of your stats. So we need to build an identifier that can distinguish two identical items even when they share the same timestamp.
We can construct this unique ID using timestamp + banner type ID + pull sequence number. The pull sequence number is a counter starting from 1, representing which pull it was within the same timestamp. Using this approach, the two identical weapons above would get unique IDs of 1748454456000100001 and 1748454456000100005 respectively.
func GenerateUniqueID(timestamp int, gachaType string, drawNumber int) (int64, error) {
gachaTypeID, err := strconv.Atoi(strings.TrimSpace(gachaType))
if err != nil {
return 0, err
}
return int64(timestamp)*1_000_000_000 +
int64(gachaTypeID%10_000)*100_000 +
int64(drawNumber%100), nil
}
This way, even if the imported data overlaps with existing records, new pulls can still be correctly identified and added to the database.
Probability Calculations
In the stats overview section, I used ECharts to render a pull probability line chart as a component background, showing the probability distribution of the gacha system. The chart data is calculated by two main functions:
Theoretical Probability Calculation
Based on a brief analysis of Wuthering Waves’ gacha system by Bilibili creator 一棵平衡树, the pull probability follows the model below, where is the number of pulls:
This is used to build a calculateTheoreticalProbability function:
export const calculateTheoreticalProbability = (): [number, number][] => {
const baseRate = 0.008; // Base rate 0.8%
const hardPity = 79; // Hard pity at 79 pulls
const data: [number, number][] = [];
const rateIncrease = [
...Array(65).fill(0), // No increase for pulls 1-65
...Array(5).fill(0.04), // +4% per pull for 66-70
...Array(5).fill(0.08), // +8% per pull for 71-75
...Array(3).fill(0.10) // +10% per pull for 76-78
];
let currentProbability = baseRate;
for (let i = 1; i <= hardPity; i++) {
if (i === hardPity) {
currentProbability = 1; // Guaranteed at 79
} else if (i > 65) {
currentProbability = i === 66
? baseRate + rateIncrease[i - 1]
: currentProbability + rateIncrease[i - 1];
}
data.push([i, currentProbability]);
}
return data;
};
Actual Probability Calculation
Since this tool is mainly for personal use, the pull data sample size is small, and some pity positions may have no data at all. Using a raw frequency estimate would make the probability curve swing wildly, producing extreme values like 0% or 100%.

Frequency estimate
To avoid this, we can apply Bayesian smoothing for a better result.
Where:
: number of 5-star items observed at pity position , : total number of pulls at pity position , : theoretical probability, : smoothing factor
Here’s how it behaves:
When is small (limited data), leans toward the theoretical probability .
When is large (ample data), leans toward the actual frequency .
The optimized result looks like this:

Bayesian smoothing
The implementation:
export const calculateActualProbability = (
gachaItems: GachaItem[] | undefined,
theoreticalProbabilityData: [number, number][],
): [number, number][] | null => {
const maxPity = theoreticalProbabilityData.length;
const totalSamples = pullCounts.reduce((sum, count) => sum + count, 0);
const equivalentSamples = Math.max(
10,
Math.min(120, 50 + (1000 - totalSamples) / 10),
);
const probabilityData: [number, number][] = new Array(maxPity);
for (let i = 0; i < maxPity; i++) {
const sampleCount = pullCounts[i];
const hitCount = rarityFiveCounts[i];
if (sampleCount === 0) {
probabilityData[i] = [i + 1, theoreticalProbabilityData[i][1]];
} else {
const priorProbability = theoreticalProbabilityData[i][1];
const priorSamples =
i >= maxPity * 0.8 ? equivalentSamples * 1.5 : equivalentSamples;
const smoothedProbability =
(hitCount + priorSamples * priorProbability) /
(sampleCount + priorSamples);
probabilityData[i] = [i + 1, smoothedProbability];
}
}
return probabilityData;
};
Automatic Deployment [Outdated — kept for reference only]
As mentioned earlier, Astrionyx is deployed on both Vercel and my own server. So every time I pushed changes to the repo, I had to pull and build on the server — a huge pain. The author of this theme wrote a workflow for automatically building and deploying the theme to a remote server. I tweaked it slightly and adapted it for Astrionyx.
However, I found that the network quality between GitHub and my server was terrible, making each transfer of the built package painfully slow (averaging 20+ minutes). Conveniently, Cloudflare’s R2 object storage offers 10 GB of free storage per month with no download fees, and the download quality within China is decent enough to act as a relay to solve this problem.
The main workflow incorporating object storage looks like this. Both upload and download steps include 5 retries (a single download can still fail due to network hiccups; in practice, 5 retries handle the vast majority of issues):
# .github/workflows/deploy.yml
name: Build and Deploy
on:
push:
branches:
- main
paths-ignore:
- '**.md'
- 'docs/**'
repository_dispatch:
types: [trigger-workflow]
permissions: write-all
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
PNPM_VERSION: 9.x.x
NODE_VERSION: 20.x
KEEP_DEPLOYMENTS: 1
RELEASE_FILE: release.zip
jobs:
build_and_deploy:
name: Build and Deploy
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 1
lfs: true
- name: Setup PNPM
uses: pnpm/action-setup@v3
with:
version: ${{ env.PNPM_VERSION }}
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project
run: |
if [ -f "./ci-release-build.sh" ]; then
sh ./ci-release-build.sh
else
echo "Build script not found, using default build command"
pnpm build
fi
- name: Create release archive
run: |
mkdir -p release_dir
if [ -f "./assets/release.zip" ]; then
mv assets/release.zip release_dir/${{ env.RELEASE_FILE }}
else
echo "assets/release.zip not found, check build script output"
exit 1
fi
- name: Install rclone
run: |
curl -O https://downloads.rclone.org/rclone-current-linux-amd64.zip
unzip rclone-current-linux-amd64.zip
cd rclone-*-linux-amd64
sudo cp rclone /usr/local/bin/
sudo chown root:root /usr/local/bin/rclone
sudo chmod 755 /usr/local/bin/rclone
rclone version
- name: Configure rclone
run: |
mkdir -p ~/.config/rclone
cat > ~/.config/rclone/rclone.conf << EOF
[r2]
type = s3
provider = Cloudflare
access_key_id = ${{ secrets.R2_ACCESS_KEY_ID }}
secret_access_key = ${{ secrets.R2_SECRET_ACCESS_KEY }}
endpoint = ${{ secrets.R2_ENDPOINT_URL }}
region = auto
acl = private
bucket_acl = private
no_check_bucket = true
force_path_style = true
EOF
- name: Upload to R2 storage
id: upload_to_r2
run: |
echo "Starting upload to R2"
max_retries=5
retry_count=0
upload_success=false
while [ $retry_count -lt $max_retries ] && [ "$upload_success" = "false" ]; do
echo "Upload attempt ${retry_count}/${max_retries} to R2 storage"
if rclone copy release_dir/${{ env.RELEASE_FILE }} r2:${{ secrets.R2_BUCKET }} --retries 3 --retries-sleep 10s --progress --s3-upload-cutoff=64M --s3-chunk-size=8M --s3-disable-checksum; then
upload_success=true
echo "Upload successful"
else
echo "Upload failed, preparing to retry"
retry_count=$((retry_count + 1))
if [ $retry_count -lt $max_retries ]; then
echo "Waiting 5 seconds before retry"
sleep 5
fi
fi
done
if [ "$upload_success" = "false" ]; then
echo "Max retries reached"
exit 1
fi
DOWNLOAD_URL="${{ secrets.R2_PUBLIC_URL }}/${{ env.RELEASE_FILE }}"
echo "DOWNLOAD_URL=$DOWNLOAD_URL" >> $GITHUB_ENV
echo "Download URL: $DOWNLOAD_URL"
- name: Download from R2 and deploy
uses: appleboy/ssh-action@v1.0.3
with:
command_timeout: 10m
host: ${{ secrets.HOST }}
username: ${{ secrets.USER }}
password: ${{ secrets.PASSWORD }}
key: ${{ secrets.KEY }}
port: ${{ secrets.PORT }}
script: |
set -e
source $HOME/.bashrc
mkdir -p /tmp/astrionyx
cd /tmp/astrionyx
rm -f release.zip
max_retries=5
retry_count=0
download_success=false
echo ${{ env.DOWNLOAD_URL }}
while [ $retry_count -lt $max_retries ] && [ "$download_success" = "false" ]; do
echo "Downloading (${retry_count}/${max_retries})"
if timeout 60s wget -q --show-progress --progress=bar:force:noscroll --no-check-certificate -O release.zip "${{ env.DOWNLOAD_URL }}"; then
if [ -s release.zip ]; then
download_success=true
else
echo "Downloaded file is empty, preparing to retry"
fi
else
echo "Download failed, preparing to retry"
fi
retry_count=$((retry_count + 1))
if [ $retry_count -lt $max_retries ] && [ "$download_success" = "false" ]; then
echo "Waiting 5 seconds before retry"
sleep 5
rm -f release.zip
fi
done
if [ "$download_success" = "false" ]; then
echo "Max retries reached"
exit 1
fi
basedir=$HOME/astrionyx
workdir=$basedir/${{ github.run_number }}
mkdir -p $workdir
mkdir -p $basedir/.cache
mv /tmp/astrionyx/release.zip $workdir/release.zip
cd $workdir
unzip -q $workdir/release.zip
rm -f $workdir/release.zip
rm -rf $workdir/standalone/.env
ln -s $HOME/astrionyx/.env $workdir/standalone/.env
export NEXT_SHARP_PATH=$(npm root -g)/sharp
cp $workdir/standalone/ecosystem.config.js $basedir/ecosystem.config.js
rm -f $basedir/server.mjs
ln -s $workdir/standalone/server.mjs $basedir/server.mjs
mkdir -p $workdir/standalone/.next
rm -rf $workdir/standalone/.next/cache
ln -sf $basedir/.cache $workdir/standalone/.next/cache
cd $basedir
export PORT=8523
pm2 reload server.mjs --update-env || pm2 start server.mjs --name astrionyx --interpreter node --interpreter-args="--enable-source-maps"
pm2 save
echo "${{ github.run_number }}" > $basedir/current_deployment
echo "Cleaning up old deployments, keeping ${{ env.KEEP_DEPLOYMENTS }} latest version(s)"
current_run=${{ github.run_number }}
ls -d $basedir/[0-9]* 2>/dev/null | grep -v "$basedir/$current_run" | sort -rn | awk -v keep=${{ env.KEEP_DEPLOYMENTS }} 'NR>keep' | while read dir; do
echo "Removing old version: $dir"
rm -rf "$dir"
done
rm -rf /tmp/astrionyx 2>/dev/null || true
echo "Deployment complete"
- name: Run post-deploy script
if: success()
run: |
if [ -n "${{ secrets.AFTER_DEPLOY_SCRIPT }}" ]; then
echo "Running post-deploy script"
${{ secrets.AFTER_DEPLOY_SCRIPT }}
fi
- name: Delete file from R2
if: always()
run: |
echo "Cleaning up temp file from R2 storage"
rclone delete r2:${{ secrets.R2_BUCKET }}/${{ env.RELEASE_FILE }}
# ci-release-build.sh
#!env bash
set -e
CWD=$(pwd)
npm run build
cd .next
pwd
rm -rf cache
cp -r ../public ./standalone/public
cd ./standalone
echo ';process.title = "Astrionyx"' >>server.js
mv server.js server.mjs
mv ../static/ ./.next/static
cp $CWD/ecosystem.standalone.config.cjs ./ecosystem.config.js
cd ..
mkdir -p $CWD/assets
rm -rf $CWD/assets/release.zip
zip --symlinks -r $CWD/assets/release.zip ./*
With this workflow, every push to the main branch automatically builds and deploys Astrionyx to the server. Using R2 object storage as a relay dramatically cuts deployment time (the whole workflow went from 20 minutes to under 4 minutes). Time well saved.

Optimized with R2 object storage
That’s all🎉.