banner
Vinking

Vinking

你写下的每一个BUG 都是人类反抗被人工智能统治的一颗子弹

Astrionyx - Mingchao Gacha Analysis Tool and Its Details

After getting out of the pit of Genshin Impact, Star Rail, and Zone Zero, I've recently become obsessed with Honkai: Star Rail || (Yes, it's the game where "xxx just needs xxx, while xxx has a lot to consider") ||. When it comes to these types of anime games, the dreaded gacha system is an unavoidable aspect; with gacha comes the possibility of getting characters or weapons, leading to the emergence of various third-party programs like "xxx Assistant" and "xxx Workshop," one of whose key functions is gacha analysis.

The issue arose when a friend wanted to see my gacha records for Star Rail, but upon opening the previously used program, I found that all the saved gacha records were gone. I tried downloading the Star Rail cloud game to re-import the gacha link to recover the data, but after a long struggle, the program kept indicating that the gacha address was incorrect, and I couldn't retrieve the historical records. I was troubled by this for a while, so I decided to create my own gacha record analysis tool to keep the data in my hands, which led to the creation of the following gacha analysis tool:

Astrionyx

Astrionyx is a web application based on Next.js that supports the analysis of different types of gacha records. It allows for manual data import or updates and can also import data via API.

It also supports deployment on Vercel and your own server, with support for both MySQL and Vercel Postgres databases. Currently, foreign traffic is routed to Vercel, using the Vercel Postgres database, while domestic traffic is routed to your own server, using your own MySQL database, which is very convenient.

During the development of Astrionyx, I encountered many interesting problems, and if you also want to write such an application, I hope this helps you.

Data Import and Update#

Data Import#

Like most games, Honkai: Star Rail does not provide an official API for exporting gacha data. The way it displays gacha history is: when the user clicks the "Gacha History" button, the game generates a temporary link and opens it through the in-game browser to present the gacha history content. We can capture this link starting with https://aki-gm-resources.aki-game.com/aki/gacha/index.html through packet capturing or from log files.

This page will use a POST request to the API https://gmserver-api.aki-game2.com/gacha/record/query to obtain the gacha history for each pool. This request includes key information such as player ID (playerId), server ID (serverId), and pool ID (cardPoolId), 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 cardPoolId = params.get('resources_id') || ''; 
const cardPoolType = params.get('gacha_type') || '';
const serverId = params.get('svr_id') || '';
const languageCode = params.get('lang') || '';
const recordId = params.get('record_id') || '';

Among them, the pool type parameter cardPoolType can take values of [1, 7], which are mapped as follows:

export const POOL_TYPES = [
  { type: 1, name: "Character Event Gacha" },
  { type: 2, name: "Weapon Event Gacha" },
  { type: 3, name: "Character Standard Gacha" },
  { type: 4, name: "Weapon Standard Gacha" },
  { type: 5, name: "Beginner Gacha" },
  { type: 6, name: "Beginner Select Gacha" },
  { type: 7, name: "Thanksgiving Targeted Gacha" },
];

Create a backend API that forwards to the official API to get the gacha history for each pool.

Data Update#

In games like Honkai: Star Rail, there can be "ten consecutive pulls."

Ten Consecutive Pulls

As shown in the image, "ten consecutive pulls" can result in obtaining two identical items, meaning two records have completely identical attributes, including the pull time. The result obtained via JSON looks like this:

{
    "code": 0,
    "message": "success",
    "data": [
        {
            "cardPoolType": "Character Precision Tuning",
            "resourceId": 21050043,
            "qualityLevel": 3,
            "resourceType": "Weapon",
            "name": "Traveler Matrix·Exploration",
            "count": 1,
            "time": "2025-05-29 01:47:36"
        },
        ...
        {
            "cardPoolType": "Character Precision Tuning",
            "resourceId": 21050043,
            "qualityLevel": 3,
            "resourceType": "Weapon",
            "name": "Traveler Matrix·Exploration",
            "count": 1,
            "time": "2025-05-29 01:47:36"
        },
        ...
    ]
}

