diff --git a/.gitignore b/.gitignore index f53e225..5859cfb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Server builds server/rpgpt +server/*.db # sensitive API_KEY.bat diff --git a/build_web_debug.bat b/build_web_debug.bat index fa498fd..81d0a09 100644 --- a/build_web_debug.bat +++ b/build_web_debug.bat @@ -9,8 +9,10 @@ call run_codegen.bat || goto :error @REM GO FUCK YOURSELF set FLAGS=-s TOTAL_STACK=5242880 +copy marketing_page\favicon.ico build_web\favicon.ico + @echo on -emcc -sEXPORTED_FUNCTIONS=_main,_end_text_input -sEXPORTED_RUNTIME_METHODS=ccall,cwrap -O0 -s ALLOW_MEMORY_GROWTH %FLAGS% --source-map-base ../ -gsource-map -DDEVTOOLS -Ithirdparty -Igen main.c -o build_web\index.html --preload-file assets --shell-file web_template.html || goto :error +emcc -sEXPORTED_FUNCTIONS=_main,_end_text_input,_stop_controlling_input,_start_controlling_input -sEXPORTED_RUNTIME_METHODS=ccall,cwrap -O0 -s ALLOW_MEMORY_GROWTH %FLAGS% --source-map-base ../ -gsource-map -DDEVTOOLS -Ithirdparty -Igen main.c -o build_web\index.html --preload-file assets --shell-file web_template.html || goto :error @echo off goto :EOF diff --git a/build_web_release.bat b/build_web_release.bat index 527ead8..8a9934f 100644 --- a/build_web_release.bat +++ b/build_web_release.bat @@ -8,8 +8,12 @@ call run_codegen.bat || goto :error @REM GO FUCK YOURSELF set FLAGS=-s TOTAL_STACK=5242880 +copy marketing_page\favicon.ico build_web_release\favicon.ico +copy marketing_page\eye_closed.svg build_web_release\eye_closed.svg +copy marketing_page\eye_open.svg build_web_release\eye_open.svg + echo Building release -emcc -sEXPORTED_FUNCTIONS=_main,_end_text_input -sEXPORTED_RUNTIME_METHODS=ccall,cwrap -DNDEBUG -O2 -s ALLOW_MEMORY_GROWTH %FLAGS% -Ithirdparty -Igen main.c -o build_web_release\index.html --preload-file assets --shell-file web_template.html || goto :error +emcc -sEXPORTED_FUNCTIONS=_main,_end_text_input,_stop_controlling_input,_start_controlling_input -sEXPORTED_RUNTIME_METHODS=ccall,cwrap -DNDEBUG -O2 -s ALLOW_MEMORY_GROWTH %FLAGS% -Ithirdparty -Igen main.c -o build_web_release\index.html --preload-file assets --shell-file web_template.html || goto :error goto :EOF diff --git a/main.c b/main.c index a369ec8..e571d82 100644 --- a/main.c +++ b/main.c @@ -354,10 +354,19 @@ void begin_text_input() } #else #ifdef WEB -void begin_text_input() +EMSCRIPTEN_KEEPALIVE +void stop_controlling_input() { - Log("Disabling event handlers\n"); _sapp_emsc_unregister_eventhandlers(); // stop getting input, hand it off to text input +} + +EMSCRIPTEN_KEEPALIVE +void start_controlling_input() +{ + _sapp_emsc_register_eventhandlers(); +} +void begin_text_input() +{ memset(keydown, 0, ARRLEN(keydown)); emscripten_run_script("start_dialog();"); } @@ -747,9 +756,13 @@ void begin_text_input(); // called when player engages in dialog, must say somet // a callback, when 'text backend' has finished making text. End dialog void end_text_input(char *what_player_said) { + // avoid double ending text input + if(player->state != CHARACTER_TALKING) + { + return; + } player->state = CHARACTER_IDLE; #ifdef WEB // hacky - _sapp_emsc_register_eventhandlers(); #endif size_t player_said_len = strlen(what_player_said); @@ -1115,6 +1128,11 @@ void audio_stream_callback(float *buffer, int num_frames, int num_channels) void init(void) { +#ifdef WEB + EM_ASM({ + set_server_url(UTF8ToString($0)); + }, SERVER_URL); +#endif Log("Size of entity struct: %zu\n", sizeof(Entity)); Log("Size of %d entities: %zu kb\n", (int)ARRLEN(entities), sizeof(entities)/1024); sg_setup(&(sg_desc){ diff --git a/marketing_page/favicon.ico b/marketing_page/favicon.ico new file mode 100644 index 0000000..d3d4df6 Binary files /dev/null and b/marketing_page/favicon.ico differ diff --git a/marketing_page/index.html b/marketing_page/index.html index fe101b6..4be3d6b 100644 --- a/marketing_page/index.html +++ b/marketing_page/index.html @@ -1,4 +1,5 @@ + PlayGPT diff --git a/run_codegen.bat b/run_codegen.bat index a0c0b40..3575ce1 100644 --- a/run_codegen.bat +++ b/run_codegen.bat @@ -4,6 +4,7 @@ echo Asset packs which must be bought and unzipped into root directory before ru echo https://rafaelmatos.itch.io/epic-rpg-world-pack-ancient-ruins echo https://sventhole.itch.io/undead-pixel-art-characters + rmdir /S /q assets\copyrighted mkdir assets\copyrighted copy "EPIC RPG World Pack - Ancient Ruins V 1.7\EPIC RPG World Pack - Ancient Ruins V 1.7\Characters\NPC Merchant-idle.png" "assets\copyrighted\merchant.png" || goto :error diff --git a/server/go.mod b/server/go.mod index 2d1419c..1b13e21 100644 --- a/server/go.mod +++ b/server/go.mod @@ -2,4 +2,13 @@ module github.com/creikey/rpgpt go 1.19 -require github.com/sashabaranov/go-gpt3 v1.2.1 // indirect +require ( + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/mattn/go-sqlite3 v1.14.15 // indirect + github.com/sashabaranov/go-gpt3 v1.2.1 // indirect + github.com/stripe/stripe-go/v72 v72.122.0 // indirect + github.com/stripe/stripe-go/v74 v74.13.0 // indirect + gorm.io/driver/sqlite v1.4.4 // indirect + gorm.io/gorm v1.24.6 // indirect +) diff --git a/server/go.sum b/server/go.sum index db2116d..06fbc8f 100644 --- a/server/go.sum +++ b/server/go.sum @@ -1,2 +1,37 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI= +github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sashabaranov/go-gpt3 v1.2.1 h1:kfU+vQ1ThI7p+xfwwJC8olEEEWjK3smgKZ3FcYbaLRQ= github.com/sashabaranov/go-gpt3 v1.2.1/go.mod h1:BIZdbwdzxZbCrcKGMGH6u2eyGe1xFuX9Anmh3tCP8lQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stripe/stripe-go/v72 v72.122.0 h1:eRXWqnEwGny6dneQ5BsxGzUCED5n180u8n665JHlut8= +github.com/stripe/stripe-go/v72 v72.122.0/go.mod h1:QwqJQtduHubZht9mek5sds9CtQcKFdsykV9ZepRWwo0= +github.com/stripe/stripe-go/v74 v74.13.0 h1:n9VIeApHaGsqRQcEsr8ANldfFrLzFSasfNBkq0roPTw= +github.com/stripe/stripe-go/v74 v74.13.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.4.4 h1:gIufGoR0dQzjkyqDyYSCvsYR6fba1Gw5YKDqKeChxFc= +gorm.io/driver/sqlite v1.4.4/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI= +gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= +gorm.io/gorm v1.24.6 h1:wy98aq9oFEetsc4CAbKD2SoBCdMzsbSIvSUUFJuHi5s= +gorm.io/gorm v1.24.6/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= diff --git a/server/main.go b/server/main.go index 08147b1..3965eec 100644 --- a/server/main.go +++ b/server/main.go @@ -2,31 +2,316 @@ package main import ( "fmt" + "time" "net/http" "io" "os" "context" "log" + "io/ioutil" + "strings" + "encoding/json" + "math/rand" gogpt "github.com/sashabaranov/go-gpt3" + "github.com/stripe/stripe-go/v74" + "github.com/stripe/stripe-go/v74/webhook" + "github.com/stripe/stripe-go/v74/checkout/session" + + "gorm.io/gorm" + "gorm.io/driver/sqlite" + +) + +// BoughtType values. do not reorganize these or you fuck up the database +const ( + DayPass = iota +) + +const ( + // A-Z and 0-9, four digits means this many codes + MaxCodes = 36 * 36 * 36 * 36 ) +type userCode int + +type User struct { + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + + Code userCode `gorm:"primaryKey"` // of maximum value max codes, incremented one by one. These are converted to 4 digit alphanumeric code users can remember/use + BoughtTime int64 // unix time. Used to figure out if the pass is still valid + BoughtType int // enum + + IsFulfilled bool // before users are checked out they are unfulfilled + CheckoutSessionID string +} + var c *gogpt.Client +var logResponses = false +var doCors = false +var checkoutRedirectTo string +var daypassPriceId string +var webhookSecret string +var db *gorm.DB + +func intPow(n, m int) int { + if m == 0 { + return 1 + } + result := n + for i := 2; i <= m; i++ { + result *= n + } + return result +} + +var numberToChar = [...]rune{'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'} + +func codeToString(code userCode) (string, error) { + toReturn := "" + value := int(code) + // converting to base 36 (A-Z + numbers) then into characters, then appending + for digit := 3; digit >= 0; digit-- { + currentPlaceValue := value / intPow(36, digit) + value -= currentPlaceValue * intPow(36, digit) + if currentPlaceValue >= len(numberToChar) { return "", fmt.Errorf("Failed to generate usercode %d to string, currentPlaceValue %d and length of number to char %d", code, currentPlaceValue, len(numberToChar)) } + toReturn += string(numberToChar[currentPlaceValue]) + } + return toReturn, nil; +} + +func parseUserCode(s string) (userCode, error) { + asRune := []rune(s) + if len(asRune) != 4 { return 0, fmt.Errorf("String to deconvert is not of length 4: %s", s) } + var toReturn userCode = 0 + for digit := 3; digit >= 0; digit-- { + curDigitNum := 0 + found := false + for i, letter := range numberToChar { + if letter == asRune[digit] { + curDigitNum = i + found = true + } + } + if !found { return 0, fmt.Errorf("Failed to find digit's number %s", s) } + toReturn += userCode(curDigitNum * intPow(36, digit)) + } + return toReturn, nil +} + +func isUserOld(user User) bool { + return (currentTime() - user.BoughtTime) > 24*60*60 +} + +func clearOld(db *gorm.DB) { + var users []User + result := db.Find(&users) + if result.Error != nil { + log.Fatal(result.Error) + } + var toDelete []userCode // codes + for _, user := range users { + if user.BoughtType != 0 { + panic("Don't know how to handle bought type " + string(user.BoughtType) + " yet") + } + if isUserOld(user) { + toDelete = append(toDelete, user.Code) + } + } + + for _, del := range toDelete { + db.Delete(&User{}, del) + } +} + +func webhookResponse(w http.ResponseWriter, req *http.Request) { + const MaxBodyBytes = int64(65536) + req.Body = http.MaxBytesReader(w, req.Body, MaxBodyBytes) + + body, err := ioutil.ReadAll(req.Body) + if err != nil { + log.Printf("Error reading request body: %v\n", err) + w.WriteHeader(http.StatusServiceUnavailable) + return + } + + endpointSecret := webhookSecret + event, err := webhook.ConstructEvent(body, req.Header.Get("Stripe-Signature"), endpointSecret) + + if err != nil { + log.Printf("Error verifying webhook signature %s\n", err) + w.WriteHeader(http.StatusBadRequest) // Return a 400 error on a bad signature + return + } + + if event.Type == "checkout.session.completed" { + var session stripe.CheckoutSession + err := json.Unmarshal(event.Data.Raw, &session) + if err != nil { + log.Printf("Error parsing webhook JSON %s", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + params := &stripe.CheckoutSessionParams{} + params.AddExpand("line_items") + + // Retrieve the session. If you require line items in the response, you may include them by expanding line_items. + // Fulfill the purchase... + + + var toFulfill User + found := false + for trial := 0; trial < 5; trial++ { + if db.Where("checkout_session_id = ?", session.ID).First(&toFulfill).Error != nil { + log.Println("Failed to fulfill user with ID " + session.ID) + } else { + found = true + break + } + } + if !found { + log.Println("Error Failed to find user in database to fulfill: very bad! ID: " + session.ID) + } else { + userString, err := codeToString(toFulfill.Code) + if err != nil { + log.Printf("Error strange thing, saved user's code was unable to be converted to a string %s", err) + } + log.Printf("Fulfilling user with code %s\n", userString) + toFulfill.IsFulfilled = true + db.Save(&toFulfill) + } + } + + w.WriteHeader(http.StatusOK) +} + +func checkout(w http.ResponseWriter, req *http.Request) { + if doCors { + w.Header().Set("Access-Control-Allow-Origin", "*") + } + + // generate a code + var newCode string + var newCodeUser userCode + found := false + for i := 0; i < 1000; i++ { + codeInt := rand.Intn(MaxCodes) + newCodeUser = userCode(codeInt) + var tmp User + r := db.Where("Code = ?", newCodeUser).Limit(1).Find(&tmp) + if r.RowsAffected == 0{ + var err error + newCode, err = codeToString(newCodeUser) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + log.Fatalf("Failed to generate code from random number: %s", err) + return + } + found = true + break + } + } + if !found { + w.WriteHeader(http.StatusInternalServerError) + log.Fatal("Failed to find new code!!!") + return + } + + params := &stripe.CheckoutSessionParams { + LineItems: []*stripe.CheckoutSessionLineItemParams { + &stripe.CheckoutSessionLineItemParams{ + Price: stripe.String(daypassPriceId), + Quantity: stripe.Int64(1), + }, + }, + Mode: stripe.String(string(stripe.CheckoutSessionModePayment)), + SuccessURL: stripe.String(checkoutRedirectTo), + CancelURL: stripe.String(checkoutRedirectTo), + } + + s, err := session.New(params) + + if err != nil { + log.Printf("session.New: %v", err) + } + + log.Printf("Creating user with checkout session ID %s\n", s.ID) + result := db.Create(&User { + Code: newCodeUser, + BoughtTime: currentTime(), + BoughtType: DayPass, + IsFulfilled: false, + CheckoutSessionID: s.ID, + }) + if result.Error != nil { + w.WriteHeader(http.StatusInternalServerError) + log.Printf("Failed to write to database: %s", result.Error) + } else { + fmt.Fprintf(w, "%s|%s", newCode, s.URL) + } +} func index(w http.ResponseWriter, req *http.Request) { - //time.Sleep(4 * time.Second) req.Body = http.MaxBytesReader(w, req.Body, 1024 * 1024) // no sending huge files to crash the server - promptBytes, err := io.ReadAll(req.Body) + if doCors { + w.Header().Set("Access-Control-Allow-Origin", "*") + } + bodyBytes, err := io.ReadAll(req.Body) if err != nil { w.WriteHeader(http.StatusBadRequest) + log.Println("Bad error: ", err) return } else { - promptString := string(promptBytes) + bodyString := string(bodyBytes) + splitBody := strings.Split(bodyString, "|") + + if len(splitBody) != 2 { + w.WriteHeader(http.StatusBadRequest) + } + var promptString string = splitBody[1] + var userToken string = splitBody[0] - fmt.Println() - fmt.Println("Println line prompt string: ", promptString) + // see if need to pay + rejected := false + { + if len(userToken) != 4 { + log.Println("Rejected because not 4: `" + userToken + "`") + rejected = true + } else { + var thisUser User + thisUserCode, err := parseUserCode(userToken) + if err != nil { + log.Printf("Error: Failed to parse user token %s\n", userToken) + rejected = true + } else { + if db.First(&thisUser, thisUserCode).Error != nil { + log.Printf("User code %d string %s couldn't be found in the database: %s\n", thisUserCode, userToken, db.Error) + rejected = true + } else { + if isUserOld(thisUser) { + log.Println("User code " + userToken + " is old, not valid") + db.Delete(&thisUser) + rejected = true + } else { + rejected = false + } + } + } + } + } + if rejected { + fmt.Fprintf(w, "0") + return + } + + if logResponses { + log.Println("Println line prompt string: ", promptString) + } ctx := context.Background() - req := gogpt.CompletionRequest{ + req := gogpt.CompletionRequest { Model: "curie:ft-personal-2023-03-24-03-06-24", MaxTokens: 80, Prompt: promptString, @@ -39,26 +324,64 @@ func index(w http.ResponseWriter, req *http.Request) { } resp, err := c.CreateCompletion(ctx, req) if err != nil { - fmt.Println("Failed to generate: ", err) + log.Println("Error Failed to generate: ", err) w.WriteHeader(http.StatusInternalServerError) return } response := resp.Choices[0].Text - fmt.Println("Println response: ", response) - fmt.Fprintf(w, "%s", response) + if logResponses { + log.Println("Println response: ", response) + } + fmt.Fprintf(w, "1%s", response) } } +func currentTime() int64 { + return time.Now().Unix() +} + func main() { + var err error + db, err = gorm.Open(sqlite.Open("rpgpt.db"), &gorm.Config{}) + if err != nil { + log.Fatal(err) + } + db.AutoMigrate(&User{}) + + clearOld(db) + api_key := os.Getenv("OPENAI_API_KEY") if api_key == "" { log.Fatal("Must provide openai key") } + checkoutRedirectTo = os.Getenv("REDIRECT_TO") + if checkoutRedirectTo == "" { + log.Fatal("Must provide a base URL (without slash) for playgpt to redirect to") + } + stripeKey := os.Getenv("STRIPE_KEY") + if stripeKey == "" { + log.Fatal("Must provide stripe key") + } + daypassPriceId = os.Getenv("PRICE_ID") + if daypassPriceId == "" { + log.Fatal("Must provide daypass price ID") + } + stripe.Key = stripeKey + webhookSecret = os.Getenv("WEBHOOK_SECRET") + if webhookSecret == "" { + log.Fatal("Must provide webhook secret for receiving checkout completed events") + } + + logResponses = os.Getenv("LOG_RESPONSES") != "" + doCors = os.Getenv("CORS") != "" c = gogpt.NewClient(api_key) http.HandleFunc("/", index) + http.HandleFunc("/webhook", webhookResponse) + http.HandleFunc("/checkout", checkout) - log.Println("Serving...") - http.ListenAndServe(":8090", nil) + portString := ":8090" + log.Println("Serving on " + portString + "...") + http.ListenAndServe(portString, nil) } diff --git a/todo.txt b/todo.txt index 2bb0417..ca4d517 100644 --- a/todo.txt +++ b/todo.txt @@ -6,6 +6,7 @@ Happening by END OF STREAM: - New characters/items from fate - New art in - Old man in beginning is invincible + - Make new openai key (it was leaked) - Add cancel button - Style buttons - Make map better diff --git a/web_template.html b/web_template.html index 1d12f4c..ef3c7a7 100644 --- a/web_template.html +++ b/web_template.html @@ -1,36 +1,16 @@ - - + + + AI RPG + + - + + + +
+

AI is expensive so you need to pay for it

+

You can play for free 10 minutes per day

+
+ +

24 Hour Pass

+ +
+
+ + + +
+
@@ -109,7 +175,6 @@ body {
- {{{ SCRIPT }}}