稍候片刻,月出文自明。
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") || "";// 2026.06.09: 添加 10,11 号联动唤取卡池
// 2026.07.21: 添加 12,13 号忆旅唤取卡池
// 牛魔的怎么越来越多卡池了😅
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: "武器忆旅唤取" },
];{
"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"
},
...
]
}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
}export const calculateTheoreticalProbability = (): [number, number][] => {
const baseRate = 0.008; // 基础概率0.8%
const hardPity = 79; // 保底79抽
const data: [number, number][] = [];
const rateIncrease = [
...Array(65).fill(0), // 1-65抽概率不增加
...Array(5).fill(0.04), // 66-70抽每次+4%
...Array(5).fill(0.08), // 71-75抽每次+8%
...Array(3).fill(0.10) // 76-78抽每次+10%
];
let currentProbability = baseRate;
for (let i = 1; i <= hardPity; i++) {
if (i === hardPity) {
currentProbability = 1; // 第79抽必出
} else if (i > 65) {
currentProbability = i === 66
? baseRate + rateIncrease[i - 1]
: currentProbability + rateIncrease[i - 1];
}
data.push([i, currentProbability]);
}
return data;
};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;
};# .github/workflows/deploy.yml
name: 构建与部署
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: 构建与部署
runs-on: ubuntu-latest
steps:
- name: 检出代码
uses: actions/checkout@v4
with:
fetch-depth: 1
lfs: true
- name: 设置PNPM
uses: pnpm/action-setup@v3
with:
version: ${{ env.PNPM_VERSION }}
run_install: false
- name: 设置Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: 安装依赖
run: pnpm install --frozen-lockfile
- name: 构建项目
run: |
if [ -f "./ci-release-build.sh" ]; then
sh ./ci-release-build.sh
else
echo "构建脚本不存在,使用默认构建命令"
pnpm build
fi
- name: 创建发布归档
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 不存在,检查构建脚本输出"
exit 1
fi
- name: 安装 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: 配置 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: 上传到 R2 存储
id: upload_to_r2
run: |
echo "开始上传到 R2"
max_retries=5
retry_count=0
upload_success=false
while [ $retry_count -lt $max_retries ] && [ "$upload_success" = "false" ]; do
echo "上传 ${retry_count}/${max_retries} 到 R2 存储"
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 "上传成功"
else
echo "上传失败,准备重试"
retry_count=$((retry_count + 1))
if [ $retry_count -lt $max_retries ]; then
echo "等待 5 秒后重试"
sleep 5
fi
fi
done
if [ "$upload_success" = "false" ]; then
echo "已达到最大重试次数"
exit 1
fi
DOWNLOAD_URL="${{ secrets.R2_PUBLIC_URL }}/${{ env.RELEASE_FILE }}"
echo "DOWNLOAD_URL=$DOWNLOAD_URL" >> $GITHUB_ENV
echo "下载URL: $DOWNLOAD_URL"
- name: 从 R2 下载并部署
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 "下载 (${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 "下载的文件为空,准备重试"
fi
else
echo "下载失败,准备重试"
fi
retry_count=$((retry_count + 1))
if [ $retry_count -lt $max_retries ] && [ "$download_success" = "false" ]; then
echo "等待5秒后重试"
sleep 5
rm -f release.zip
fi
done
if [ "$download_success" = "false" ]; then
echo "已达到最大重试次数"
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 "清理旧部署,保留 ${{ env.KEEP_DEPLOYMENTS }} 个最新版本"
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 "删除旧版本: $dir"
rm -rf "$dir"
done
rm -rf /tmp/astrionyx 2>/dev/null || true
echo "部署完成"
- name: 运行部署后脚本
if: success()
run: |
if [ -n "${{ secrets.AFTER_DEPLOY_SCRIPT }}" ]; then
echo "执行部署后脚本"
${{ secrets.AFTER_DEPLOY_SCRIPT }}
fi
- name: 删除 R2 中的文件
if: always()
run: |
echo "清理 R2 存储中的临时文件"
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 ./*
继脱坑了原神、星穹铁道以及绝区零后,最近沉迷上了鸣潮 (没错就是 「xxx 只要 xxx 就好了,而 xxx 要考虑的事情就很多了」的那个游戏)。聊到这类二次元游戏,万恶的抽卡便是不得不品的一环;有抽卡就必定会有角色武器的歪与不歪,从而催生出了一堆例如「xxx 小助手」、「xxx 工坊」之类的第三方小程序,它们的其中一个重要的功能便是抽卡分析。
事情的起因是,某位朋友想看看我的星穹铁道抽卡记录,但是打开之前使用的小程序发现以前存进去的抽卡记录全都不见了。尝试下载星铁云游戏重新导入抽卡链接恢复数据,但是折腾了半天,小程序始终提示抽卡地址错误,最终没能找回历史记录。后来一直对这件事情耿耿于怀,便想自己做一个抽卡记录分析的工具,将数据保存在自己手上,于是便写了这个抽卡分析工具。
Astrionyx 是一个基于 Next.js 的 Web 应用,Astrionyx 目前是一个前端 Next.js 后端 Go 项目,支持分析不同类型的卡池记录,可以手动导入或者更新数据,也可以通过 API 导入数据。除了鸣潮,现在还顺带支持了原神、星穹铁道、绝区零、明日方舟:终末地、异环等游戏。项目前端可以直接丢到 Vercel / EdgeOne Pages上托管,后端则是用 Docker 部署到自己服务器,部署起来非常非常的方便。
在开发 Astrionyx 的过程中,碰到了不少有意思的问题,如果你也想自己写一个这样的应用,希望对你有所帮助。
数据的导入与更新
数据导入
和绝大多数游戏一样,鸣潮并未提供官方 API 用于导出抽卡数据。其抽卡历史的展示方式是:当用户点击“抽卡历史”按钮时,游戏会生成一个临时链接,并通过游戏内的浏览器打开该链接,以此来呈现抽卡历史内容。我们可以通过抓包或者从日志文件中获取到这个以 aki-gm-resources.aki-game.com/aki/gacha/index.html 开头的链接。
该页面会通过 POST 的方式向 API gmserver-api.aki-game2.com/gacha/record/query 获取每个卡池的抽取历史。这个请求中包含了玩家 ID(playerId)、服务器 ID(serverId)、卡池 ID(gachaId)等关键信息,这些都可以从 URL 中的参数提取:
其中,卡池类型参数 cardPoolType 取值为 [1, 13],具体映射如下:
创建一个后端 API ,转发到官方 API 获取每个卡池的抽卡历史即可。
数据更新
在鸣潮等游戏中,抽卡会存在「十连抽」的情况。
由图可以看到,「十连抽」会出现获取到两件相同物品的情况,即两个记录具有包括抽取时间(十连抽中,所有物品都是在同一个时间点生成)在内的完全相同的属性,通过 Json 获取到的结果就像这样:
这会导致我们在后续更新抽卡记录的时候无法确定哪些记录已经导入过,从而影响统计数据的准确性。所以我们需要构建一个即使在相同时间下依然能区分两件相同物品的编号。
我们可以通过 时间戳 + 卡池类型 ID + 抽卡序号 来确定这个唯一编号。其中,抽卡序号表示:从 1 开始计数,在相同时间戳下的第几次抽卡。由此,可以将上面的两个相同的武器设置 uniqueId,分别为 1748454456000100001 和 1748454456000100005。
这样即使导入的数据与原有的数据存在重合等情况,依然可以正常识别新增的抽卡记录并更新数据库。
概率数据计算
页面的统计概览中,使用 ECharts 库实现了抽卡概率分析折线图作为组件背景,用来展示抽卡系统的概率分布。折线图的数据通过两个主要函数计算:
理论概率计算
根据 B 站 UP 主一棵平衡树撰写的 鸣潮抽卡系统简析,鸣潮抽卡概率符合下面模型,其中
i为抽卡次数:P_5[i] = \begin{cases} 0.008 & (i < 66) \\ 0.008 + 0.04 \cdot (i - 65) & (66 \leq i < 71) \\ 0.208 + 0.08 \cdot (i - 70) & (71 \leq i < 76) \\ 0.608 + 0.1 \cdot (i - 75) & (76 \leq i < 79) \\ 1 & (i = 79) \end{cases}以此构建一个理论概率函数 calculateTheoreticalProbability:
实际概率计算
由于工具主要是个人使用,抽卡数据样本量较小,某些抽数位置可能没有任何数据。如果直接使用频率估计,会导致概率曲线波动极大,出现 0% 或者 100% 这样的极端值。
为了避免出现这种情况,我们可以引入 贝叶斯平滑 来实现更好的效果。
P_{\text{posterior}} = \frac{k_i + S \cdot P_{\text{prior}}}{n_i + S}其中,
k_i:在第i抽位置观察到的 5 星数量、n_i:在第i抽位置的总抽数、P_{\text{prior}}:理论概率、S:平滑因子其行为如下:
如果
n_i较小(即数据量较小时),P_{\text{posterior}}会更趋近于理论概率P_{\text{prior}}如果
n_i较大(即数据量较大时),P_{\text{posterior}}会更趋近于实际频率\frac{k_i}{n_i}优化后的效果如下:
实现代码如下:
自动部署
目前该项目通过工作流+阿里云容器镜像服务来实现自动部署
以下自动部署方式已过时仅保留用作记录:
上文提到过,Astrionyx 同时部署在 vercel 以及自己的服务器上,所以每次修改完项目 push 到仓库的时候,都需要在服务器上 pull 以及 build,非常麻烦。这个主题的作者写过一个将主题自动构建并且部署到远程服务器的工作流,将其稍作修改后用到了 Astrionyx 上。
但是使用过程中发现,GitHub 到服务器之间的网络连接质量非常差,导致每次将构建好的包传输到服务器都需要花费大量时间(平均 20 min+)。恰好的是,Cloudflare 家的 R2 对象存储有每月 10 GB的免费存储额度以及免下载费用,境内的下载质量也算不错,可以作为「中转站」来解决这个问题。
结合对象存储的主要工作流如下,给上传和下载都加上了 5 次重试(单次下载仍然可能因为网络波动导致下载失败,实测 5 次重试能解决绝大部分问题)
通过这套工作流,每次推送代码到主分支时,Astrionyx 都会自动构建并部署到服务器。而通过 R2 对象存储作为中转站,能极大缩短部署时间(整套工作流从 20min 降低到 4 min 内),节省生命。
That's all🎉.