This will cause us to be unable to determine which records have already been imported when updating gacha records later, thus affecting the accuracy of statistical data. Therefore, we need to construct a unique identifier that can distinguish two identical items even at the same time.

We can determine this unique identifier through Pool Type ID + Timestamp + Pull Number. The pull number indicates: starting from 1, the nth pull at the same timestamp. Thus, the two identical weapons above can be set with uniqueIds of 01174845445601 and 01174845445605.

function generateUniqueId(poolType: string | number, timestamp: number, drawNumber: number): string {
  const poolTypeStr = poolType.toString().padStart(2, '0');
  const drawNumberStr = drawNumber.toString().padStart(2, '0');
  return `${poolTypeStr}${timestamp}${drawNumberStr}`;
}

const uniqueId = generateUniqueId(timestampInSeconds, requestPoolType, drawNumber);

This way, even if the imported data overlaps with existing data, we can still correctly identify new gacha records and update the database.

Probability Data Calculation#

In the statistical overview of the page, an ECharts library is used to implement a gacha probability analysis line chart as a component background to display the probability distribution of the gacha system. The data for the line chart is calculated through two main functions:

Theoretical Probability Calculation#

According to a Bilibili UP master, a balanced tree, in the Analysis of the Honkai: Star Rail Gacha System, the gacha probability of Honkai: Star Rail conforms to the following model, where $i$ is the number of pulls:

P5[i]={0.008(i<66)0.008+0.04(i65)(66i<71)0.208+0.08(i70)(71i<76)0.608+0.1(i75)(76i<79)1(i=79)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}

Based on this, we construct a theoretical probability function calculateTheoreticalProbability:

