Compare commits

...

7 Commits

@ -1,5 +1,26 @@
[
{can_hear: [NPC_Daniel, NPC_Raphael]}
{enum: NPC_Devil, dialog: "What's up cracker jack", to: Daniel}
{enum: NPC_Daniel, dialog: "What the hell are you talking about?", to: Devil}
{enum: NPC_Devil, dialog: "Bootylicious", to: Daniel}
{enum: NPC_Daniel, dialog: "I don't know what the hell is going on here, but whatever it is it isn't good. Get off my property or leave promptly.", to: Devil}
{enum: NPC_Raphael, dialog: "Yeah man, what's up with you?", to: Devil}
{enum: NPC_Devil, dialog: "Nunya!", to: Raphael}
{enum: NPC_Raphael, dialog: "What does 'Nunya' mean? A wild critter you are...", to: Devil}
{enum: NPC_Daniel, dialog: "Can we do something about this vermin?", to: Raphael}
{enum: NPC_Raphael, dialog: "I dunno man, he's got some gunpowder", to: Daniel}
{enum: NPC_Daniel, dialog: "Gunpower? Well, that changes things. Keep an eye on him, but don't take any unnecessary risks. We don't need any more trouble around here.", to: Raphael}
{enum: NPC_Devil, dialog: "Lick my nuts!", to: Daniel}
{enum: NPC_Daniel, dialog: "You've got a mouth on you, don't you? I suggest you watch your tongue before you find yourself in a world of hurt.", to: Devil}
{enum: NPC_Raphael, dialog: "Man I could really use a vacation", to: Daniel}
{enum: NPC_Daniel, dialog: "A vacation, huh? I can understand that. Life can be tough out here. But remember, there's work to be done and responsibilities to uphold. We don't have the luxury of vacations around here.", to: Raphael}
{enum: NPC_Devil, dialog: "Bro why you talk like that", to: Daniel}
{enum: NPC_Daniel, dialog: "I talk like this because life has taught me to be cautious and weary. I've seen things that would make your blood run cold. So pardon me if I don't sound all cheery and optimistic like some folks. Now, if you're done with your questions, I suggest you mosey on out of here.", to: Devil}
{enum: NPC_Devil, dialog: "Never! I'll kill you and destroy your farm", to: Daniel}
{enum: NPC_Daniel, dialog: "You're treading on dangerous ground, stranger. Threatening me and my farm won't end well for you. I suggest you turn around and walk away before things escalate.", to: Devil}
{enum: NPC_Devil, dialog: "I'll take my leave them. Until next time!", to: Daniel}
{enum: NPC_Raphael, dialog: "What a psycho...", to: Daniel}
/*
{can_hear: [NPC_WellDweller, NPC_Farmer, NPC_ManInBlack]},

@ -3,7 +3,10 @@
#include "HandmadeMath.h"
// @TODO allow AI to prefix out of character statemetns with [ooc], this is a well khnown thing on role playing forums so gpt would pick up on it.
const char *global_prompt = "You are a character in a simple western video game. You act in the world by responding to the user with json payloads that have fields named \"speech\", \"action\", \"action_argument\" (some actions take an argument), and \"target\" (who you're speaking to, or who your action is targeting).";
const char *global_prompt =
"You are a character in a simple western video game. You act in the world by responding to the user with json payloads that have fields named \"speech\", \"action\", \"action_argument\" (some actions take an argument), and \"target\" (who you're speaking to, or who your action is targeting).\n"
"You speak only when you have something to say, or are responding to somebody, and use short, concise, punchy language. If you're just overhearing what other people are saying, you only say something when absolutely compelled to do so"
;
const char *top_of_header = ""
"#pragma once\n"
@ -49,7 +52,7 @@ typedef struct
char *prompt;
} CharacterGen;
CharacterGen characters[] = {
#define CHARACTER_PROMPT_PREFIX "You specifically are acting as a "
#define CHARACTER_PROMPT_PREFIX(name) "You, " name ", specifically are acting as a "
{
.name = "nobody",
.enum_name = "nobody",
@ -63,11 +66,15 @@ CharacterGen characters[] = {
{
.name = "Daniel",
.enum_name = "Daniel",
.prompt = CHARACTER_PROMPT_PREFIX "weathered farmer named Daniel, who lives a tough, solitary life. You don't see much of a reason to keep living but soldier on anyways. You have a tragic backstory, and mostly just work on the farm.",
.prompt = CHARACTER_PROMPT_PREFIX("Daniel") "weathered farmer, who lives a tough, solitary life. You don't see much of a reason to keep living but soldier on anyways. You have a tragic backstory, and mostly just work on the farm.",
},
{
.name = "Raphael",
.enum_name = "Raphael",
.prompt = CHARACTER_PROMPT_PREFIX "physicist from the 1980s who got their doctorate in subatomic particle physics. They don't know why they're in a western town, but they're terrified.",
.prompt = CHARACTER_PROMPT_PREFIX("Raphael") "physicist from the 1980s who got their doctorate in subatomic particle physics. They don't know why they're in a western town, but they're terrified.",
},
};
{
.name = "The Devil",
.enum_name = "Devil",
.prompt = CHARACTER_PROMPT_PREFIX("The Devil") "strange red beast, the devil himself, evil incarnate. You mercilessly mock everybody who talks to you, and are intending to instill absolute chaos.",
},};

@ -1652,6 +1652,19 @@ MD_String8 is_action_valid(MD_Arena *arena, Entity *from, Action a)
error_message = FmtWithLint(arena, "You can't join somebody, you're already in %s's party", characters[gete(from->joined)->npc_kind].name);
}
if(error_message.size == 0 && a.kind == ACT_join)
{
bool talk_to_valid = false;
BUFF_ITER(NpcKind, &talk)
{
if(*it == a.argument.targeting) talk_to_valid = true;
}
if(talk_to_valid == false)
{
error_message = FmtWithLint(arena, "Your action_argument for who to join, %s, is either invalid (you can't join nobody) or it's not an NPC that's near you right now.", characters[a.argument.targeting].name);
}
}
if(error_message.size == 0)
{
AvailableActions available = {0};
@ -2101,8 +2114,7 @@ void initialize_gamestate_from_threedee_level(GameState *gs, ThreeDeeLevel *leve
if(drama_errors.node_count == 0)
{
memcpy(current_action.speech.text, dialog.str, dialog.size);
current_action.speech.text_length = (int)dialog.size;
chunk_from_s8(&current_action.speech, dialog);
}
}
@ -2124,7 +2136,7 @@ void initialize_gamestate_from_threedee_level(GameState *gs, ThreeDeeLevel *leve
this_context.i_said_this = true;
}
remember_action(gs, it, current_action, this_context);
if(it->npc_kind != current_context.author_npc_kind)
if(it->npc_kind != current_context.author_npc_kind && it->npc_kind != current_context.talking_to_kind)
{
// it's good for NPC health that they have examples of not saying anything in response to others speaking,
// so that they do the same when it's unlikely for them to talk.
@ -2145,7 +2157,7 @@ void initialize_gamestate_from_threedee_level(GameState *gs, ThreeDeeLevel *leve
PushWithLint(scratch.arena, &drama_errors, "Couldn't find NPC of kind %s in the current map", characters[want].enum_name);
}
}
Log("Propagated to %.s...\n", characters[want].name);
Log("Propagated to %d name '%s'...\n", want, characters[want].name);
}
}
}
@ -5655,6 +5667,7 @@ void frame(void)
// draw the 3d render
draw_quad((DrawParams){quad_at(V2(0.0, screen_size().y), screen_size()), IMG(state.threedee_pass_image), WHITE, .layer = LAYER_WORLD, .custom_pipeline = state.twodee_colorcorrect_pip });
draw_quad((DrawParams){quad_at(V2(0.0, screen_size().y), screen_size()), IMG(state.outline_pass_image), WHITE, .layer = LAYER_UI_FG, .custom_pipeline = state.twodee_outline_pip, .layer = LAYER_UI});
// 2d drawing TODO move this to when the drawing is flushed.
sg_begin_default_pass(&state.clear_depth_buffer_pass_action, sapp_width(), sapp_height());
@ -6995,35 +7008,52 @@ ISANERROR("Don't know how to do this stuff on this platform.")
draw_quad((DrawParams){quad_at(V2(0.0, screen_size().y/2.0f), MulV2F(screen_size(), 0.1f)), IMG(state.outline_pass_image), WHITE, .layer = LAYER_UI_FG});
Vec3 view_cam_pos = MulM4V4(InvGeneralM4(view), V4(0,0,0,1)).xyz;
Vec3 world_mouse = screenspace_point_to_camera_point(mouse_pos);
Vec3 mouse_ray = NormV3(SubV3(world_mouse, view_cam_pos));
Vec3 marker = ray_intersect_plane(view_cam_pos, mouse_ray, V3(0,0,0), V3(0,1,0));
Vec2 mouse_on_floor = point_plane(marker);
Overlapping mouse_over = get_overlapping(aabb_centered(mouse_on_floor, V2(1,1)));
BUFF_ITER(Entity*, &mouse_over)
if(view_cam_pos.y >= 0.050f) // causes nan if not true... not good...
{
dbgcol(PINK)
Vec3 world_mouse = screenspace_point_to_camera_point(mouse_pos);
Vec3 mouse_ray = NormV3(SubV3(world_mouse, view_cam_pos));
Vec3 marker = ray_intersect_plane(view_cam_pos, mouse_ray, V3(0,0,0), V3(0,1,0));
Vec2 mouse_on_floor = point_plane(marker);
Overlapping mouse_over = get_overlapping(aabb_centered(mouse_on_floor, V2(1,1)));
BUFF_ITER(Entity*, &mouse_over)
{
dbgplanerect(entity_aabb(*it));
dbgcol(PINK)
{
dbgplanerect(entity_aabb(*it));
// debug draw memories of hovered
Entity *to_view = *it;
Vec2 start_at = V2(0,300);
Vec2 cur_pos = start_at;
Entity *to_view = *it;
Vec2 start_at = V2(0,300);
Vec2 cur_pos = start_at;
AABB bounds = draw_text((TextParams){false, MD_S8Fmt(frame_arena, "--Memories for %s--", characters[to_view->npc_kind].name), cur_pos, WHITE, 1.0});
cur_pos.y -= aabb_size(bounds).y;
for(Memory *cur = to_view->memories_first; cur; cur = cur->next)
if(cur->speech.text_length > 0)
{
MD_String8 to_text = cur->context.talking_to_kind != NPC_nobody ? MD_S8Fmt(frame_arena, " to %s ", characters[cur->context.talking_to_kind].name) : MD_S8Lit("");
MD_String8 text = MD_S8Fmt(frame_arena, "%s%s%.*s: %.*s", to_view->npc_kind == cur->context.author_npc_kind ? "(Me) " : "", characters[cur->context.author_npc_kind].name, MD_S8VArg(to_text), cur->speech.text_length, cur->speech);
AABB bounds = draw_text((TextParams){false, text, cur_pos, WHITE, 1.0});
AABB bounds = draw_text((TextParams){false, MD_S8Fmt(frame_arena, "--Memories for %s--", characters[to_view->npc_kind].name), cur_pos, WHITE, 1.0});
cur_pos.y -= aabb_size(bounds).y;
for(Memory *cur = to_view->memories_first; cur; cur = cur->next)
if(cur->speech.text_length > 0)
{
MD_String8 to_text = cur->context.talking_to_kind != NPC_nobody ? MD_S8Fmt(frame_arena, " to %s ", characters[cur->context.talking_to_kind].name) : MD_S8Lit("");
MD_String8 text = MD_S8Fmt(frame_arena, "%s%s%.*s: %.*s", to_view->npc_kind == cur->context.author_npc_kind ? "(Me) " : "", characters[cur->context.author_npc_kind].name, MD_S8VArg(to_text), cur->speech.text_length, cur->speech);
AABB bounds = draw_text((TextParams){false, text, cur_pos, WHITE, 1.0});
cur_pos.y -= aabb_size(bounds).y;
}
if(keypressed[SAPP_KEYCODE_Q] && !receiving_text_input)
{
Log("-- Printing debug memories for %s --\n", characters[to_view->npc_kind].name);
int mem_idx = 0;
for(Memory *cur = to_view->memories_first; cur; cur = cur->next)
{
MD_String8 to_text = cur->context.talking_to_kind != NPC_nobody ? MD_S8Fmt(frame_arena, " to %s ", characters[cur->context.talking_to_kind].name) : MD_S8Lit("");
MD_String8 text = MD_S8Fmt(frame_arena, "%s%s%.*s: %.*s", to_view->npc_kind == cur->context.author_npc_kind ? "(Me) " : "", characters[cur->context.author_npc_kind].name, MD_S8VArg(to_text), cur->speech.text_length, cur->speech);
printf("Memory %d: %.*s\n", mem_idx, MD_S8VArg(text));
mem_idx++;
}
}
break;
}
}
break;
}
Vec2 pos = V2(0.0, screen_size().Y);

@ -367,9 +367,9 @@ MD_String8 generate_chatgpt_prompt(MD_Arena *arena, GameState *gs, Entity *e, Ca
AddFmt("\n");
// @TODO unhardcode this, this will be a description of where the character is right now
AddFmt("You're currently standing in Daniel's farm's barn, a run-down structure that barely serves its purpose. Daniel's mighty protective of it though.");
AddFmt("You're currently standing in Daniel's farm's barn, a run-down structure that barely serves its purpose. Daniel's mighty protective of it though.\n");
AddFmt("The actions you can perform, what they do, and the arguments they expect:");
AddFmt("The actions you can perform, what they do, and the arguments they expect:\n");
AvailableActions can_perform;
fill_available_actions(gs, e, &can_perform);
BUFF_ITER(ActionKind, &can_perform)
@ -383,32 +383,12 @@ MD_String8 generate_chatgpt_prompt(MD_Arena *arena, GameState *gs, Entity *e, Ca
MD_String8List current_list = {0};
for(Memory *it = e->memories_first; it; it = it->next)
{
// dump the current list, as the human understandable description of what's happened in the game so far, as a user node
if(it->context.i_said_this || it == e->memories_last)
{
if(it == e->memories_last && e->errorlist_first)
{
AddFmt("Errors you made: \n");
for(TextChunkList *cur = e->errorlist_first; cur; cur = cur->next)
{
AddFmt("%.*s\n", TextChunkVArg(cur->text));
}
}
if(current_list.node_count > 0)
AddNewNode(MSG_USER);
}
// going through memories, I'm going to accumulate human understandable sentences for what happened in current_list.
// when I see an 'i_said_this' memory, that means I flush. and add a new assistant node.
if(it->context.i_said_this)
{
AddFmt("{");
AddFmt("\"speech\":\"%.*s\",", TextChunkVArg(it->speech));
AddFmt("\"action\":\"%s\",", actions[it->action_taken].name);
AddFmt("\"action_argument\":\"%.*s\",", MD_S8VArg(action_argument_string(it->action_argument)));
AddFmt("\"target\":\"%s\"}", characters[it->context.talking_to_kind].name);
AddNewNode(MSG_ASSISTANT);
}
else
{
// write a new human understandable sentence or two to current_list
if (!it->context.i_said_this) {
// dump a human understandable sentence description of what happened in this memory
if(it->action_taken != ACT_none)
{
@ -432,8 +412,41 @@ MD_String8 generate_chatgpt_prompt(MD_Arena *arena, GameState *gs, Entity *e, Ca
else
target_string = MD_S8CString(characters[it->context.talking_to_kind].name);
}
AddFmt("%s said %.*s to %.*s\n", characters[it->context.author_npc_kind].name, TextChunkVArg(it->speech), MD_S8VArg(target_string));
MD_String8 speaking_to_you_helper = MD_S8Lit("(Speaking directly you) ");
if(it->context.talking_to_kind != e->npc_kind)
{
speaking_to_you_helper = MD_S8Lit("(Overheard conversation, they aren't speaking directly to you) ");
}
AddFmt("%.*s%s said \"%.*s\" to %.*s\n", MD_S8VArg(speaking_to_you_helper), characters[it->context.author_npc_kind].name, TextChunkVArg(it->speech), MD_S8VArg(target_string));
}
}
// if I said this, or it's the last memory, flush the current list as a user node
if(it->context.i_said_this || it == e->memories_last)
{
if(it == e->memories_last && e->errorlist_first)
{
AddFmt("Errors you made: \n");
for(TextChunkList *cur = e->errorlist_first; cur; cur = cur->next)
{
AddFmt("%.*s\n", TextChunkVArg(cur->text));
}
}
if(current_list.node_count > 0)
AddNewNode(MSG_USER);
}
if(it->context.i_said_this)
{
MD_String8List current_list = {0}; // shadow the list of human understandable sentences to quickly flush
AddFmt("{");
AddFmt("\"speech\":\"%.*s\",", TextChunkVArg(it->speech));
AddFmt("\"action\":\"%s\",", actions[it->action_taken].name);
AddFmt("\"action_argument\":\"%.*s\",", MD_S8VArg(action_argument_string(it->action_argument)));
AddFmt("\"target\":\"%s\"}", characters[it->context.talking_to_kind].name);
AddNewNode(MSG_ASSISTANT);
}
}
MD_String8 with_trailing_comma = MD_S8ListJoin(scratch.arena, list, &(MD_StringJoin){MD_S8Lit(""),MD_S8Lit(""),MD_S8Lit(""),});

@ -261,6 +261,13 @@ func checkout(w http.ResponseWriter, req *http.Request) {
}
func completion(w http.ResponseWriter, req *http.Request) {
logStr := "" // print all the logs at once, to make it so the printed output as multiple completions are happening at once, is coherent
defer func() {
if len(logStr) > 0 {
log.Println(logStr)
}
}()
req.Body = http.MaxBytesReader(w, req.Body, 1024 * 1024) // no sending huge files to crash the server
if doCors {
w.Header().Set("Access-Control-Allow-Origin", "*")
@ -268,7 +275,7 @@ func completion(w http.ResponseWriter, req *http.Request) {
bodyBytes, err := io.ReadAll(req.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
log.Println("Bad error: ", err)
logStr += fmt.Sprintf("Bad error: ", err) + "\n"
return
} else {
bodyString := string(bodyBytes)
@ -276,7 +283,7 @@ func completion(w http.ResponseWriter, req *http.Request) {
if len(splitBody) != 2 {
w.WriteHeader(http.StatusBadRequest)
log.Printf("Weird body length %d not 2\n", len(splitBody))
logStr += fmt.Sprintf("Weird body length %d not 2\n", len(splitBody))
return
}
var promptString string = splitBody[1]
@ -286,7 +293,7 @@ func completion(w http.ResponseWriter, req *http.Request) {
rejected := false
cleanTimedOut()
if len(stripe.Key) == 0 {
log.Printf("Stripe capabilities are disabled, so automatically allowing completion")
logStr += fmt.Sprintf("Stripe capabilities are disabled, so automatically allowing completion")
} else {
if len(userToken) != 4 {
// where I do the IP rate limiting
@ -310,32 +317,32 @@ func completion(w http.ResponseWriter, req *http.Request) {
var thisUser User
thisUserCode, err := codes.ParseUserCode(userToken)
if err != nil {
log.Printf("Error: Failed to parse user token %s\n", userToken)
logStr += fmt.Sprintf("Error: Failed to parse user token %s\n", userToken)
rejected = true
} else {
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)
logStr += fmt.Sprintf("User code %d string %s couldn't be found in the database: %s\n", thisUserCode, userToken, err)
rejected = true
} else {
if isUserOld(thisUser) {
log.Println("User code " + userToken + " is old, not valid")
logStr += fmt.Sprintf("User code " + userToken + " is old, not valid") + "\n"
db.Delete(&thisUser)
rejected = true
} else {
// now have valid user, in the database, to be rate limit checked
// rate limiting based on user token
if !thisUser.IsFulfilled {
log.Println("Unfulfilled user trying to play, might've been unresponded to event. Retrieving backlog of unfulfilled events...\n")
logStr += fmt.Sprintf("Unfulfilled user trying to play, might've been unresponded to event. Retrieving backlog of unfulfilled events...\n") + "\n"
params := &stripe.EventListParams{}
params.Filters.AddFilter("delivery_success", "", "false")
i := event.List(params)
for i.Next() {
e := i.Event()
log.Println("Unfulfilled event! Of type %s. Handling...\n", e.Type)
logStr += fmt.Sprintf("Unfulfilled event! Of type %s. Handling...\n", e.Type) + "\n"
err := handleEvent(*e)
if err != nil {
log.Println("Failed to fulfill unfulfilled event: %s\n", err)
logStr += fmt.Sprintf("Failed to fulfill unfulfilled event: %s\n", err) + "\n"
}
}
}
@ -348,7 +355,7 @@ func completion(w http.ResponseWriter, req *http.Request) {
daypassTimedOut[thisUserCode] = currentTime()
}
} else {
log.Println("User with code and existing entry in database was not fulfilled, and wanted to play... Very bad. Usercode: %s\n", thisUserCode)
logStr += fmt.Sprintf("User with code and existing entry in database was not fulfilled, and wanted to play... Very bad. Usercode: %s\n", thisUserCode) + "\n"
}
}
}
@ -361,7 +368,7 @@ func completion(w http.ResponseWriter, req *http.Request) {
}
if logResponses {
log.Println("Println line prompt string: ", promptString)
logStr += fmt.Sprintf("Println line prompt string: ", promptString) + "\n"
}
ctx := context.Background()
@ -380,7 +387,7 @@ func completion(w http.ResponseWriter, req *http.Request) {
}
resp, err := c.CreateCompletion(ctx, req)
if err != nil {
log.Println("Error Failed to generate, failed to create completion: ", err)
logStr += fmt.Sprintf("Error Failed to generate, failed to create completion: ", err) + "\n"
w.WriteHeader(http.StatusInternalServerError)
return
}
@ -388,24 +395,24 @@ func completion(w http.ResponseWriter, req *http.Request) {
} else {
// parse the json walter
var parsed []ChatGPTElem
log.Printf("----------------------------------------------------------")
defer log.Printf("----------------------------------------------------------")
log.Printf("Parsing prompt string `%s`\n\n\n", promptString)
logStr += fmt.Sprintf("----------------------------------------------------------")
logStr += fmt.Sprintf("Parsing prompt string `%s`\n\n\n", promptString)
err = json.Unmarshal([]byte(promptString), &parsed)
if err != nil {
log.Println("Error bad json given for prompt: ", err)
logStr += fmt.Sprintf("Error bad json given for prompt: ", err) + "\n"
w.WriteHeader(http.StatusBadRequest)
return
}
messages := make([]openai.ChatCompletionMessage, 0)
for _, elem := range parsed {
log.Printf("Making message with role %s and Content `%s`...\n", elem.ElemType, elem.Content)
logStr += fmt.Sprintf("Making message with role %s and Content `%s`...\n", elem.ElemType, elem.Content)
messages = append(messages, openai.ChatCompletionMessage {
Role: elem.ElemType,
Content: elem.Content,
})
}
logStr += fmt.Sprintf("----------------------------------------------------------")
if false { // temporary testing AI
response = "ACT_holo \"Garbage\""
@ -420,19 +427,19 @@ func completion(w http.ResponseWriter, req *http.Request) {
},
)
if err != nil {
log.Println("Error Failed to generate: ", err)
logStr += fmt.Sprintf("Error Failed to generate: ", err) + "\n"
w.WriteHeader(http.StatusInternalServerError)
return
}
log.Printf("Full response: \n````\n%s\n````\n", resp)
logStr += fmt.Sprintf("Full response: \n````\n%s\n````\n", resp)
response = resp.Choices[0].Message.Content
}
/*
with_action := strings.SplitAfter(response, "ACT_")
if len(with_action) != 2 {
log.Printf("Could not find action in response string `%s`\n", response)
logStr += fmt.Sprintf("Could not find action in response string `%s`\n", response)
w.WriteHeader(http.StatusInternalServerError) // game should send a new retry request after this
return
}
@ -444,7 +451,7 @@ func completion(w http.ResponseWriter, req *http.Request) {
between_quotes := strings.Split(response, "\"")
// [action] " [stuff] " [anything extra]
if len(between_quotes) < 2 {
log.Printf("Could not find enough quotes in response string `%s`\n", response)
logStr += fmt.Sprintf("Could not find enough quotes in response string `%s`\n", response)
w.WriteHeader(http.StatusInternalServerError)
return
}
@ -452,8 +459,8 @@ func completion(w http.ResponseWriter, req *http.Request) {
*/
}
if logResponses {
log.Println("Println response: `", response + "`")
log.Println()
logStr += fmt.Sprintf("Println response: `", response + "`") + "\n"
logStr += "\n"
}
fmt.Fprintf(w, "1%s", response + "\n")
}

Loading…
Cancel
Save