The wise words of ThePrimeagen:
I have this dumb idea in my head and I will make this thing
[work].
So... I built this full-stack FGO gacha simulator in, what is to me, a record time of 5 days (to reach MVP status).
Since then, I have iterated over the application like mad to dish out features.
I do not own the characters, names, and attributes that might appear during the usage of this software. Those are the intellectual properties of their respective copyright holders.
I, however, hold copyright for the source code and my trade names.
I'm not affiliated with any entities mentioned or whose resource I use in this project, e.g. FGO and its owner, Atlas Academy, and others, but myself. Nor do I claim to be partnered with, sponsored or funded by the aforementioned.
- Func: Single roll
- Func: Multi roll
- FE: Servants card face
- FE: Servants class indicators
- Text only
- Symbol
- FE: Servants rarity indicators
- Text only
- Symbol
- FE: Staggered render
- FE: Servant information page (dynamic routing)
- DB: Scheduled data update task
- DB: Servant images
- Image retrieval
- Store to image database
- Caching
- SV: API rate limiting
- DevOps: Automated deployment
- Dockerfile for server
- Github Actions for server + database
- Deploy to production server
- DevOps: Reset/Improve versioning
- Follow semver
- Automate tag generation
- Automate version increment
- Separate versioning for each component
Just a simple Nextjs application bootstrapped via create-t3-app
.
Everything in this domain is done in Python.
These are executed via python main.py <option>
- No options, default: Initialize the SQL database and add to it the data from
.json
manifest. full-update
: (Housekeeping) Update the.json
manifest, fetch and store image assets from the Atlas Academy API.download-faces
: (CI/CD) Download the latest image assets from Github release.zip
: (Housekeeping) Zip the downloaded asset folder into a zip file for release publishing.
Servants data is retrieved from Atlas Academy API. The dataset type is basic with servant names in both EN and JP and includes the servant card face asset URL.
The database contains a reduction of this dataset which only includes the following properties/columns/attributes:
CREATE TABLE servants(
collectionNo INT PRIMARY KEY,
sv_name VARCHAR(128) NOT NULL,
rarity INT,
class_name VARCHAR(50) NOT NULL,
face VARCHAR(200) NOT NULL
);
Currently, it is implemented in the simplest database SQLite3 for local development with plans to eventually host on a PostgreSQL server somewhere.
The database update strategy is polling. Every 2 weeks, a schedule task SHOULD run to fetch the latest dataset from the Atlas Academy API. After fetching, the task SHOULD simply execute an update function which would insert any new entries into the database, which SHOULD handle the PRIMARY KEY collision exception and skip existing servant entries.
NOTE: I acknowledge that this is not the most efficient nor optimized way; however, it is not a resource intensive operation nor a frequent operation, so this is a good enough trade-off with the efforts needed to develop better code.
For example:
def fetch_new_data():
url = "https://api.atlasacademy.io/export/JP/basic_servant_lang_en.json"
r = requests.get(url)
data = r.json()
return data
def update_db(json_data):
"""
Update the SQLite database with the data from the json file
"""
current_path = os.getcwd()
for i in range(len(json_data)):
try:
face_path = f"https://api.reroll.ing/assets/{json_data[i]['collectionNo']}.png"
cur.execute("INSERT INTO servants (collectionNo, sv_original_name, sv_name, rarity, class_name, atk_max, hp_max, attribute, face_url, face_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(json_data[i]['collectionNo'], json_data[i]['originalName'], json_data[i]['name'], json_data[i]['rarity'], json_data[i]['className'], json_data[i]['atkMax'], json_data[i]['hpMax'], json_data[i]['attribute'], json_data[i]['face'], face_path))
except sqlite3.IntegrityError:
print(
f"Servant already exists in database, skipping: {json_data[i]['collectionNo']} - \"{json_data[i]['name']}\"")
con.commit()
Everything here is written in Golang.
I use Gin as the server which will handle the API routing.
There are simply 2 routes for 2 roll scenarios:
/roll/single
/roll/multi
On pre-connection, we read and set the environment variables from .env
.
On setup, the server will initialize a connection with the SQLite database. This is simply a static file read which looks for a sv_db.db
generated from the Python package above.
After the SQL database "connection" is established, we do a SELECT * FROM servants
🤡 and store the query results in a runtime variable, a []Servant
slice. Then, we simply mutate and access the retrieved dataset as needed by each roll scenario.
The server uses default CORS config defined in the cors
package and listens on localhost:8080
.
Takes the servants slice from as its parameter.
Roll an integer from 1 to 100
- Roll a
[1,1]
- Filter SSR from the servants slice (by matching rarity) ->
filtered []Servant
slice - Roll a number [0, len(filtered)] ->
local_roll
- Append
filtered[local_roll]
to the response body
- Filter SSR from the servants slice (by matching rarity) ->
- Roll a
[2,4]
- Same idea
- Roll a
[5, 100]
- Same idea
I expected the implementation for multi roll would be difficult, but it turned out to be more cumbersome than hard.
First, I create a guaranteed []Servant
slice as the pool containing only SSR and SR servants.
For multi roll, 1 out of the 11 rolls is a guaranteed rarity 4 or above servant.
To handle this case, I simply put it in a loop and add a condition for the first iteration.
Then, I proceeded with the rest of the cases similarly to the single roll.
Now looking back, I also put the 3* and below servants in their own slice as well, but I don't really need it for the implementation. BUT then I'm too lazy to remove it so... whatever. Probably speed it up by 0.01% (I pulled that number out of thin air, don't quote me on that).
- GET all servants
/servants
- GET servant by collection number
/servants/:collectionNo
- GET API server status
/health
- GET total number of servants
/stats/total_servants
- Frontend -> Vercel
- Database + Server -> baremetal Linux VPS
To deploy, the following steps I took were:
Get the API server running and listening on localhost:8080
- Create a VPS for
$5
a month ssh
into it- Install
go
,build-essential
,tmux
, andgit
- Clone the repo
- Add the environment variables
- Build the executable binary with
go build
- Start
tmux
- Run the server
./server
- Install Docker on the Linux server [ Debian | RHEL ]
- Run
docker run -dp 8080:8080 ghcr.io/aaanh/reroll.ing/server:latest
or the versions you need
Networking
-
Add the server's static IP to DNS A record on Cloudflare
-
Install
nginx
,certbot
,python3-certbot-nginx
-
Configure
nginx
's site configurationserver { if ($host != api.reroll.ing) { return 444; } # Add index.php to the list if you are using PHP # index index.html index.htm index.nginx-debian.html; server_name _ api.reroll.ing; location / { root /var/www/html; index index.html index.htm; } location /health { proxy_pass http://localhost:8080; } location /roll/ { proxy_pass http://localhost:8080; } location /stats/total_servants { proxy_pass http://localhost:8080; } location /servants { proxy_pass http://localhost:8080; } location /servants/ { proxy_pass http://localhost:8080; } location /assets/ { alias /var/www/reroll.ing/assets/; } listen [::]:443 ssl http2; listen 443 ssl http2; ssl_certificate /etc/ssl/cert.pem; ssl_certificate_key /etc/ssl/key.pem; ssl_client_certificate /etc/ssl/cloudflare.crt; ssl_verify_client off; } server { if ($host != api.reroll.ing) { return 444; } if ($host = api.reroll.ing) { return 301 https://$host$request_uri; } # managed by Certbot listen 80 default_server; listen [::]:80 default_server; server_name api.reroll.ing; return 404; # managed by Certbot }
-
Start
nginx
service:systemctl enable --now nginx
-
Obtain SSL cert:
certbot --nginx -d api.example.com
If you're lucky, it'll work on the first try 🥲