export const calculateTheoreticalProbability = (): [number, number][] => {
  const baseRate = 0.008; // Base probability 0.8%
  const hardPity = 79; // Hard pity at 79 pulls
  const data: [number, number][] = [];
  const rateIncrease = [
    ...Array(65).fill(0), // Probability does not increase for pulls 1-65
    ...Array(5).fill(0.04), // For pulls 66-70, each increases by 4%
    ...Array(5).fill(0.08), // For pulls 71-75, each increases by 8%
    ...Array(3).fill(0.10) // For pulls 76-78, each increases by 10%
  ];
  
  let currentProbability = baseRate;
  for (let i = 1; i <= hardPity; i++) {
    if (i === hardPity) {
      currentProbability = 1; // The 79th pull guarantees a 5-star
    } 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 the tool is mainly for personal use, the sample size of gacha data is relatively small, and some pull positions may have no data at all. If we directly use frequency estimation, it can lead to extreme values like 0% or 100% in the probability curve.

Frequency Estimation

To avoid this situation, we can introduce Bayesian smoothing for better results.

Pposterior=ki+SPpriorni+SP_{\text{posterior}} = \frac{k_i + S \cdot P_{\text{prior}}}{n_i + S}

Where,

$k_i$: The number of 5-star items observed at the $i$-th pull position, $n_i$: The total number of pulls at the $i$-th position, $P_{\text{prior}}$: Theoretical probability, $S$: Smoothing factor (set to 20 here)

Its behavior is as follows:

If $n_i$ is small (i.e., when the data volume is small), $P_{\text{posterior}}$ will be closer to the theoretical probability $P_{\text{prior}}$.

If $n_i$ is large (i.e., when the data volume is large), $P_{\text{posterior}}$ will be closer to the actual frequency $\frac{k_i}{n_i}$.

The optimized effect is as follows:

Bayesian Smoothing Processing

The implementation code is as follows:

export const calculateActualProbability = (
  gachaItems: GachaItem[] | undefined,
  theoreticalProbabilityData: [number, number][]
): [number, number][] | null => {
  // Data processing logic
  
  const S = 20;
  const probabilityData: [number, number][] = new Array(79);
  
  for (let i = 0; i < 79; i++) {
    const n_i = pullCounts[i];
    const k_i = rarityFiveCounts[i];
    
    if (n_i === 0) {
      // If there is no data for this pull position, use theoretical probability
      probabilityData[i] = [i + 1, theoreticalProbabilityData[i][1]];
    } else {
      const prior_prob_i = theoreticalProbabilityData[i][1];
      const smoothed_probability = (k_i + S * prior_prob_i) / (n_i + S);
      probabilityData[i] = [i + 1, smoothed_probability];
    }
  }
  
  return probabilityData;
};

Automatic Deployment#

As mentioned earlier, Astrionyx is deployed on both Vercel and its own server, so every time the project is modified and pushed to the repository, it needs to pull and build on the server, which is very cumbersome. The author of this theme has written a workflow that automatically builds the theme and deploys it to a remote server, which I slightly modified for use in Astrionyx.

However, during use, I found that the network connection quality between GitHub and the server is very poor, causing the transfer of the built package to the server to take a lot of time (averaging 20 min+). Fortunately, Cloudflare's R2 object storage offers 10 GB of free storage per month and no download fees, and the download quality within the country is also quite good, making it a suitable "transit station" to solve this problem.

The main workflow of the object storage is as follows, with 5 retries added for both uploads and downloads (a single download may still fail due to network fluctuations, and 5 retries can solve most problems).

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: Set PNPM
        uses: pnpm/action-setup@v3
        with:
          version: ${{ env.PNPM_VERSION }}
          run_install: false

      - name: Set 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 does not exist, 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 does not exist, 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 "Uploading ${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 retrying"
                sleep 5
              fi
            fi
          done
          
          if [ "$upload_success" = "false" ]; then
            echo "Reached maximum retry count"
            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/[email protected]
        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 retrying"
                sleep 5
                rm -f release.zip
              fi
            done
            
            if [ "$download_success" = "false" ]; then
              echo "Reached maximum retry count"
              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

            # Remember to change to your available port 👇
            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 versions"
            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 "Deleting old version: $dir"
              rm -rf "$dir"
            done
            
            rm -rf /tmp/astrionyx 2>/dev/null || true
            echo "Deployment completed"

      - name: Run Post-Deployment Script
        if: success()
        run: |
          if [ -n "${{ secrets.AFTER_DEPLOY_SCRIPT }}" ]; then
            echo "Executing post-deployment script"
            ${{ secrets.AFTER_DEPLOY_SCRIPT }}
          fi

      - name: Delete Files in R2
        if: always()
        run: |
          echo "Cleaning up temporary files in R2 storage"
          rclone delete r2:${{ secrets.R2_BUCKET }}/${{ env.RELEASE_FILE }}

With this workflow, every time code is pushed to the main branch, Astrionyx will automatically build and deploy to the server. Using R2 object storage as a transit station can greatly shorten deployment time (the entire workflow time reduced from 20 min to under 4 min), saving time.

Optimized with R2 Object Storage

iOS Widget#

Astrionyx provides an API for integration with other applications, and on iOS, you can use the Scriptable app to develop a widget for display.

Optimized with R2 Object Storage

/**
 * Honkai: Star Rail Widget
 * 
 * Author: Vinking
 */

const config = {
    apiUrl: "https://example.com/path/to/your/api",
    userId: "uid",

    design: {
        typography: {
            small: {
                hero: { size: 18, weight: 'heavy' },
                title: { size: 12, weight: 'medium' },
                body: { size: 10, weight: 'regular' },
                caption: { size: 8, weight: 'medium' }
            },
            medium: {
                hero: { size: 24, weight: 'heavy' },
                title: { size: 14, weight: 'medium' },
                body: { size: 12, weight: 'regular' },
                caption: { size: 10, weight: 'medium' }
            },
            large: {
                hero: { size: 28, weight: 'heavy' },
                title: { size: 16, weight: 'medium' },
                body: { size: 14, weight: 'regular' },
                caption: { size: 11, weight: 'medium' }
            }
        },

        spacing: {
            small: { xs: 4, sm: 6, md: 8, lg: 12, xl: 16 },
            medium: { xs: 6, sm: 8, md: 12, lg: 16, xl: 20 },
            large: { xs: 8, sm: 10, md: 16, lg: 20, xl: 24 }
        },

        radius: {
            small: 12,
            medium: 16,
            large: 20,
            element: 8
        }
    },

    colors: {
        primary: "#7c3aed",
        primarySoft: "#8b5cf6",
        success: "#059669",
        warning: "#d97706",
        accent: "#a855f7",
        pitySmall: "#0891b2",
        
        text: {
            primary: "#0f172a",
            secondary: "#1e293b",
            tertiary: "#475569",
            muted: "#64748b",
            accent: "#4f46e5"
        },
        surface: {
            primary: "#f8fafc",
            secondary: "#f1f5f9",
            elevated: "#e2e8f0"
        },
        luck: {
            positive: "#eab308",
            negative: "#65a30d"
        }
    }
};

function getColor(colorName) {
    if (colorName && colorName.startsWith('#')) {
        return new Color(colorName);
    }

    if (typeof config.colors[colorName] === 'string') {
        return new Color(config.colors[colorName]);
    }

    if (colorName && colorName.includes('#')) {
        return new Color(colorName);
    }

    if (colorName && colorName.includes('.')) {
        const [category, subcategory] = colorName.split('.');
        if (category === 'text' || category === 'surface') {
            return new Color(config.colors[category][subcategory]);
        }
        if (category === 'luck') {
            return new Color(config.colors.luck[subcategory]);
        }
    }

    console.warn(`Unable to parse color: ${colorName}`);
    return new Color("#666666");
}

function getWidgetSize() {
    return config.widgetFamily || 'medium';
}

function getDesignTokens(size) {
    return {
        typography: config.design.typography[size],
        spacing: config.design.spacing[size],
        radius: config.design.radius[size] || config.design.radius.medium
    };
}

async function createWidget() {
    const widget = new ListWidget();
    const size = getWidgetSize();
    const tokens = getDesignTokens(size);

    setupWidgetStyle(widget, size, tokens);

    try {
        const data = await fetchGachaData();

        if (!data.success || !data.data?.length) {
            throw new Error("No data available");
        }

        // Find limited character pool data (poolType = "1")
        const characterPool = data.data.find(pool => pool.poolType === "1");
        
        if (!characterPool) {
            throw new Error("Limited character pool data not found");
        }

        switch (size) {
            case 'small':
                buildSmallLayout(widget, characterPool, tokens);
                break;
            case 'medium':
                buildMediumLayout(widget, characterPool, tokens);
                break;
            case 'large':
                buildLargeLayout(widget, characterPool, tokens);
                break;
        }

    } catch (error) {
        buildErrorLayout(widget, error.message, tokens);
    }

    return widget;
}

function setupWidgetStyle(widget, size, tokens) {
    widget.backgroundColor = getColor('surface.primary');
    widget.cornerRadius = tokens.radius;

    const padding = tokens.spacing.lg;
    widget.setPadding(padding, padding, padding, padding);
}

async function fetchGachaData() {
    const request = new Request(config.apiUrl);
    request.timeoutInterval = 15;
    return await request.loadJSON();
}

function buildSmallLayout(widget, pool, tokens) {
    const { totalCount = 0, highestRarityCount = 0, luckIndex = 0 } = pool;

    const header = createHeader(widget, tokens, true);
    widget.addSpacer(tokens.spacing.md);

    const statsContainer = widget.addStack();
    statsContainer.layoutHorizontally();
    statsContainer.spacing = tokens.spacing.sm;

    const totalCard = createStatCard(
        statsContainer,
        totalCount.toString(),
        "Total Pulls",
        getColor('primary'),
        tokens
    );

    const legendaryCard = createStatCard(
        statsContainer,
        highestRarityCount.toString(),
        "5-Star Count",
        getColor('accent'),
        tokens
    );

    widget.addSpacer(tokens.spacing.sm);

    createLuckIndicator(widget, luckIndex, tokens, 'compact');
}

function buildMediumLayout(widget, pool, tokens) {
    const {
        totalCount = 0,
        highestRarityCount = 0,
        averagePull = 0,
        isSmallPity,
        luckIndex = 0
    } = pool;

    createHeader(widget, tokens, false);
    widget.addSpacer(tokens.spacing.lg);

    const mainStats = widget.addStack();
    mainStats.layoutHorizontally();
    mainStats.spacing = tokens.spacing.md;

    createStatColumn(mainStats, totalCount.toString(), "Total Pulls", getColor('primary'), tokens);
    const divider1 = mainStats.addStack();
    divider1.backgroundColor = new Color(getColor('text.muted').hex, 0.1);
    divider1.cornerRadius = 1;
    divider1.size = new Size(1, tokens.typography.hero.size);

    createStatColumn(mainStats, highestRarityCount.toString(), "5-Star Count", getColor('accent'), tokens);

    const divider2 = mainStats.addStack();
    divider2.backgroundColor = new Color(getColor('text.muted').hex, 0.1);
    divider2.cornerRadius = 1;
    divider2.size = new Size(1, tokens.typography.hero.size);

    createStatColumn(mainStats, averagePull.toFixed(1), "Average", getColor('success'), tokens);

    widget.addSpacer(tokens.spacing.lg);

    const bottomRow = widget.addStack();
    bottomRow.layoutHorizontally();
    bottomRow.centerAlignContent();

    const pityIndicator = createPityStatus(bottomRow, isSmallPity, tokens);

    bottomRow.addSpacer();

    createLuckIndicator(bottomRow, luckIndex, tokens, 'inline');
}

function buildLargeLayout(widget, pool, tokens) {
    const {
        totalCount = 0,
        highestRarityCount = 0,
        averagePull = 0,
        isSmallPity,
        luckIndex = 0,
        lastPullTime
    } = pool;

    createDetailedHeader(widget, tokens);
    widget.addSpacer(tokens.spacing.xl);

    const statsGrid = widget.addStack();
    statsGrid.layoutHorizontally();
    statsGrid.spacing = tokens.spacing.lg;

    createStatColumn(statsGrid, totalCount.toString(), "Total Pulls", getColor('primary'), tokens);
    createStatColumn(statsGrid, highestRarityCount.toString(), "5-Star Count", getColor('accent'), tokens);
    createStatColumn(statsGrid, averagePull.toFixed(1), "Average", getColor('success'), tokens);

    widget.addSpacer(tokens.spacing.xl);

    createDivider(widget);
    widget.addSpacer(tokens.spacing.lg);

    createDetailedPityStatus(widget, isSmallPity, tokens);
    widget.addSpacer(tokens.spacing.md);

    createLuckIndicator(widget, luckIndex, tokens, 'detailed');

    widget.addSpacer(tokens.spacing.lg);

    createFooter(widget, tokens);
}

function createHeader(widget, tokens, compact = false) {
    const header = widget.addStack();
    header.layoutHorizontally();
    header.centerAlignContent();

    const iconSymbol = SFSymbol.named("person.fill.viewfinder");
    iconSymbol.applyFont(Font.systemFont(tokens.typography.title.size + 2));
    const icon = header.addImage(iconSymbol.image);
    icon.imageSize = new Size(tokens.typography.title.size + 2, tokens.typography.title.size + 2);
    icon.tintColor = getColor('primary');
    header.addSpacer(tokens.spacing.xs);

    const title = header.addText("Limited Characters");
    title.font = Font.boldSystemFont(tokens.typography.title.size);
    title.textColor = getColor('text.accent');
    title.lineLimit = 1;

    if (!compact) {
        header.addSpacer();

        const uid = header.addText(`UID: ${config.userId}`);
        uid.font = Font.mediumSystemFont(tokens.typography.caption.size);
        uid.textColor = getColor('text.muted');
    }

    return header;
}

function createDetailedHeader(widget, tokens) {
    const header = createHeader(widget, tokens, false);

    widget.addSpacer(tokens.spacing.sm);

    const subtitleStack = widget.addStack();
    subtitleStack.layoutHorizontally();

    const subtitleIcon = SFSymbol.named("square.grid.3x3.bottomright.filled");
    subtitleIcon.applyFont(Font.systemFont(tokens.typography.body.size));
    const icon = subtitleStack.addImage(subtitleIcon.image);
    icon.imageSize = new Size(tokens.typography.body.size, tokens.typography.body.size);
    icon.tintColor = getColor('primarySoft');

    subtitleStack.addSpacer(tokens.spacing.xs);

    const subtitle = subtitleStack.addText("Overview of Limited Character Pool Data");
    subtitle.font = Font.systemFont(tokens.typography.body.size);
    subtitle.textColor = getColor('text.secondary');
}

function createStatCard(container, value, label, color, tokens) {
    const card = container.addStack();
    card.layoutVertically();
    card.centerAlignContent();

    const bgColor = getColor('surface.elevated');
    const borderColor = new Color(color.hex, 0.2);

    card.backgroundColor = new Color(bgColor.hex, 0.3);
    card.borderColor = borderColor;
    card.borderWidth = 1;

    card.cornerRadius = config.design.radius.element;
    card.setPadding(tokens.spacing.sm, tokens.spacing.md, tokens.spacing.sm, tokens.spacing.md);

    const valueText = card.addText(value);
    valueText.font = Font.heavySystemFont(tokens.typography.hero.size);
    valueText.textColor = color;
    valueText.centerAlignText();
    valueText.shadowColor = new Color(color.hex, 0.3);
    valueText.shadowOffset = new Point(0, 1);
    valueText.shadowRadius = 2;

    card.addSpacer(2);

    const labelText = card.addText(label);
    labelText.font = Font.mediumSystemFont(tokens.typography.caption.size);
    labelText.textColor = getColor('text.tertiary');
    labelText.centerAlignText();

    return card;
}

function createStatColumn(container, value, label, color, tokens) {
    const column = container.addStack();
    column.layoutVertically();
    column.centerAlignContent();

    const valueText = column.addText(value);
    valueText.font = Font.heavySystemFont(tokens.typography.hero.size);
    valueText.textColor = color;
    valueText.centerAlignText();
    valueText.shadowColor = new Color(color.hex, 0.3);
    valueText.shadowOffset = new Point(0, 1);
    valueText.shadowRadius = 1;

    column.addSpacer(tokens.spacing.xs);

    const labelText = column.addText(label);
    labelText.font = Font.systemFont(tokens.typography.body.size);
    labelText.textColor = getColor('text.secondary');
    labelText.centerAlignText();

    return column;
}

function createPityStatus(container, isSmallPity, tokens) {
    const status = container.addStack();
    status.layoutHorizontally();
    status.centerAlignContent();

    const dotColor = isSmallPity
        ? new Color(config.colors.pitySmall)
        : getColor('success');

    const bgColor = new Color(dotColor.hex, 0.15);

    status.backgroundColor = bgColor;
    status.cornerRadius = tokens.spacing.sm;
    status.setPadding(tokens.spacing.xs, tokens.spacing.md, tokens.spacing.xs, tokens.spacing.md);

    const iconName = isSmallPity ? "diamond.bottomhalf.filled" : "diamond.fill";
    const iconSymbol = SFSymbol.named(iconName);
    iconSymbol.applyFont(Font.systemFont(tokens.typography.caption.size));
    const icon = status.addImage(iconSymbol.image);
    icon.imageSize = new Size(tokens.typography.caption.size, tokens.typography.caption.size);
    icon.tintColor = dotColor;

    status.addSpacer(tokens.spacing.xs);

    const text = status.addText(isSmallPity ? "Small Pity" : "Big Pity");
    text.font = Font.boldSystemFont(tokens.typography.body.size);
    text.textColor = dotColor;

    return status;
}

function createDetailedPityStatus(widget, isSmallPity, tokens) {
    const row = widget.addStack();
    row.layoutHorizontally();
    row.centerAlignContent();

    const label = row.addText("Pity Status");
    label.font = Font.systemFont(tokens.typography.body.size);
    label.textColor = getColor('text.secondary');

    row.addSpacer();

    createPityStatus(row, isSmallPity, tokens);
}

function createLuckIndicator(container, luckIndex, tokens, style = 'inline') {
    const luckColor = getLuckColor(luckIndex);

    if (style === 'compact') {
        const luck = container.addStack();
        luck.layoutHorizontally();
        luck.centerAlignContent();

        const iconSymbol = SFSymbol.named("sparkles");
        iconSymbol.applyFont(Font.systemFont(tokens.typography.body.size));
        const icon = luck.addImage(iconSymbol.image);
        icon.imageSize = new Size(tokens.typography.body.size, tokens.typography.body.size);
        icon.tintColor = luckColor;

        luck.addSpacer(tokens.spacing.xs);

        const value = luck.addText(`${luckIndex > 0 ? '+' : ''}${luckIndex}%`);
        value.font = Font.heavySystemFont(tokens.typography.title.size);
        value.textColor = luckColor;
        value.shadowColor = new Color(luckColor.hex, 0.3);
        value.shadowOffset = new Point(0, 1);
        value.shadowRadius = 1;

    } else if (style === 'inline') {
        const luck = container.addStack();
        luck.layoutHorizontally();
        luck.centerAlignContent();

        const label = luck.addText("Luck");
        label.font = Font.systemFont(tokens.typography.body.size);
        label.textColor = getColor('text.secondary');

        luck.addSpacer(tokens.spacing.xs);

        const value = luck.addText(`${luckIndex > 0 ? '+' : ''}${luckIndex}%`);
        value.font = Font.heavySystemFont(tokens.typography.title.size);
        value.textColor = luckColor;
        value.shadowColor = new Color(luckColor.hex, 0.3);
        value.shadowOffset = new Point(0, 1);
        value.shadowRadius = 1;

    } else if (style === 'detailed') {
        const row = container.addStack();
        row.layoutHorizontally();
        row.centerAlignContent();

        const iconSymbol = SFSymbol.named("sparkles.square.filled.on.square");
        iconSymbol.applyFont(Font.systemFont(tokens.typography.body.size));
        const icon = row.addImage(iconSymbol.image);
        icon.imageSize = new Size(tokens.typography.body.size, tokens.typography.body.size);
        icon.tintColor = luckColor;

        row.addSpacer(tokens.spacing.xs);

        const label = row.addText("Luck Index");
        label.font = Font.systemFont(tokens.typography.body.size);
        label.textColor = getColor('text.secondary');

        row.addSpacer();

        const value = row.addText(`${luckIndex > 0 ? '+' : ''}${luckIndex}%`);
        value.font = Font.heavySystemFont(tokens.typography.title.size);
        value.textColor = luckColor;
        value.shadowColor = new Color(luckColor.hex, 0.3);
        value.shadowOffset = new Point(0, 1);
        value.shadowRadius = 1;
    }
}

function getLuckColor(luckIndex) {
    return luckIndex >= 0
        ? getColor('luck.positive')
        : getColor('luck.negative');
}

function createDivider(widget) {
    const divider = widget.addStack();
    const gradient = new LinearGradient();
    gradient.colors = [
        new Color(getColor('text.muted').hex, 0.05),
        new Color(getColor('text.muted').hex, 0.2),
        new Color(getColor('text.muted').hex, 0.05)
    ];
    gradient.locations = [0, 0.5, 1];
    divider.backgroundGradient = gradient;
    divider.cornerRadius = 1;
    divider.size = new Size(0, 1);
}

function createFooter(widget, tokens) {
    const footerStack = widget.addStack();
    footerStack.layoutHorizontally();
    footerStack.centerAlignContent();

    const iconSymbol = SFSymbol.named("wave.3.right");
    iconSymbol.applyFont(Font.systemFont(tokens.typography.caption.size));
    const icon = footerStack.addImage(iconSymbol.image);
    icon.imageSize = new Size(tokens.typography.caption.size, tokens.typography.caption.size);
    icon.tintColor = getColor('text.muted');

    footerStack.addSpacer(tokens.spacing.xs);

    const footer = footerStack.addText("Powered by Astrionyx");
    footer.font = Font.systemFont(tokens.typography.caption.size);
    footer.textColor = getColor('text.muted');
    footer.alpha = 0.8;
}

function buildErrorLayout(widget, message, tokens) {
    const container = widget.addStack();
    container.layoutVertically();
    container.centerAlignContent();

    const iconSymbol = SFSymbol.named("xmark.octagon.fill");
    iconSymbol.applyFont(Font.systemFont(tokens.typography.hero.size));
    const icon = container.addImage(iconSymbol.image);
    icon.imageSize = new Size(tokens.typography.hero.size, tokens.typography.hero.size);
    icon.tintColor = getColor('warning');

    container.addSpacer(tokens.spacing.sm);

    const errorTitle = container.addText("Data Retrieval Failed");
    errorTitle.font = Font.boldSystemFont(tokens.typography.title.size);
    errorTitle.textColor = getColor('text.primary');
    errorTitle.centerAlignText();

    container.addSpacer(tokens.spacing.xs);

    const errorStack = container.addStack();
    errorStack.backgroundColor = new Color(getColor('warning').hex, 0.1);
    errorStack.cornerRadius = 8;
    errorStack.setPadding(tokens.spacing.sm, tokens.spacing.md, tokens.spacing.sm, tokens.spacing.md);

    const text = errorStack.addText(message);
    text.font = Font.mediumSystemFont(tokens.typography.body.size);
    text.textColor = getColor('warning');
    text.centerAlignText();
    text.lineLimit = 2;

    container.addSpacer(tokens.spacing.md);

    const refreshStack = container.addStack();
    refreshStack.layoutHorizontally();
    refreshStack.centerAlignContent();

    const refreshIcon = SFSymbol.named("arrow.clockwise");
    refreshIcon.applyFont(Font.systemFont(tokens.typography.caption.size));
    const refreshIconImage = refreshStack.addImage(refreshIcon.image);
    refreshIconImage.imageSize = new Size(tokens.typography.caption.size, tokens.typography.caption.size);
    refreshIconImage.tintColor = getColor('text.tertiary');

    refreshStack.addSpacer(tokens.spacing.xs);

    const refreshHint = refreshStack.addText("Please refresh and try again later");
    refreshHint.font = Font.systemFont(tokens.typography.caption.size);
    refreshHint.textColor = getColor('text.tertiary');
}

async function main() {
    const widget = await createWidget();

    if (config.runsInWidget) {
        Script.setWidget(widget);
    } else {
        const size = config.previewSize || "medium";
        switch (size) {
            case "small":
                widget.presentSmall();
                break;
            case "large":
                widget.presentLarge();
                break;
            default:
                widget.presentMedium();
        }
    }

    Script.complete();
}

await main();

That's all🎉.

This article was synchronized to xLog by Mix Space
The original link is https://www.vinking.top/posts/codes/astrionyx-wuwa-gacha-analysis


Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.