diff --git a/build_web_debug.bat b/build_web_debug.bat index 81d0a09..cdebff4 100644 --- a/build_web_debug.bat +++ b/build_web_debug.bat @@ -10,9 +10,10 @@ call run_codegen.bat || goto :error set FLAGS=-s TOTAL_STACK=5242880 copy marketing_page\favicon.ico build_web\favicon.ico +copy main.c build_web\main.c || goto :error @echo on -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 +emcc -sEXPORTED_FUNCTIONS=_main,_end_text_input,_stop_controlling_input,_start_controlling_input,_read_from_save_data,_dump_save_data,_in_dialog -sEXPORTED_RUNTIME_METHODS=ccall,cwrap -O0 -s ALLOW_MEMORY_GROWTH %FLAGS% --source-map-base http://localhost:8000/ -gsource-map -g3 -gdwarf -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 8a9934f..dce635d 100644 --- a/build_web_release.bat +++ b/build_web_release.bat @@ -13,7 +13,7 @@ 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,_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 +emcc -sEXPORTED_FUNCTIONS=_main,_end_text_input,_stop_controlling_input,_start_controlling_input,_read_from_save_data,_dump_save_data,_in_dialog -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 731e8c0..cf51ca9 100644 --- a/main.c +++ b/main.c @@ -1,3 +1,6 @@ +// you will die someday +#define CURRENT_VERSION 3 // wehenver you change Entity increment this boz + #define SOKOL_IMPL #if defined(WIN32) || defined(_WIN32) #define DESKTOP @@ -188,14 +191,14 @@ typedef struct Entity bool destroy; int generation; - // fields for all entities + // fields for all gs.entities Vec2 pos; Vec2 vel; // only used sometimes, like in old man and bullet float damage; // at 1.0, dead! zero initialized bool facing_left; double dead_time; bool dead; - // multiple entities have a sword swing + // multiple gs.entities have a sword swing BUFF(EntityRef, 8) done_damage_to_this_swing; // only do damage once, but hitbox stays around bool is_bullet; @@ -259,7 +262,7 @@ typedef BUFF(Overlap, 16) Overlapping; typedef struct Level { TileInstance tiles[LAYERS][LEVEL_TILES][LEVEL_TILES]; - Entity initial_entities[MAX_ENTITIES]; // shouldn't be directly modified, only used to initialize entities on loading of level + Entity initial_entities[MAX_ENTITIES]; // shouldn't be directly modified, only used to initialize gs.entities on loading of level } Level; typedef struct TileCoord @@ -351,14 +354,15 @@ void play_audio(AudioSample *sample) // on web it disables event handling so the button up event isn't received bool keydown[SAPP_KEYCODE_MENU] = {0}; + +bool in_dialog() +{ + return player->state == CHARACTER_TALKING; +} + #ifdef DESKTOP bool receiving_text_input = false; Sentence text_input_buffer = {0}; -void begin_text_input() -{ - receiving_text_input = true; - BUFF_CLEAR(&text_input_buffer); -} #else #ifdef WEB EMSCRIPTEN_KEEPALIVE @@ -369,13 +373,9 @@ void stop_controlling_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();"); + _sapp_emsc_register_eventhandlers(); } #else #error "No platform defined for text input! @@ -730,23 +730,27 @@ void add_new_npc_sentence(Entity *npc, char *sentence) } AABB level_aabb = { .upper_left = {0.0f, 0.0f}, .lower_right = {TILE_SIZE * LEVEL_TILES, -(TILE_SIZE * LEVEL_TILES)} }; -Entity entities[MAX_ENTITIES] = {0}; +typedef struct GameState { + int version; + Entity entities[MAX_ENTITIES]; +} GameState; +GameState gs = {0}; EntityRef frome(Entity *e) { EntityRef to_return = { - .index = (int)(e - entities), + .index = (int)(e - gs.entities), .generation = e->generation, }; assert(to_return.index >= 0); - assert(to_return.index < ARRLEN(entities)); + assert(to_return.index < ARRLEN(gs.entities)); return to_return; } Entity *gete(EntityRef ref) { if(ref.generation == 0) return 0; - Entity *to_return = &entities[ref.index]; + Entity *to_return = &gs.entities[ref.index]; if(!to_return->exists || to_return->generation != ref.generation) { return 0; @@ -764,11 +768,11 @@ bool eq(EntityRef ref1, EntityRef ref2) Entity *new_entity() { - for(int i = 0; i < ARRLEN(entities); i++) + for(int i = 0; i < ARRLEN(gs.entities); i++) { - if(!entities[i].exists) + if(!gs.entities[i].exists) { - Entity *to_return = &entities[i]; + Entity *to_return = &gs.entities[i]; int gen = to_return->generation; *to_return = (Entity){0}; to_return->exists = true; @@ -780,7 +784,62 @@ Entity *new_entity() return NULL; } -void begin_text_input(); // called when player engages in dialog, must say something and fill text_input_buffer +void update_player_from_entities() +{ + player = 0; + ENTITIES_ITER(gs.entities) + { + if(it->is_character) + { + assert(player == 0); + player = it; + } + } + assert(player != 0); +} + +void reset_level() +{ + // load level + Level *to_load = &level_level0; + { + assert(ARRLEN(to_load->initial_entities) == ARRLEN(gs.entities)); + memcpy(gs.entities, to_load->initial_entities, sizeof(Entity) * MAX_ENTITIES); + gs.version = CURRENT_VERSION; + ENTITIES_ITER(gs.entities) + { + if(it->generation == 0) it->generation = 1; // zero value generation means doesn't exist + } + } + update_player_from_entities(); +} + + +#ifdef WEB +EMSCRIPTEN_KEEPALIVE +void dump_save_data() +{ + EM_ASM({ + save_game_data = new Int8Array(Module.HEAP8.buffer, $0, $1); + }, (char*)(&gs), sizeof(gs)); +} +EMSCRIPTEN_KEEPALIVE +void read_from_save_data(char *data, size_t length) +{ + GameState read_data = {0}; + memcpy((char*)(&read_data), data, length); + if(read_data.version != CURRENT_VERSION) + { + Log("Bad gamestate, has version %d expected version %d\n", read_data.version, CURRENT_VERSION); + } + else + { + gs = read_data; + update_player_from_entities(); + } +} +#endif + // a callback, when 'text backend' has finished making text. End dialog void end_text_input(char *what_player_said) { @@ -1086,27 +1145,7 @@ static struct sg_bindings bind; } state; -void reset_level() -{ - // load level - Level *to_load = &level_level0; - { - assert(ARRLEN(to_load->initial_entities) == ARRLEN(entities)); - memcpy(entities, to_load->initial_entities, sizeof(Entity) * MAX_ENTITIES); - player = NULL; - ENTITIES_ITER(entities) - { - if(it->generation == 0) it->generation = 1; // zero value generation means doesn't exist - if(it->is_character) - { - assert(player == NULL); - player = it; - } - } - assert(player != NULL); // level initial config must have player entity - } -} void audio_stream_callback(float *buffer, int num_frames, int num_channels) { @@ -1178,7 +1217,7 @@ void init(void) }, 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); + Log("Size of %d gs.entities: %zu kb\n", (int)ARRLEN(gs.entities), sizeof(gs.entities)/1024); sg_setup(&(sg_desc){ .context = sapp_sgcontext(), }); @@ -1193,6 +1232,12 @@ void init(void) load_assets(); reset_level(); +#ifdef WEB + EM_ASM({ + load_all(); + }); +#endif + // load font { FILE* fontFile = fopen("assets/orange kid.ttf", "rb"); @@ -2054,7 +2099,7 @@ void draw_animated_sprite(AnimatedSprite *s, double elapsed_time, bool flipped, draw_quad((DrawParams){true, q, spritesheet_img, region, tint, .y_coord_sorting = y_sort_pos, .alpha_clip_threshold = 0.2f}); } -// gets aabbs overlapping the input aabb, including entities and tiles +// gets aabbs overlapping the input aabb, including gs.entities and tiles Overlapping get_overlapping(Level *l, AABB aabb) { Overlapping to_return = {0}; @@ -2072,8 +2117,8 @@ Overlapping get_overlapping(Level *l, AABB aabb) } } - // the entities jessie - ENTITIES_ITER(entities) + // the gs.entities jessie + ENTITIES_ITER(gs.entities) { if(!(it->is_character && it->is_rolling) && overlapping(aabb, entity_aabb(it))) { @@ -2138,7 +2183,7 @@ Vec2 move_and_slide(MoveSlideParams p) // add entity boxes if(!p.dont_collide_with_entities && !(p.from->is_character && p.from->is_rolling)) { - ENTITIES_ITER(entities) + ENTITIES_ITER(gs.entities) { if(!(it->is_character && it->is_rolling) && it != p.from && !(it->is_npc && it->dead) && !it->is_item) { @@ -2385,16 +2430,25 @@ void frame(void) return; #endif +#ifdef DESKTOP + if(!receiving_text_input && in_dialog()) + { + receiving_text_input = true; + BUFF_CLEAR(&text_input_buffer); + } +#endif + // better for vertical aspect ratios if(screen_size().x < 0.7f*screen_size().y) { - cam.scale = 3.5f; + cam.scale = 2.5f; } else { cam.scale = 2.0f; } + uint64_t time_start_frame = stm_now(); // elapsed_time double dt_double = 0.0; @@ -2539,7 +2593,7 @@ void frame(void) { Vec2 pos = V2(0.0, screen_size().Y); int num_entities = 0; - ENTITIES_ITER(entities) num_entities++; + ENTITIES_ITER(gs.entities) num_entities++; char *stats = tprint("Frametime: %.1f ms\nProcessing: %.1f ms\nEntities: %d\nDraw calls: %d\nProfiling: %s\n", dt*1000.0, last_frame_processing_time*1000.0, num_entities, num_draw_calls, profiling ? "yes" : "no"); AABB bounds = draw_text((TextParams){false, true, stats, pos, BLACK, 1.0f}); pos.Y -= bounds.upper_left.Y - screen_size().Y; @@ -2551,9 +2605,9 @@ void frame(void) } #endif // devtools - // process entities + // process gs.entities PROFILE_SCOPE("entity processing") - ENTITIES_ITER(entities) + ENTITIES_ITER(gs.entities) { assert(!(it->exists && it->generation == 0)); #ifdef WEB @@ -2812,9 +2866,9 @@ void frame(void) } } - PROFILE_SCOPE("Destroy entities") + PROFILE_SCOPE("Destroy gs.entities") { - ENTITIES_ITER(entities) + ENTITIES_ITER(gs.entities) { if(it->destroy) { @@ -2902,7 +2956,6 @@ void frame(void) // begin dialog with closest npc player->state = CHARACTER_TALKING; player->talking_to = frome(closest_interact_with); - begin_text_input(); } else if(closest_interact_with->is_item) { @@ -3056,9 +3109,9 @@ void frame(void) } } - // render entities + // render gs.entities PROFILE_SCOPE("entity rendering") - ENTITIES_ITER(entities) + ENTITIES_ITER(gs.entities) { #ifdef WEB if(it->gen_request_id != 0) diff --git a/server/codes/codes.go b/server/codes/codes.go new file mode 100644 index 0000000..71b9ca7 --- /dev/null +++ b/server/codes/codes.go @@ -0,0 +1,59 @@ +package codes + +import ( + "fmt" +) + +type UserCode int + + +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 := [4]rune{'A', 'A', 'A', 'A'} + + value := int(code) + + for place := 3; place >= 0; place-- { + index := 3 - place + currentPlaceValue := value / intPow(36, place) + value -= currentPlaceValue * intPow(36, place) + if currentPlaceValue >= len(numberToChar) { return "", fmt.Errorf("Failed to generate usercode %d to string, currentPlaceValue %d and length of number to char %d in place %d", code, currentPlaceValue, len(numberToChar),place ) } + toReturn[index] = numberToChar[currentPlaceValue] + } + + return string(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 place := 3; place >= 0; place-- { + index := 3 - place + curDigitNum := 0 + found := false + for i, letter := range numberToChar { + if letter == asRune[index] { + curDigitNum = i + found = true + } + } + if !found { return 0, fmt.Errorf("Failed to find place's number %s", s) } + toReturn += UserCode(curDigitNum * intPow(36, place)) + } + return toReturn, nil +} + diff --git a/server/codes/codes_test.go b/server/codes/codes_test.go new file mode 100644 index 0000000..b41749f --- /dev/null +++ b/server/codes/codes_test.go @@ -0,0 +1,34 @@ +package codes + +import ( + "testing" + "runtime" +) + +func assert(t *testing.T, cond bool) { + if !cond { + _, _, line, _ := runtime.Caller(1) + t.Fatalf("Failed on line %d", line) + } +} + +func TestCodes(t *testing.T) { + parsed, err := ParseUserCode("AAAA") + assert(t, err == nil) + assert(t, int(parsed) == 0) + + var stringed string + stringed, err = CodeToString(UserCode(1)) + assert(t, err == nil) + assert(t, stringed == "AAAB") + + parsed, err = ParseUserCode("AAAB") + assert(t, err == nil) + assert(t, int(parsed) == 1) + + parsed, err = ParseUserCode("BAAA") + assert(t, err == nil) + assert(t, int(parsed) == 46656) + +} + diff --git a/server/go.mod b/server/go.mod index 1b13e21..4e2f226 100644 --- a/server/go.mod +++ b/server/go.mod @@ -1,4 +1,4 @@ -module github.com/creikey/rpgpt +module github.com/creikey/rpgpt/server go 1.19 diff --git a/server/main.go b/server/main.go index 3965eec..b6549ac 100644 --- a/server/main.go +++ b/server/main.go @@ -20,6 +20,7 @@ import ( "gorm.io/gorm" "gorm.io/driver/sqlite" + "github.com/creikey/rpgpt/server/codes" ) // BoughtType values. do not reorganize these or you fuck up the database @@ -32,14 +33,13 @@ const ( 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 + Code codes.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 @@ -54,52 +54,24 @@ 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]) +var daypassTimedOut = make(map[codes.UserCode]int64) // value is time last requested, rate limiting by day pass. If exists is rate limited, should be removed when ok to request again +// for 10 free minutes a day, is when ip address began requesting +var ipAddyTenFree = make(map[string]int64) + +func cleanTimedOut() { + for k, v := range daypassTimedOut { + if currentTime() - v > 1 { + delete(daypassTimedOut, k) + } } - 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 - } + for k, v := range ipAddyTenFree { + if currentTime() - v > 24*60*60 { + delete(ipAddyTenFree, k) } - 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 } @@ -110,7 +82,7 @@ func clearOld(db *gorm.DB) { if result.Error != nil { log.Fatal(result.Error) } - var toDelete []userCode // codes + var toDelete []codes.UserCode // codes for _, user := range users { if user.BoughtType != 0 { panic("Don't know how to handle bought type " + string(user.BoughtType) + " yet") @@ -174,11 +146,11 @@ func webhookResponse(w http.ResponseWriter, req *http.Request) { if !found { log.Println("Error Failed to find user in database to fulfill: very bad! ID: " + session.ID) } else { - userString, err := codeToString(toFulfill.Code) + userString, err := codes.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) + log.Printf("Fulfilling user with code %s number %d\n", userString, toFulfill.Code) toFulfill.IsFulfilled = true db.Save(&toFulfill) } @@ -194,16 +166,16 @@ func checkout(w http.ResponseWriter, req *http.Request) { // generate a code var newCode string - var newCodeUser userCode + var newCodeUser codes.UserCode found := false for i := 0; i < 1000; i++ { codeInt := rand.Intn(MaxCodes) - newCodeUser = userCode(codeInt) + newCodeUser = codes.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) + newCode, err = codes.CodeToString(newCodeUser) if err != nil { w.WriteHeader(http.StatusInternalServerError) log.Fatalf("Failed to generate code from random number: %s", err) @@ -219,6 +191,7 @@ func checkout(w http.ResponseWriter, req *http.Request) { return } + customMessage := fmt.Sprintf("**IMPORTANT** Your Day Pass Code is %s", newCode) params := &stripe.CheckoutSessionParams { LineItems: []*stripe.CheckoutSessionLineItemParams { &stripe.CheckoutSessionLineItemParams{ @@ -229,6 +202,7 @@ func checkout(w http.ResponseWriter, req *http.Request) { Mode: stripe.String(string(stripe.CheckoutSessionModePayment)), SuccessURL: stripe.String(checkoutRedirectTo), CancelURL: stripe.String(checkoutRedirectTo), + CustomText: &stripe.CheckoutSessionCustomTextParams{ Submit: &stripe.CheckoutSessionCustomTextSubmitParams { Message: &customMessage } }, } s, err := session.New(params) @@ -237,7 +211,7 @@ func checkout(w http.ResponseWriter, req *http.Request) { log.Printf("session.New: %v", err) } - log.Printf("Creating user with checkout session ID %s\n", s.ID) + log.Printf("Creating user code %s with checkout session ID %s\n", newCode, newCodeUser ,s.ID) result := db.Create(&User { Code: newCodeUser, BoughtTime: currentTime(), @@ -275,19 +249,33 @@ func index(w http.ResponseWriter, req *http.Request) { // see if need to pay rejected := false + cleanTimedOut() { if len(userToken) != 4 { - log.Println("Rejected because not 4: `" + userToken + "`") - rejected = true + // where I do the IP rate limiting + + userKey := req.RemoteAddr + createdTime, ok := ipAddyTenFree[userKey] + if !ok { + ipAddyTenFree[userKey] = currentTime() + rejected = false + } else { + if currentTime() - createdTime < 10*60 { + rejected = false + } else { + rejected = true // out of free time buddy + } + } } else { var thisUser User - thisUserCode, err := parseUserCode(userToken) + thisUserCode, err := codes.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) + err := db.First(&thisUser, thisUserCode).Error + if err != nil { + log.Printf("User code %d string %s couldn't be found in the database: %s\n", thisUserCode, userToken, err) rejected = true } else { if isUserOld(thisUser) { @@ -295,7 +283,15 @@ func index(w http.ResponseWriter, req *http.Request) { db.Delete(&thisUser) rejected = true } else { - rejected = false + // now have valid user, in the database, to be rate limit checked + // rate limiting based on user token + _, exists := daypassTimedOut[thisUserCode] + if exists { + rejected = true + } else { + rejected = false + daypassTimedOut[thisUserCode] = currentTime() + } } } } @@ -381,7 +377,7 @@ func main() { http.HandleFunc("/checkout", checkout) portString := ":8090" - log.Println("Serving on " + portString + "...") + log.Println("DO NOT RUN WITH CLOUDFLARE PROXY it rate limits based on IP, if behind proxy every IP will be the same. Would need to fix req.RemoteAddr. Serving on " + portString + "...") http.ListenAndServe(portString, nil) } diff --git a/todo.txt b/todo.txt index ca4d517..29ae6e1 100644 --- a/todo.txt +++ b/todo.txt @@ -1,5 +1,6 @@ Happening by END OF STREAM: - Payment working + - Respond to cancel with stripe, redirect to ?cancelled=true that clears day pass ticket cookie and ui value - Fixed timesep the gameplay (which means separate player rendering) - Make action between stars much more technical sounding so AI doesn't hallucinate responses - Help you fight and fight you actions diff --git a/web_template.html b/web_template.html index ef3c7a7..ce2a9ae 100644 --- a/web_template.html +++ b/web_template.html @@ -156,7 +156,7 @@ body {