diff --git a/README.md b/README.md
index 221b768..2859e5e 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@ Atlas via long polling and an in-process cron scheduler.
|---|---|
| `util` | `/help`, `/info` (admin), `/stickerid` (owner) |
| `misc` | `/ping`, `/ping_stats` (admin), `/ff` (admin), `/xlt1`, `/giaxang` (Petrolimex retail fuel prices), `/the_answer` (owner), `/trongtruonghop` + `/tth`, `/trongtruonghopvng` + `/tthvng` disclaimers |
-| `random` | Pick one comma-separated option at random: `/random` (text), `/wheelofnames` (wheel GIF), `/gacha` (card-pack wish MP4; options are 5★ by default, prefix `4*` or `3*`). The animations use the optional wheelofnames renderer |
+| `random` | Pick one comma-separated option at random: `/random` (text), `/wheelofnames` (wheel GIF), `/gacha` (card-pack wish MP4; options are 5★ by default, prefix `4*` or `3*`), `/genshin` (unlisted; the same wish as a Genshin-style meteor). The animations use the optional wheelofnames renderer |
| `amlich` | Vietnamese lunar calendar: `/amlich` (dương lịch → âm lịch, defaults to today), `/duonglich` (âm lịch → dương lịch, `nhuan` flag for leap months); dates accept `d`, `d/m`, or `d/m/yyyy` — missing parts fill from today in the input's calendar. Years 1800–2199 only |
| `wordle` | Daily Wordle game: `/wordle [word]`, `/wordle_new`, `/wordle_giveup`, `/wordle_stats` |
| `loldle` | League-of-Legends "guess the champion": `/loldle [champion]`, `/loldle_giveup`, `/loldle_stats`, `/loldle_setmax` (owner) |
diff --git a/docs/deploy-coolify-selfhosted.md b/docs/deploy-coolify-selfhosted.md
index 45af681..6f4337e 100644
--- a/docs/deploy-coolify-selfhosted.md
+++ b/docs/deploy-coolify-selfhosted.md
@@ -93,6 +93,10 @@ option is equally likely, as with `/random`; the rarity only sets what the
animation and reply show. Options are 5★ by default; prefix `4*` or `3*` to
lower one, e.g. `/gacha Pizza, 4* Pho, 3* Rice`.
+The unlisted `/genshin` takes the same input and renders the Genshin-style
+meteor wish from `.../api/genshin` on the same service as a 7-second `640x360`
+MP4, with the same text fallback.
+
## 1. MongoDB Atlas (M0)
1. Create a free **M0** cluster (512 MB — ample for the tiny paper-trading KV).
diff --git a/internal/modules/random/gacha_api_client.go b/internal/modules/random/gacha_api_client.go
index b20ef01..a7d5314 100644
--- a/internal/modules/random/gacha_api_client.go
+++ b/internal/modules/random/gacha_api_client.go
@@ -10,12 +10,25 @@ import (
)
const (
- gachaRemoteFPS = 24
- // gachaRemoteWidth is the requested long edge; the wish is portrait, so
- // the video is gachaVideoWidth x gachaRemoteWidth.
- gachaRemoteWidth = 640
- gachaVideoWidth = 360
- gachaDuration = 6
+ gachaRemoteFPS = 24
+ gachaRemoteWidth = 640
+ gachaRemoteHeight = 360
+)
+
+// gachaStyle is one wish animation the renderer serves: its path next to
+// /api/gif, the clip length in seconds, and the frame size it renders for a
+// gachaRemoteWidth request.
+type gachaStyle struct {
+ Path string
+ Duration int
+ Width int
+ Height int
+}
+
+var (
+ // The card-pack wish is portrait: the requested width is its long edge.
+ gachaStyleCardPack = gachaStyle{Path: "gacha", Duration: 6, Width: gachaRemoteHeight, Height: gachaRemoteWidth}
+ gachaStyleGenshin = gachaStyle{Path: "genshin", Duration: 7, Width: gachaRemoteWidth, Height: gachaRemoteHeight}
)
type gachaAPIRequest struct {
@@ -25,10 +38,11 @@ type gachaAPIRequest struct {
Width int `json:"width"`
}
-// gachaAPIEndpoint derives the wish renderer from the configured wheel
-// endpoint: the same service serves /api/gacha next to /api/gif, so the last
-// path segment is swapped and no second URL needs configuring.
-func gachaAPIEndpoint(rawURL string) (*url.URL, error) {
+// gachaAPIEndpoint derives a wish renderer from the configured wheel
+// endpoint: the same service serves /api/gacha and /api/genshin next to
+// /api/gif, so the last path segment is swapped and no second URL needs
+// configuring.
+func gachaAPIEndpoint(rawURL, path string) (*url.URL, error) {
endpoint, err := wheelAPIEndpoint(rawURL)
if err != nil {
return nil, err
@@ -36,12 +50,13 @@ func gachaAPIEndpoint(rawURL string) (*url.URL, error) {
base := *endpoint
base.Path = strings.TrimSuffix(base.Path, "/")
base.RawPath = ""
- return base.ResolveReference(&url.URL{Path: "gacha"}), nil
+ return base.ResolveReference(&url.URL{Path: path}), nil
}
-// RenderGacha returns the MP4 wish animation revealing label at rarity stars.
-func (c wheelAPIClient) RenderGacha(ctx context.Context, label string, rarity int) ([]byte, error) {
- endpoint, err := gachaAPIEndpoint(c.URL)
+// RenderGacha returns the MP4 wish animation at path revealing label at
+// rarity stars.
+func (c wheelAPIClient) RenderGacha(ctx context.Context, path, label string, rarity int) ([]byte, error) {
+ endpoint, err := gachaAPIEndpoint(c.URL, path)
if err != nil {
return nil, err
}
@@ -68,15 +83,15 @@ func isMP4(data []byte) bool {
return len(data) >= 8 && bytes.Equal(data[4:8], []byte("ftyp"))
}
-func renderGachaAnimation(ctx context.Context, label string, rarity int) (wheelAnimation, error) {
- data, err := newWheelAPIClientFromEnv().RenderGacha(ctx, label, rarity)
+func renderGachaAnimation(ctx context.Context, style gachaStyle, label string, rarity int) (wheelAnimation, error) {
+ data, err := newWheelAPIClientFromEnv().RenderGacha(ctx, style.Path, label, rarity)
if err != nil {
return wheelAnimation{}, err
}
return wheelAnimation{
Data: data,
- Duration: gachaDuration,
- Width: gachaVideoWidth,
- Height: gachaRemoteWidth,
+ Duration: style.Duration,
+ Width: style.Width,
+ Height: style.Height,
}, nil
}
diff --git a/internal/modules/random/gacha_api_client_test.go b/internal/modules/random/gacha_api_client_test.go
index 7114c18..90a3f40 100644
--- a/internal/modules/random/gacha_api_client_test.go
+++ b/internal/modules/random/gacha_api_client_test.go
@@ -13,7 +13,7 @@ func TestGachaAPIEndpoint_SwapsLastPathSegment(t *testing.T) {
"https://render.example/api/gif/": "https://render.example/api/gacha",
"http://10.0.0.5:3000/api/gif": "http://10.0.0.5:3000/api/gacha",
} {
- got, err := gachaAPIEndpoint(raw)
+ got, err := gachaAPIEndpoint(raw, "gacha")
if err != nil {
t.Fatalf("gachaAPIEndpoint(%q): %v", raw, err)
}
@@ -21,7 +21,7 @@ func TestGachaAPIEndpoint_SwapsLastPathSegment(t *testing.T) {
t.Errorf("gachaAPIEndpoint(%q) = %q, want %q", raw, got, want)
}
}
- if _, err := gachaAPIEndpoint(""); err != errWheelAPINotConfigured {
+ if _, err := gachaAPIEndpoint("", "gacha"); err != errWheelAPINotConfigured {
t.Fatalf("empty url err = %v, want errWheelAPINotConfigured", err)
}
}
@@ -38,7 +38,7 @@ func TestWheelAPIClient_RenderGachaRejectsInvalidInput(t *testing.T) {
{name: "rarity too high", label: "a", rarity: 6},
} {
t.Run(tc.name, func(t *testing.T) {
- if _, err := client.RenderGacha(context.Background(), tc.label, tc.rarity); err == nil {
+ if _, err := client.RenderGacha(context.Background(), "gacha", tc.label, tc.rarity); err == nil {
t.Fatal("RenderGacha returned nil error")
}
})
@@ -66,7 +66,7 @@ func TestWheelAPIClient_RenderGachaReturnsErrorsForBadResponses(t *testing.T) {
defer server.Close()
client := wheelAPIClient{HTTP: server.Client(), URL: server.URL + "/api/gif"}
- if _, err := client.RenderGacha(context.Background(), "a", 3); err == nil {
+ if _, err := client.RenderGacha(context.Background(), "gacha", "a", 3); err == nil {
t.Fatal("RenderGacha returned nil error")
}
})
diff --git a/internal/modules/random/gacha_command.go b/internal/modules/random/gacha_command.go
index 7f30ff9..8577060 100644
--- a/internal/modules/random/gacha_command.go
+++ b/internal/modules/random/gacha_command.go
@@ -18,6 +18,7 @@ import (
const (
gachaUsage = "Usage: /gacha