ソースを参照

Merge flip_world from https://github.com/jblanked/FlipWorld

# Conflicts:
#	flip_world/callback/callback.c
Willy-JL 8 ヶ月 前
コミット
f56beab9d2

+ 0 - 30
flip_world/README.md

@@ -65,33 +65,3 @@ NPCs are friendly characters that players can interact with. Currently, you can
 4. Click `Settings -> WiFi`, then input your WiFi SSID and password.
 5. Hit the `BACK` button, click `User`. If your username is not present, click `Username` and add one. Do the same for the password field.
 6. Go back to the main menu and hit `Play`. It will register an account if necessary and fetch data from our API that's used to render our graphics.
-
-## Roadmap
-
-**v0.2**
-- Game Mechanics
-- Video Game Module support
-
-**v0.3**
-- Stability patch
-
-**v0.4**
-- New game features
-- Stability patch
-- World expansion
-
-**v0.5**
-- Stability patch
-- NPCs
-
-**v0.6**
-- New game features
-
-**v0.7**
-- New game features
-
-**v0.8**
-- Multiplayer support (PvP Beta)
-
-**v1.0**
-- Official release

+ 16 - 10
flip_world/app.c

@@ -1,5 +1,6 @@
 #include <alloc/alloc.h>
 #include <flip_storage/storage.h>
+#include <update/update.h>
 
 // Entry point for the FlipWorld application
 int32_t flip_world_main(void *p)
@@ -31,6 +32,8 @@ int32_t flip_world_main(void *p)
         return -1;
     }
 
+    save_char("app_version", VERSION);
+
     if (!flipper_http_send_command(fhttp, HTTP_CMD_PING))
     {
         FURI_LOG_E(TAG, "Failed to ping the device");
@@ -38,6 +41,8 @@ int32_t flip_world_main(void *p)
         return -1;
     }
 
+    furi_delay_ms(100);
+
     // Try to wait for pong response.
     uint32_t counter = 10;
     while (fhttp->state == INACTIVE && --counter > 0)
@@ -46,18 +51,19 @@ int32_t flip_world_main(void *p)
         furi_delay_ms(100);
     }
 
-    if (counter == 0)
+    // last response should be PONG
+    if (!fhttp->last_response || strcmp(fhttp->last_response, "[PONG]") != 0)
+    {
         easy_flipper_dialog("FlipperHTTP Error", "Ensure your WiFi Developer\nBoard or Pico W is connected\nand the latest FlipperHTTP\nfirmware is installed.");
-
-    // save app version
-    // char app_version[16];
-    // snprintf(app_version, sizeof(app_version), "%f", (double)VERSION);
-    save_char("app_version", VERSION);
-
-    // for now use the catalog API until I implement caching on the server
-    if (flip_world_handle_app_update(fhttp, true))
+        FURI_LOG_E(TAG, "Failed to receive PONG");
+    }
+    else
     {
-        easy_flipper_dialog("Update Status", "Complete.\nRestart your Flipper Zero.");
+        // for now use the catalog API until I implement caching on the server
+        if (update_is_ready(fhttp, true))
+        {
+            easy_flipper_dialog("Update Status", "Complete.\nRestart your Flipper Zero.");
+        }
     }
 
     flipper_http_free(fhttp);

+ 1 - 1
flip_world/application.fam

@@ -17,5 +17,5 @@ App(
     ),
     fap_author="JBlanked",
     fap_weburl="https://github.com/jblanked/FlipWorld",
-    fap_version="0.8.2",
+    fap_version="0.8.3",
 )

BIN
flip_world/assets/01-home.png


+ 10 - 0
flip_world/assets/CHANGELOG.md

@@ -1,3 +1,13 @@
+## 0.8.3 (2025-04-24)
+- Added PVE multiplayer (up to 5 users per game).
+- Added a level requirement to play multiplayer.
+- Updated to the latest auto-update library.
+- Fixed world transitions.
+- Updated game ending responses.
+
+## 0.8.2 (2025-04-19)
+- Fixed an out-of-memory crash.
+
 ## 0.8.1 (2025-04-09)
 - Improved memory allocation, saving 26 KB.
 - Added auto-updating (currently uses the catalog API).

+ 0 - 30
flip_world/assets/README.md

@@ -64,33 +64,3 @@ NPCs are friendly characters that players can interact with. Currently, you can
 4. Click "Settings -> WiFi", then input your WiFi SSID and password.
 5. Hit the "BACK" button, click "User". If your username is not present, click "Username" and add one. Do the same for the password field.
 6. Go back to the main menu and hit "Play", followed by "Tutorial". It will register an account if necessary and fetch data from our API that's used to render our graphics.
-
-## Roadmap
-
-**v0.2**
-- Game Mechanics
-- Video Game Module support
-
-**v0.3**
-- Stability patch
-
-**v0.4**
-- New game features
-- Stability patch
-- World expansion
-
-**v0.5**
-- Stability patch
-- NPCs
-
-**v0.6**
-- New game features
-
-**v0.7**
-- New game features
-
-**v0.8**
-- Multiplayer support (PvP Beta)
-
-**v1.0**
-- Official release

+ 2 - 2
flip_world/callback/alloc.c

@@ -390,9 +390,9 @@ bool alloc_game_submenu(void *context)
         {
             return false;
         }
-        submenu_add_item(app->submenu_game, "Tutorial", FlipWorldSubmenuIndexStory, callback_submenu_choices, app);
-        submenu_add_item(app->submenu_game, "PvP (Beta)", FlipWorldSubmenuIndexPvP, callback_submenu_choices, app);
+        submenu_add_item(app->submenu_game, "Story", FlipWorldSubmenuIndexStory, callback_submenu_choices, app);
         submenu_add_item(app->submenu_game, "PvE", FlipWorldSubmenuIndexPvE, callback_submenu_choices, app);
+        submenu_add_item(app->submenu_game, "PvP", FlipWorldSubmenuIndexPvP, callback_submenu_choices, app);
     }
     return true;
 }

+ 61 - 34
flip_world/callback/callback.c

@@ -417,7 +417,7 @@ static bool _fetch_worlds(DataLoaderModel* model) {
     return flipper_http_request(
         model->fhttp,
         GET,
-        "https://www.jblanked.com/flipper/api/world/v5/get/10/",
+        "https://www.jblanked.com/flipper/api/world/v8/get/10/",
         "{\"Content-Type\":\"application/json\"}",
         NULL);
 }
@@ -484,11 +484,11 @@ void callback_user_settings_select(void* context, uint32_t index) {
     }
 }
 
-void callback_submenu_lobby_choices(void* context, uint32_t index) {
+void callback_submenu_lobby_pvp_choices(void* context, uint32_t index) {
     /* Handle other game lobbies
              1. when clicked on, send request to fetch the selected game lobby details
              2. start the websocket session
-             3. start the game thread (the rest will be handled by game_start and player_update)
+             3. start the game thread (the rest will be handled by game_start_game and player_update)
              */
     FlipWorldApp* app = (FlipWorldApp*)context;
     furi_check(app, "FlipWorldApp is NULL");
@@ -522,34 +522,69 @@ void callback_submenu_lobby_choices(void* context, uint32_t index) {
         // if there is one player and it's the user, make the user wait until another player joins
         // if there is one player and it's not the user, parse_lobby and start websocket
         // if there are 2 players (which there shouldn't be at this point), show an error message saying the lobby is full
-        switch(game_lobby_count(fhttp, lobby)) {
-        case -1:
-            FURI_LOG_E(TAG, "Failed to get player count");
-            easy_flipper_dialog("Error", "Failed to get player count. Press BACK to return.");
-            flipper_http_free(fhttp);
-            furi_string_free(lobby);
-            return;
-        case 0:
-            // add the user to the lobby
-            if(!game_join_lobby(fhttp, lobby_list[lobby_index])) {
-                FURI_LOG_E(TAG, "Failed to join lobby");
-                easy_flipper_dialog("Error", "Failed to join lobby. Press BACK to return.");
+        if(game_mode_index == 1) // pvp
+        {
+            switch(game_lobby_count(fhttp, lobby)) {
+            case -1:
+                FURI_LOG_E(TAG, "Failed to get player count");
+                easy_flipper_dialog("Error", "Failed to get player count. Press BACK to return.");
                 flipper_http_free(fhttp);
                 furi_string_free(lobby);
                 return;
-            }
-            // send the user to the waiting screen
-            game_waiting_lobby(app);
-            return;
-        case 1:
-            // check if the user is in the lobby
-            if(game_in_lobby(fhttp, lobby)) {
+            case 0:
+                // add the user to the lobby
+                if(!game_join_lobby(fhttp, lobby_list[lobby_index])) {
+                    FURI_LOG_E(TAG, "Failed to join lobby");
+                    easy_flipper_dialog("Error", "Failed to join lobby. Press BACK to return.");
+                    flipper_http_free(fhttp);
+                    furi_string_free(lobby);
+                    return;
+                }
                 // send the user to the waiting screen
-                FURI_LOG_I(TAG, "User is in the lobby");
+                game_waiting_lobby(app);
+                return;
+            case 1:
+                // check if the user is in the lobby
+                if(game_in_lobby(fhttp, lobby)) {
+                    if(!game_remove_from_lobby(fhttp)) {
+                        FURI_LOG_I(TAG, "User is in the lobby but failed to remove");
+                        easy_flipper_dialog(
+                            "Error",
+                            "You're already in the lobby.\nContact JBlanked.\n\nPress BACK to return.");
+                        flipper_http_free(fhttp);
+                        furi_string_free(lobby);
+                        return;
+                    }
+                }
+                // add the user to the lobby
+                if(!game_join_lobby(fhttp, lobby_list[lobby_index])) {
+                    FURI_LOG_E(TAG, "Failed to join lobby");
+                    easy_flipper_dialog("Error", "Failed to join lobby. Press BACK to return.");
+                    flipper_http_free(fhttp);
+                    furi_string_free(lobby);
+                    return;
+                }
+                break;
+            case 2:
+                // show an error message saying the lobby is full
+                FURI_LOG_E(TAG, "Lobby is full");
+                easy_flipper_dialog("Error", "Lobby is full. Press BACK to return.");
                 flipper_http_free(fhttp);
                 furi_string_free(lobby);
-                game_waiting_lobby(app);
                 return;
+            };
+        } else {
+            // check if the user is in the lobby
+            if(game_in_lobby(fhttp, lobby)) {
+                if(!game_remove_from_lobby(fhttp)) {
+                    FURI_LOG_I(TAG, "User is in the lobby but failed to remove");
+                    easy_flipper_dialog(
+                        "Error",
+                        "You're already in the lobby.\nContact JBlanked.\n\nPress BACK to return.");
+                    flipper_http_free(fhttp);
+                    furi_string_free(lobby);
+                    return;
+                }
             }
             // add the user to the lobby
             if(!game_join_lobby(fhttp, lobby_list[lobby_index])) {
@@ -559,17 +594,9 @@ void callback_submenu_lobby_choices(void* context, uint32_t index) {
                 furi_string_free(lobby);
                 return;
             }
-            break;
-        case 2:
-            // show an error message saying the lobby is full
-            FURI_LOG_E(TAG, "Lobby is full");
-            easy_flipper_dialog("Error", "Lobby is full. Press BACK to return.");
-            flipper_http_free(fhttp);
-            furi_string_free(lobby);
-            return;
-        };
+        }
 
-        game_start_pvp(
+        game_start_game(
             fhttp, lobby, app); // this will free both the fhttp and lobby, and start the game
     }
 }

+ 1 - 1
flip_world/callback/callback.h

@@ -18,4 +18,4 @@ void callback_vibration_on_change(VariableItem *item);
 void callback_player_on_change(VariableItem *item);
 void callback_vgm_x_change(VariableItem *item);
 void callback_vgm_y_change(VariableItem *item);
-void callback_submenu_lobby_choices(void *context, uint32_t index);
+void callback_submenu_lobby_pvp_choices(void *context, uint32_t index);

+ 208 - 125
flip_world/callback/game.c

@@ -18,6 +18,7 @@
 bool user_hit_back = false;
 uint32_t lobby_index = -1;
 char *lobby_list[10];
+char game_ws_lobby_name[64];
 
 static uint8_t timer_iteration = 0; // timer iteration for the loading screen
 static uint8_t timer_refresh = 5;   // duration for timer to refresh
@@ -125,7 +126,7 @@ static int32_t game_waiting_app_callback(void *p)
     }
     // if we reach here, it means we timed out or the user hit back
     FURI_LOG_E(TAG, "No players joined within the timeout or user hit back");
-    remove_player_from_lobby(fhttp);
+    game_remove_from_lobby(fhttp);
     flipper_http_free(fhttp);
     view_dispatcher_switch_to_view(app->view_dispatcher, FlipWorldViewSubmenuOther);
     return 0;
@@ -181,7 +182,7 @@ static bool game_fetch_world_list(FlipperHTTP *fhttp)
     snprintf(fhttp->file_path, sizeof(fhttp->file_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world/worlds/world_list.json");
 
     fhttp->save_received_data = true;
-    return flipper_http_request(fhttp, GET, "https://www.jblanked.com/flipper/api/world/v5/list/10/", "{\"Content-Type\":\"application/json\"}", NULL);
+    return flipper_http_request(fhttp, GET, "https://www.jblanked.com/flipper/api/world/v8/list/10/", "{\"Content-Type\":\"application/json\"}", NULL);
 }
 // we will load the palyer stats from the API and save them
 // in player_spawn game method, it will load the player stats that we saved
@@ -386,7 +387,7 @@ static bool game_fetch(DataLoaderModel *model)
 
         model->fhttp->save_received_data = true;
         char url[128];
-        snprintf(url, sizeof(url), "https://www.jblanked.com/flipper/api/world/v5/get/world/%s/", furi_string_get_cstr(first_world));
+        snprintf(url, sizeof(url), "https://www.jblanked.com/flipper/api/world/v8/get/world/%s/", furi_string_get_cstr(first_world));
         furi_string_free(world_list);
         furi_string_free(first_world);
         return flipper_http_request(model->fhttp, GET, url, "{\"Content-Type\":\"application/json\"}", NULL);
@@ -555,6 +556,134 @@ static void game_switch_to_view(FlipWorldApp *app)
     }
     loader_switch_to_view(app, "Starting Game..", game_fetch, game_parse, 5, callback_to_submenu, FlipWorldViewLoader);
 }
+bool game_start_ws(FlipperHTTP *fhttp, char *lobby_name)
+{
+    if (!fhttp)
+    {
+        FURI_LOG_E(TAG, "FlipperHTTP is NULL");
+        return false;
+    }
+    if (!lobby_name || strlen(lobby_name) == 0)
+    {
+        FURI_LOG_E(TAG, "Lobby name is NULL or empty");
+        return false;
+    }
+    fhttp->state = IDLE; // ensure it's set to IDLE for the next request
+    char websocket_url[128];
+    snprintf(websocket_url, sizeof(websocket_url), "ws://www.jblanked.com/ws/game/%s/", lobby_name);
+    if (!flipper_http_websocket_start(fhttp, websocket_url, 80, "{\"Content-Type\":\"application/json\"}"))
+    {
+        FURI_LOG_E(TAG, "Failed to start websocket");
+        return false;
+    }
+    fhttp->state = RECEIVING;
+    while (fhttp->state != IDLE)
+    {
+        furi_delay_ms(100);
+    }
+    return true;
+}
+// load pvp info (this returns the lobbies available)
+static bool game_fetch_lobbies(FlipperHTTP *fhttp, uint8_t max_players, uint8_t max_lobbies)
+{
+    if (!fhttp)
+    {
+        FURI_LOG_E(TAG, "FlipperHTTP is NULL");
+        return false;
+    }
+    char lobby_type[4];
+    snprintf(lobby_type, sizeof(lobby_type), game_mode_index == 0 ? "pve" : "pvp");
+    // ensure flip_world directory exists
+    char directory_path[128];
+    snprintf(directory_path, sizeof(directory_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world");
+    Storage *storage = furi_record_open(RECORD_STORAGE);
+    storage_common_mkdir(storage, directory_path);
+    snprintf(directory_path, sizeof(directory_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world/%s", lobby_type);
+    storage_common_mkdir(storage, directory_path);
+    snprintf(directory_path, sizeof(directory_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world/%s/lobbies", lobby_type);
+    storage_common_mkdir(storage, directory_path);
+    snprintf(fhttp->file_path, sizeof(fhttp->file_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world/%s/%s_lobbies.json", lobby_type, lobby_type);
+    storage_simply_remove_recursive(storage, fhttp->file_path); // ensure the file is empty
+    furi_record_close(RECORD_STORAGE);
+    //
+    char url[128];
+    snprintf(url, sizeof(url), "https://www.jblanked.com/flipper/api/world/%s/lobbies/%d/%d/", lobby_type, max_players, max_lobbies);
+    fhttp->save_received_data = true;
+    return flipper_http_request(fhttp, GET, url, "{\"Content-Type\":\"application/json\"}", NULL);
+}
+
+static bool game_parse_lobbies(FlipperHTTP *fhttp, uint8_t max_lobbies, void *context)
+{
+    if (!fhttp)
+    {
+        FURI_LOG_E(TAG, "FlipperHTTP is NULL");
+        return false;
+    }
+    FlipWorldApp *app = (FlipWorldApp *)context;
+    furi_check(app);
+    // add the lobbies to the submenu
+    FuriString *lobbies = flipper_http_load_from_file(fhttp->file_path);
+    if (!lobbies)
+    {
+        FURI_LOG_E(TAG, "Failed to load lobbies");
+        return false;
+    }
+
+    // parse the lobbies
+    for (uint32_t i = 0; i < max_lobbies; i++)
+    {
+        FuriString *lobby = get_json_array_value_furi("lobbies", i, lobbies);
+        if (!lobby)
+        {
+            FURI_LOG_I(TAG, "No more lobbies");
+            break;
+        }
+        FuriString *lobby_id = get_json_value_furi("id", lobby);
+        if (!lobby_id)
+        {
+            FURI_LOG_E(TAG, "Failed to get lobby id");
+            furi_string_free(lobby);
+            return false;
+        }
+        // add the lobby to the submenu
+        submenu_add_item(app->submenu_other, furi_string_get_cstr(lobby_id), FlipWorldSubmenuIndexLobby + i, callback_submenu_lobby_pvp_choices, app);
+        // add the lobby to the list
+        if (!easy_flipper_set_buffer(&lobby_list[i], 64))
+        {
+            FURI_LOG_E(TAG, "Failed to allocate lobby list");
+            furi_string_free(lobby);
+            furi_string_free(lobby_id);
+            return false;
+        }
+        snprintf(lobby_list[i], 64, "%s", furi_string_get_cstr(lobby_id));
+        furi_string_free(lobby);
+        furi_string_free(lobby_id);
+    }
+    furi_string_free(lobbies);
+    return true;
+}
+
+static bool game_lobbies(FlipperHTTP *fhttp, uint8_t max_players, uint8_t max_lobbies, void *context)
+{
+    if (!fhttp || !context)
+    {
+        FURI_LOG_E(TAG, "FlipperHTTP or context is NULL");
+        return false;
+    }
+    if (!game_fetch_lobbies(fhttp, max_players, max_lobbies))
+    {
+        FURI_LOG_E(TAG, "Failed to fetch lobbies");
+        return false;
+    }
+    fhttp->state = RECEIVING;
+    while (fhttp->state != IDLE)
+    {
+        // Wait for the request to be received
+        furi_delay_ms(100);
+    }
+    return game_parse_lobbies(fhttp, max_lobbies, context);
+}
+
 void game_run(FlipWorldApp *app)
 {
     furi_check(app, "FlipWorldApp is NULL");
@@ -600,17 +729,15 @@ void game_run(FlipWorldApp *app)
         view_dispatcher_switch_to_view(app->view_dispatcher, FlipWorldViewMessage);
 
         // Make the request
-        if (game_mode_index != 1) // not GAME_MODE_PVP
+        if (game_mode_index == 2) // GAME_MODE_STORY
         {
             if (!flipper_http_process_response_async(fhttp, game_fetch_world_list_i, parse_world_list_i) || !flipper_http_process_response_async(fhttp, game_fetch_player_stats_i, set_player_context))
             {
                 FURI_LOG_E(HTTP_TAG, "Failed to make request");
                 flipper_http_free(fhttp);
             }
-            else
-            {
-                flipper_http_free(fhttp);
-            }
+
+            flipper_http_free(fhttp);
 
             if (!game_thread_start(app))
             {
@@ -621,96 +748,34 @@ void game_run(FlipWorldApp *app)
         }
         else
         {
-            // load pvp info (this returns the lobbies available)
-            bool fetch_pvp_lobbies()
+            free_submenu_other(app);
+            if (!alloc_submenu_other(app, FlipWorldViewLobby))
             {
-                // ensure flip_world directory exists
-                char directory_path[128];
-                snprintf(directory_path, sizeof(directory_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world");
-                Storage *storage = furi_record_open(RECORD_STORAGE);
-                storage_common_mkdir(storage, directory_path);
-                snprintf(directory_path, sizeof(directory_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world/pvp");
-                storage_common_mkdir(storage, directory_path);
-                snprintf(directory_path, sizeof(directory_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world/pvp/lobbies");
-                storage_common_mkdir(storage, directory_path);
-                snprintf(fhttp->file_path, sizeof(fhttp->file_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world/pvp/pvp_lobbies.json");
-                storage_simply_remove_recursive(storage, fhttp->file_path); // ensure the file is empty
-                furi_record_close(RECORD_STORAGE);
-                fhttp->save_received_data = true;
-                // 2 players max, 10 lobbies
-                return flipper_http_request(fhttp, GET, "https://www.jblanked.com/flipper/api/world/pvp/lobbies/2/10/", "{\"Content-Type\":\"application/json\"}", NULL);
-            }
-
-            bool parse_pvp_lobbies()
-            {
-
-                free_submenu_other(app);
-                if (!alloc_submenu_other(app, FlipWorldViewLobby))
-                {
-                    FURI_LOG_E(TAG, "Failed to allocate lobby submenu");
-                    return false;
-                }
-
-                // add the lobbies to the submenu
-                FuriString *lobbies = flipper_http_load_from_file(fhttp->file_path);
-                if (!lobbies)
-                {
-                    FURI_LOG_E(TAG, "Failed to load lobbies");
-                    return false;
-                }
-
-                // parse the lobbies
-                for (uint32_t i = 0; i < 10; i++)
-                {
-                    FuriString *lobby = get_json_array_value_furi("lobbies", i, lobbies);
-                    if (!lobby)
-                    {
-                        FURI_LOG_I(TAG, "No more lobbies");
-                        break;
-                    }
-                    FuriString *lobby_id = get_json_value_furi("id", lobby);
-                    if (!lobby_id)
-                    {
-                        FURI_LOG_E(TAG, "Failed to get lobby id");
-                        furi_string_free(lobby);
-                        return false;
-                    }
-                    // add the lobby to the submenu
-                    submenu_add_item(app->submenu_other, furi_string_get_cstr(lobby_id), FlipWorldSubmenuIndexLobby + i, callback_submenu_lobby_choices, app);
-                    // add the lobby to the list
-                    if (!easy_flipper_set_buffer(&lobby_list[i], 64))
-                    {
-                        FURI_LOG_E(TAG, "Failed to allocate lobby list");
-                        furi_string_free(lobby);
-                        furi_string_free(lobby_id);
-                        return false;
-                    }
-                    snprintf(lobby_list[i], 64, "%s", furi_string_get_cstr(lobby_id));
-                    furi_string_free(lobby);
-                    furi_string_free(lobby_id);
-                }
-                furi_string_free(lobbies);
-                return true;
+                FURI_LOG_E(TAG, "Failed to allocate lobby submenu");
+                return;
             }
 
             // load pvp lobbies and player stats
-            if (!flipper_http_process_response_async(fhttp, fetch_pvp_lobbies, parse_pvp_lobbies) || !flipper_http_process_response_async(fhttp, game_fetch_player_stats_i, set_player_context))
+            if (!game_lobbies(
+                    fhttp,
+                    game_mode_index == 1 ? 2 : MAX_ENEMIES,
+                    10,
+                    app))
             {
                 // unlike the pve/story, receiving data is necessary
                 // so send the user back to the main menu if it fails
                 FURI_LOG_E(HTTP_TAG, "Failed to make request");
                 easy_flipper_dialog("Error", "Failed to make request. Press BACK to return.");
-                view_dispatcher_switch_to_view(app->view_dispatcher,
-                                               FlipWorldViewSubmenu);
+                view_dispatcher_switch_to_view(app->view_dispatcher, FlipWorldViewSubmenu);
                 flipper_http_free(fhttp);
             }
             else
             {
                 flipper_http_free(fhttp);
-            }
 
-            // switch to the lobby submenu
-            view_dispatcher_switch_to_view(app->view_dispatcher, FlipWorldViewSubmenuOther);
+                // switch to the lobby submenu
+                view_dispatcher_switch_to_view(app->view_dispatcher, FlipWorldViewSubmenuOther);
+            }
         }
     }
     else
@@ -719,7 +784,7 @@ void game_run(FlipWorldApp *app)
     }
 }
 
-bool game_fetch_lobby(FlipperHTTP *fhttp, char *lobby_name)
+bool game_fetch_lobby(FlipperHTTP *fhttp, const char *lobby_name)
 {
     if (!fhttp)
     {
@@ -731,6 +796,8 @@ bool game_fetch_lobby(FlipperHTTP *fhttp, char *lobby_name)
         FURI_LOG_E(TAG, "Lobby name is NULL or empty");
         return false;
     }
+    char lobby_type[4];
+    snprintf(lobby_type, sizeof(lobby_type), game_mode_index == 0 ? "pve" : "pvp");
     char username[64];
     if (!load_char("Flip-Social-Username", username, sizeof(username)))
     {
@@ -739,7 +806,7 @@ bool game_fetch_lobby(FlipperHTTP *fhttp, char *lobby_name)
     }
     // send the request to fetch the lobby details, with player_username
     char url[128];
-    snprintf(url, sizeof(url), "https://www.jblanked.com/flipper/api/world/pvp/lobby/get/%s/%s/", lobby_name, username);
+    snprintf(url, sizeof(url), "https://www.jblanked.com/flipper/api/world/%s/lobby/get/%s/%s/", lobby_type, lobby_name, username);
     snprintf(fhttp->file_path, sizeof(fhttp->file_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world/pvp/lobbies/%s.json", lobby_name);
     fhttp->save_received_data = true;
     if (!flipper_http_request(fhttp, GET, url, "{\"Content-Type\":\"application/json\"}", NULL))
@@ -754,7 +821,7 @@ bool game_fetch_lobby(FlipperHTTP *fhttp, char *lobby_name)
     }
     return true;
 }
-bool game_join_lobby(FlipperHTTP *fhttp, char *lobby_name)
+bool game_join_lobby(FlipperHTTP *fhttp, const char *lobby_name)
 {
     if (!fhttp)
     {
@@ -766,6 +833,8 @@ bool game_join_lobby(FlipperHTTP *fhttp, char *lobby_name)
         FURI_LOG_E(TAG, "Lobby name is NULL or empty");
         return false;
     }
+    char lobby_type[4];
+    snprintf(lobby_type, sizeof(lobby_type), game_mode_index == 0 ? "pve" : "pvp");
     char username[64];
     if (!load_char("Flip-Social-Username", username, sizeof(username)))
     {
@@ -776,7 +845,7 @@ bool game_join_lobby(FlipperHTTP *fhttp, char *lobby_name)
     char payload[128];
     snprintf(payload, sizeof(payload), "{\"username\":\"%s\", \"game_id\":\"%s\"}", username, lobby_name);
     save_char("pvp_lobby_name", lobby_name); // save the lobby name
-    snprintf(url, sizeof(url), "https://www.jblanked.com/flipper/api/world/pvp/lobby/join/");
+    snprintf(url, sizeof(url), "https://www.jblanked.com/flipper/api/world/%s/lobby/join/", lobby_type);
     if (!flipper_http_request(fhttp, POST, url, "{\"Content-Type\":\"application/json\"}", payload))
     {
         FURI_LOG_E(TAG, "Failed to join lobby");
@@ -966,52 +1035,31 @@ bool game_in_lobby(FlipperHTTP *fhttp, FuriString *lobby)
     return in_game;
 }
 
-static bool game_start_ws(FlipperHTTP *fhttp, char *lobby_name)
-{
-    if (!fhttp)
-    {
-        FURI_LOG_E(TAG, "FlipperHTTP is NULL");
-        return false;
-    }
-    if (!lobby_name || strlen(lobby_name) == 0)
-    {
-        FURI_LOG_E(TAG, "Lobby name is NULL or empty");
-        return false;
-    }
-    fhttp->state = IDLE; // ensure it's set to IDLE for the next request
-    char websocket_url[128];
-    snprintf(websocket_url, sizeof(websocket_url), "ws://www.jblanked.com/ws/game/%s/", lobby_name);
-    if (!flipper_http_websocket_start(fhttp, websocket_url, 80, "{\"Content-Type\":\"application/json\"}"))
-    {
-        FURI_LOG_E(TAG, "Failed to start websocket");
-        return false;
-    }
-    fhttp->state = RECEIVING;
-    while (fhttp->state != IDLE)
-    {
-        furi_delay_ms(100);
-    }
-    return true;
-}
 // this will free both the fhttp and lobby
-void game_start_pvp(FlipperHTTP *fhttp, FuriString *lobby, void *context)
+void game_start_game(FlipperHTTP *fhttp, FuriString *lobby, void *context)
 {
     FlipWorldApp *app = (FlipWorldApp *)context;
     furi_check(app, "FlipWorldApp is NULL");
-    // only thing left to do is create the enemy data and start the websocket session
-    if (!game_create_pvp_enemy(lobby))
+
+    if (game_mode_index == 1) // pvp (in pve, we will apennd the enemies as they are read from the websocket)
     {
-        FURI_LOG_E(TAG, "Failed to create pvp enemy context.");
-        easy_flipper_dialog("Error", "Failed to create pvp enemy context. Press BACK to return.");
-        flipper_http_free(fhttp);
-        furi_string_free(lobby);
-        return;
+        // only thing left to do is create the enemy data and start the websocket session
+        if (!game_create_pvp_enemy(lobby))
+        {
+            FURI_LOG_E(TAG, "Failed to create pvp enemy context.");
+            easy_flipper_dialog("Error", "Failed to create pvp enemy context. Press BACK to return.");
+            flipper_http_free(fhttp);
+            furi_string_free(lobby);
+            return;
+        }
     }
-
     furi_string_free(lobby);
 
+    // used later in PVE mode if needed to fetch worlds
+    snprintf(game_ws_lobby_name, sizeof(game_ws_lobby_name), "%s", lobby_list[lobby_index]);
+
     // start the websocket session
-    if (!game_start_ws(fhttp, lobby_list[lobby_index]))
+    if (!game_start_ws(fhttp, game_ws_lobby_name))
     {
         FURI_LOG_E(TAG, "Failed to start websocket session");
         easy_flipper_dialog("Error", "Failed to start websocket session. Press BACK to return.");
@@ -1064,7 +1112,7 @@ void game_waiting_process(FlipperHTTP *fhttp, void *context)
     if (count == 2)
     {
         // break out of this and start the game
-        game_start_pvp(fhttp, lobby, app); // this will free both the fhttp and lobby
+        game_start_game(fhttp, lobby, app); // this will free both the fhttp and lobby
         return;
     }
     furi_string_free(lobby);
@@ -1074,12 +1122,14 @@ void game_waiting_lobby(void *context)
 {
     FlipWorldApp *app = (FlipWorldApp *)context;
     furi_check(app, "waiting_lobby: FlipWorldApp is NULL");
+
     if (!game_start_waiting_thread(app))
     {
         FURI_LOG_E(TAG, "Failed to start waiting thread");
         easy_flipper_dialog("Error", "Failed to start waiting thread. Press BACK to return.");
         return;
     }
+
     free_message_view(app);
     if (!alloc_message_view(app, MessageStateWaitingLobby))
     {
@@ -1089,3 +1139,36 @@ void game_waiting_lobby(void *context)
     // finally, switch to the waiting lobby view
     view_dispatcher_switch_to_view(app->view_dispatcher, FlipWorldViewMessage);
 };
+
+bool game_remove_from_lobby(FlipperHTTP *fhttp)
+{
+    if (!fhttp)
+    {
+        FURI_LOG_E(TAG, "FlipperHTTP is NULL");
+        return false;
+    }
+    char username[32];
+    if (!load_char("Flip-Social-Username", username, sizeof(username)))
+    {
+        FURI_LOG_E(TAG, "Failed to load data/Flip-Social-Username");
+        return false;
+    }
+    char lobby_type[4];
+    snprintf(lobby_type, sizeof(lobby_type), game_mode_index == 0 ? "pve" : "pvp");
+    char url[128];
+    char payload[128];
+    snprintf(payload, sizeof(payload), "{\"username\":\"%s\", \"game_id\":\"%s\"}", username, game_ws_lobby_name);
+    snprintf(url, sizeof(url), "https://www.jblanked.com/flipper/api/world/%s/lobby/remove/", lobby_type);
+    fhttp->state = IDLE;
+    if (!flipper_http_request(fhttp, POST, url, "{\"Content-Type\":\"application/json\"}", payload))
+    {
+        FURI_LOG_E(TAG, "Failed to remove player from lobby");
+        return false;
+    }
+    fhttp->state = RECEIVING;
+    while (fhttp->state != IDLE)
+    {
+        furi_delay_ms(100);
+    }
+    return true;
+}

+ 7 - 4
flip_world/callback/game.h

@@ -3,16 +3,19 @@
 extern bool user_hit_back;
 extern uint32_t lobby_index;
 extern char *lobby_list[10];
+extern char game_ws_lobby_name[64];
 extern FuriThread *game_thread;
 extern FuriThread *waiting_thread;
 extern bool game_thread_running;
 extern bool waiting_thread_running;
 //
+bool game_start_ws(FlipperHTTP *fhttp, char *lobby_name);
 void game_run(FlipWorldApp *app);
-bool game_fetch_lobby(FlipperHTTP *fhttp, char *lobby_name);
-bool game_join_lobby(FlipperHTTP *fhttp, char *lobby_name);
+bool game_fetch_lobby(FlipperHTTP *fhttp, const char *lobby_name);
+bool game_join_lobby(FlipperHTTP *fhttp, const char *lobby_name);
 size_t game_lobby_count(FlipperHTTP *fhttp, FuriString *lobby);
 bool game_in_lobby(FlipperHTTP *fhttp, FuriString *lobby);
-void game_start_pvp(FlipperHTTP *fhttp, FuriString *lobby, void *context);
+void game_start_game(FlipperHTTP *fhttp, FuriString *lobby, void *context);
 void game_waiting_lobby(void *context);
-void game_waiting_process(FlipperHTTP *fhttp, void *context);
+void game_waiting_process(FlipperHTTP *fhttp, void *context);
+bool game_remove_from_lobby(FlipperHTTP *fhttp);

+ 0 - 450
flip_world/flip_world.c

@@ -35,453 +35,3 @@ bool is_enough_heap(size_t heap_size, bool check_blocks)
     }
     return true;
 }
-
-static bool flip_world_json_to_datetime(DateTime *rtc_time, FuriString *str)
-{
-    if (!rtc_time || !str)
-    {
-        FURI_LOG_E(TAG, "rtc_time or str is NULL");
-        return false;
-    }
-    FuriString *hour = get_json_value_furi("hour", str);
-    if (hour)
-    {
-        rtc_time->hour = atoi(furi_string_get_cstr(hour));
-        furi_string_free(hour);
-    }
-    FuriString *minute = get_json_value_furi("minute", str);
-    if (minute)
-    {
-        rtc_time->minute = atoi(furi_string_get_cstr(minute));
-        furi_string_free(minute);
-    }
-    FuriString *second = get_json_value_furi("second", str);
-    if (second)
-    {
-        rtc_time->second = atoi(furi_string_get_cstr(second));
-        furi_string_free(second);
-    }
-    FuriString *day = get_json_value_furi("day", str);
-    if (day)
-    {
-        rtc_time->day = atoi(furi_string_get_cstr(day));
-        furi_string_free(day);
-    }
-    FuriString *month = get_json_value_furi("month", str);
-    if (month)
-    {
-        rtc_time->month = atoi(furi_string_get_cstr(month));
-        furi_string_free(month);
-    }
-    FuriString *year = get_json_value_furi("year", str);
-    if (year)
-    {
-        rtc_time->year = atoi(furi_string_get_cstr(year));
-        furi_string_free(year);
-    }
-    FuriString *weekday = get_json_value_furi("weekday", str);
-    if (weekday)
-    {
-        rtc_time->weekday = atoi(furi_string_get_cstr(weekday));
-        furi_string_free(weekday);
-    }
-    return datetime_validate_datetime(rtc_time);
-}
-
-static FuriString *flip_world_datetime_to_json(DateTime *rtc_time)
-{
-    if (!rtc_time)
-    {
-        FURI_LOG_E(TAG, "rtc_time is NULL");
-        return NULL;
-    }
-    char json[256];
-    snprintf(
-        json,
-        sizeof(json),
-        "{\"hour\":%d,\"minute\":%d,\"second\":%d,\"day\":%d,\"month\":%d,\"year\":%d,\"weekday\":%d}",
-        rtc_time->hour,
-        rtc_time->minute,
-        rtc_time->second,
-        rtc_time->day,
-        rtc_time->month,
-        rtc_time->year,
-        rtc_time->weekday);
-    return furi_string_alloc_set_str(json);
-}
-
-static bool flip_world_save_rtc_time(DateTime *rtc_time)
-{
-    if (!rtc_time)
-    {
-        FURI_LOG_E(TAG, "rtc_time is NULL");
-        return false;
-    }
-    FuriString *json = flip_world_datetime_to_json(rtc_time);
-    if (!json)
-    {
-        FURI_LOG_E(TAG, "Failed to convert DateTime to JSON");
-        return false;
-    }
-    save_char("last_checked", furi_string_get_cstr(json));
-    furi_string_free(json);
-    return true;
-}
-
-//
-// Returns true if time_current is one hour (or more) later than the stored last_checked time
-//
-static bool flip_world_is_update_time(DateTime *time_current)
-{
-    if (!time_current)
-    {
-        FURI_LOG_E(TAG, "time_current is NULL");
-        return false;
-    }
-    char last_checked_old[128];
-    if (!load_char("last_checked", last_checked_old, sizeof(last_checked_old)))
-    {
-        FURI_LOG_E(TAG, "Failed to load last_checked");
-        FuriString *json = flip_world_datetime_to_json(time_current);
-        if (json)
-        {
-            save_char("last_checked", furi_string_get_cstr(json));
-            furi_string_free(json);
-        }
-        return false;
-    }
-
-    DateTime last_updated_time;
-
-    FuriString *last_updated_furi = char_to_furi_string(last_checked_old);
-    if (!last_updated_furi)
-    {
-        FURI_LOG_E(TAG, "Failed to convert char to FuriString");
-        return false;
-    }
-    if (!flip_world_json_to_datetime(&last_updated_time, last_updated_furi))
-    {
-        FURI_LOG_E(TAG, "Failed to convert JSON to DateTime");
-        furi_string_free(last_updated_furi);
-        return false;
-    }
-    furi_string_free(last_updated_furi); // Free after usage.
-
-    bool time_diff = false;
-    // If the date is different assume more than one hour has passed.
-    if (time_current->year != last_updated_time.year ||
-        time_current->month != last_updated_time.month ||
-        time_current->day != last_updated_time.day)
-    {
-        time_diff = true;
-    }
-    else
-    {
-        // For the same day, compute seconds from midnight.
-        int seconds_current = time_current->hour * 3600 + time_current->minute * 60 + time_current->second;
-        int seconds_last = last_updated_time.hour * 3600 + last_updated_time.minute * 60 + last_updated_time.second;
-        if ((seconds_current - seconds_last) >= 3600)
-        {
-            time_diff = true;
-        }
-    }
-
-    return time_diff;
-}
-
-// Sends a request to fetch the last updated date of the app.
-static bool flip_world_last_app_update(FlipperHTTP *fhttp, bool flipper_server)
-{
-    if (!fhttp)
-    {
-        FURI_LOG_E(TAG, "fhttp is NULL");
-        return false;
-    }
-    char url[256];
-    if (flipper_server)
-    {
-        // make sure folder is created
-        char directory_path[256];
-        snprintf(directory_path, sizeof(directory_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world");
-
-        // Create the directory
-        Storage *storage = furi_record_open(RECORD_STORAGE);
-        storage_common_mkdir(storage, directory_path);
-        snprintf(directory_path, sizeof(directory_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world/data");
-        storage_common_mkdir(storage, directory_path);
-        snprintf(directory_path, sizeof(directory_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world/data/last_update_request.txt");
-        storage_simply_remove_recursive(storage, directory_path); // ensure the file is empty
-        furi_record_close(RECORD_STORAGE);
-
-        fhttp->save_received_data = false;
-        fhttp->is_bytes_request = true;
-
-        snprintf(fhttp->file_path, sizeof(fhttp->file_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world/data/last_update_request.txt");
-        snprintf(url, sizeof(url), "https://raw.githubusercontent.com/flipperdevices/flipper-application-catalog/main/applications/GPIO/flip_world/manifest.yml");
-        return flipper_http_request(fhttp, BYTES, url, "{\"Content-Type\":\"application/json\"}", NULL);
-    }
-    else
-    {
-        snprintf(url, sizeof(url), "https://www.jblanked.com/flipper/api/app/last-updated/flip_world/");
-        return flipper_http_request(fhttp, GET, url, "{\"Content-Type\":\"application/json\"}", NULL);
-    }
-}
-
-/*
- * Scans a NUL‐terminated YAML buffer for a top‐level “version:” key,
- * and writes its (unquoted) value into out_version.
- * Returns true on success.
- */
-static bool parse_yaml_version(const char *yaml, char *out_version, size_t out_len)
-{
-    const char *p = strstr(yaml, "\nversion:");
-    if (!p)
-    {
-        // maybe it's the very first line
-        p = yaml;
-    }
-    else
-    {
-        // skip the “\n”
-        p++;
-    }
-    // skip the key name and colon
-    p = strstr(p, "version");
-    if (!p)
-        return false;
-    p += strlen("version");
-    // skip whitespace and colon
-    while (*p == ' ' || *p == ':')
-        p++;
-    // handle optional quote
-    bool quoted = (*p == '"');
-    if (quoted)
-        p++;
-    // copy up until end‐quote or newline/space
-    size_t i = 0;
-    while (*p && i + 1 < out_len)
-    {
-        if ((quoted && *p == '"') ||
-            (!quoted && (*p == '\n' || *p == ' ')))
-        {
-            break;
-        }
-        out_version[i++] = *p++;
-    }
-    out_version[i] = '\0';
-    return (i > 0);
-}
-
-// Parses the server response and returns true if an update is available.
-static bool flip_world_parse_last_app_update(FlipperHTTP *fhttp, DateTime *time_current, bool flipper_server)
-{
-    if (!fhttp)
-    {
-        FURI_LOG_E(TAG, "fhttp is NULL");
-        return false;
-    }
-    if (fhttp->state == ISSUE)
-    {
-        FURI_LOG_E(TAG, "Failed to fetch last app update");
-        return false;
-    }
-    char version_str[32];
-    if (!flipper_server)
-    {
-        if (fhttp->last_response == NULL || strlen(fhttp->last_response) == 0)
-        {
-            FURI_LOG_E(TAG, "fhttp->last_response is NULL or empty");
-            return false;
-        }
-
-        char *app_version = get_json_value("version", fhttp->last_response);
-        if (app_version)
-        {
-            // Save the server app version: it should save something like: 0.8
-            save_char("server_app_version", app_version);
-            snprintf(version_str, sizeof(version_str), "%s", app_version);
-            free(app_version);
-        }
-        else
-        {
-            FURI_LOG_E(TAG, "Failed to get app version");
-            return false;
-        }
-    }
-    else
-    {
-        FuriString *manifest_data = flipper_http_load_from_file(fhttp->file_path);
-        if (!manifest_data)
-        {
-            FURI_LOG_E(TAG, "Failed to load app data");
-            return false;
-        }
-        // parse version out of the YAML
-        if (!parse_yaml_version(furi_string_get_cstr(manifest_data), version_str, sizeof(version_str)))
-        {
-            FURI_LOG_E(TAG, "Failed to parse version from YAML manifest");
-            return false;
-        }
-        save_char("server_app_version", version_str);
-        furi_string_free(manifest_data);
-    }
-    // Only check for an update if an hour or more has passed.
-    if (flip_world_is_update_time(time_current))
-    {
-        char app_version[32];
-        if (!load_char("app_version", app_version, sizeof(app_version)))
-        {
-            FURI_LOG_E(TAG, "Failed to load app version");
-            return false;
-        }
-        // Check if the app version is different from the server version.
-        if (!is_str(app_version, version_str))
-        {
-            easy_flipper_dialog("Update available", "New update available!\nPress BACK to download.");
-            return true; // Update available.
-        }
-        FURI_LOG_I(TAG, "No update available");
-        return false; // No update available.
-    }
-    FURI_LOG_I(TAG, "Not enough time has passed since the last update check");
-    return false; // Not yet time to update.
-}
-
-static bool flip_world_get_fap_file(FlipperHTTP *fhttp, bool flipper_server)
-{
-    if (!fhttp)
-    {
-        FURI_LOG_E(TAG, "FlipperHTTP is NULL.");
-        return false;
-    }
-    char url[256];
-    fhttp->save_received_data = false;
-    fhttp->is_bytes_request = true;
-#ifndef FW_ORIGIN_Momentum
-    snprintf(
-        fhttp->file_path,
-        sizeof(fhttp->file_path),
-        STORAGE_EXT_PATH_PREFIX "/apps/GPIO/flip_world.fap");
-#else
-    snprintf(
-        fhttp->file_path,
-        sizeof(fhttp->file_path),
-        STORAGE_EXT_PATH_PREFIX "/apps/GPIO/FlipperHTTP/flip_world.fap");
-#endif
-    if (flipper_server)
-    {
-        char build_id[32];
-        snprintf(build_id, sizeof(build_id), "%s", BUILD_ID);
-        uint8_t target;
-        target = furi_hal_version_get_hw_target();
-        uint16_t api_major, api_minor;
-        furi_hal_info_get_api_version(&api_major, &api_minor);
-        snprintf(
-            url,
-            sizeof(url),
-            "https://catalog.flipperzero.one/api/v0/application/version/%s/build/compatible?target=f%d&api=%d.%d",
-            build_id,
-            target,
-            api_major,
-            api_minor);
-    }
-    else
-    {
-        snprintf(url, sizeof(url), "https://www.jblanked.com/flipper/api/app/download/flip_world/");
-    }
-    return flipper_http_request(fhttp, BYTES, url, "{\"Content-Type\": \"application/octet-stream\"}", NULL);
-}
-
-// Updates the app. Uses the supplied current time for validating if update check should proceed.
-static bool flip_world_update_app(FlipperHTTP *fhttp, DateTime *time_current, bool use_flipper_api)
-{
-    if (!fhttp)
-    {
-        FURI_LOG_E(TAG, "fhttp is NULL");
-        return false;
-    }
-    if (!flip_world_last_app_update(fhttp, use_flipper_api))
-    {
-        FURI_LOG_E(TAG, "Failed to fetch last app update");
-        return false;
-    }
-    fhttp->state = RECEIVING;
-    furi_timer_start(fhttp->get_timeout_timer, TIMEOUT_DURATION_TICKS);
-    while (fhttp->state == RECEIVING && furi_timer_is_running(fhttp->get_timeout_timer) > 0)
-    {
-        furi_delay_ms(100);
-    }
-    furi_timer_stop(fhttp->get_timeout_timer);
-    if (flip_world_parse_last_app_update(fhttp, time_current, use_flipper_api))
-    {
-        if (!flip_world_get_fap_file(fhttp, false))
-        {
-            FURI_LOG_E(TAG, "Failed to fetch fap file 1");
-            return false;
-        }
-        fhttp->state = RECEIVING;
-
-        while (fhttp->state == RECEIVING)
-        {
-            furi_delay_ms(100);
-        }
-
-        if (fhttp->state == ISSUE)
-        {
-            FURI_LOG_E(TAG, "Failed to fetch fap file 2");
-            easy_flipper_dialog("Update Error", "Failed to download the\nupdate file.\nPlease try again.");
-            return false;
-        }
-        return true;
-    }
-    return false; // No update available.
-}
-
-// Handles the app update routine. This function obtains the current RTC time,
-// checks the "last_checked" value, and if it is more than one hour old, calls for an update.
-bool flip_world_handle_app_update(FlipperHTTP *fhttp, bool use_flipper_api)
-{
-    if (!fhttp)
-    {
-        FURI_LOG_E(TAG, "fhttp is NULL");
-        return false;
-    }
-    DateTime rtc_time;
-    furi_hal_rtc_get_datetime(&rtc_time);
-    char last_checked[32];
-    if (!load_char("last_checked", last_checked, sizeof(last_checked)))
-    {
-        // First time – save the current time and check for an update.
-        if (!flip_world_save_rtc_time(&rtc_time))
-        {
-            FURI_LOG_E(TAG, "Failed to save RTC time");
-            return false;
-        }
-        return flip_world_update_app(fhttp, &rtc_time, use_flipper_api);
-    }
-    else
-    {
-        // Check if the current RTC time is at least one hour past the stored time.
-        if (flip_world_is_update_time(&rtc_time))
-        {
-            if (!flip_world_update_app(fhttp, &rtc_time, use_flipper_api))
-            {
-                // save the last_checked for the next check.
-                if (!flip_world_save_rtc_time(&rtc_time))
-                {
-                    FURI_LOG_E(TAG, "Failed to save RTC time");
-                    return false;
-                }
-                return false;
-            }
-            // Save the current time for the next check.
-            if (!flip_world_save_rtc_time(&rtc_time))
-            {
-                FURI_LOG_E(TAG, "Failed to save RTC time");
-                return false;
-            }
-            return true;
-        }
-        return false; // No update necessary.
-    }
-}

+ 2 - 5
flip_world/flip_world.h

@@ -15,19 +15,17 @@
 //
 
 #define TAG "FlipWorld"
-#define VERSION "0.8.2"
+#define VERSION "0.8.3"
 #define VERSION_TAG TAG " " FAP_VERSION
 //
 
-#define APP_ID "67f22e9a25a4a6f1fb4a2c4a"
-#define BUILD_ID "676900d983aa88302bc114c6"
-
 // Define the submenu items for our FlipWorld application
 typedef enum
 {
     FlipWorldSubmenuIndexPvE,
     FlipWorldSubmenuIndexStory,
     FlipWorldSubmenuIndexPvP,
+    //
     FlipWorldSubmenuIndexGameSubmenu,
     FlipWorldSubmenuIndexMessage,
     FlipWorldSubmenuIndexSettings,
@@ -111,4 +109,3 @@ float atof_(const char *nptr);
 float atof_furi(const FuriString *nptr);
 bool is_str(const char *src, const char *dst);
 bool is_enough_heap(size_t heap_size, bool check_blocks);
-bool flip_world_handle_app_update(FlipperHTTP *fhttp, bool use_flipper_api);

+ 6 - 6
flip_world/flipper_http/flipper_http.c

@@ -784,7 +784,7 @@ bool flipper_http_request(FlipperHTTP *fhttp, HTTPMethod method, const char *url
 {
     if (!fhttp)
     {
-        FURI_LOG_E("FlipperHTTP", "Failed to get context.");
+        FURI_LOG_E("FlipperHTTP", "flipper_http_request: Failed to get context.");
         return false;
     }
     if (!url)
@@ -919,7 +919,7 @@ bool flipper_http_send_command(FlipperHTTP *fhttp, HTTPCommand command)
 {
     if (!fhttp)
     {
-        FURI_LOG_E(HTTP_TAG, "Failed to get context.");
+        FURI_LOG_E(HTTP_TAG, "flipper_http_send_command: Failed to get context.");
         return false;
     }
     switch (command)
@@ -962,7 +962,7 @@ bool flipper_http_send_data(FlipperHTTP *fhttp, const char *data)
 {
     if (!fhttp)
     {
-        FURI_LOG_E(HTTP_TAG, "Failed to get context.");
+        FURI_LOG_E(HTTP_TAG, "flipper_http_send_data: Failed to get context.");
         return false;
     }
 
@@ -1127,7 +1127,7 @@ static void flipper_http_rx_callback(const char *line, void *context)
     FlipperHTTP *fhttp = (FlipperHTTP *)context;
     if (!fhttp)
     {
-        FURI_LOG_E(HTTP_TAG, "Failed to get context.");
+        FURI_LOG_E(HTTP_TAG, "flipper_http_rx_callback: Failed to get context.");
         return;
     }
     if (!line)
@@ -1498,7 +1498,7 @@ bool flipper_http_websocket_start(FlipperHTTP *fhttp, const char *url, uint16_t
 {
     if (!fhttp)
     {
-        FURI_LOG_E(HTTP_TAG, "Failed to get context.");
+        FURI_LOG_E(HTTP_TAG, "flipper_http_websocket_start: Failed to get context.");
         return false;
     }
     if (!url || !headers)
@@ -1537,7 +1537,7 @@ bool flipper_http_websocket_stop(FlipperHTTP *fhttp)
 {
     if (!fhttp)
     {
-        FURI_LOG_E(HTTP_TAG, "Failed to get context.");
+        FURI_LOG_E(HTTP_TAG, "flipper_http_websocket_stop: Failed to get context.");
         return false;
     }
     return flipper_http_send_data(fhttp, "[SOCKET/STOP]");

+ 1 - 1
flip_world/game/draw.c

@@ -58,7 +58,7 @@ static void draw_menu(GameManager *manager, Canvas *canvas)
         0,
         &I_icon_menu_128x64px);
 
-    if (game_context->game_mode == GAME_MODE_STORY)
+    if (game_context->game_mode == GAME_MODE_STORY && game_context->story_step < STORY_TUTORIAL_STEPS)
     {
         canvas_set_font(canvas, FontPrimary);
         canvas_draw_str(canvas, 45, 15, "Tutorial");

+ 185 - 89
flip_world/game/enemy.c

@@ -136,9 +136,8 @@ static void enemy_render(Entity *self, GameManager *manager, Canvas *canvas, voi
     }
 
     // no enemies in story mode for now
-    if (game_context->game_mode != GAME_MODE_STORY || (game_context->game_mode == GAME_MODE_STORY && game_context->tutorial_step == 4))
+    if (game_context->game_mode != GAME_MODE_STORY || (game_context->story_step == 4 || game_context->story_step >= STORY_TUTORIAL_STEPS))
     {
-
         // Draw enemy sprite relative to camera, centered on the enemy's position
         canvas_draw_sprite(
             canvas,
@@ -227,7 +226,7 @@ static void enemy_collision(Entity *self, Entity *other, GameManager *manager, v
     GameContext *game_context = game_manager_game_context_get(manager);
     furi_check(game_context, "Enemy collision: GameContext is NULL");
     PlayerContext *player_context = entity_context_get(game_context->player);
-    if (game_context->game_mode == GAME_MODE_STORY && game_context->tutorial_step != 4)
+    if (game_context->game_mode == GAME_MODE_STORY && game_context->story_step != 4 && game_context->story_step < STORY_TUTORIAL_STEPS)
     {
         // FURI_LOG_I("Game", "Enemy collision: No enemies in story mode");
         return;
@@ -265,10 +264,10 @@ static void enemy_collision(Entity *self, Entity *other, GameManager *manager, v
         // Handle Player Attacking Enemy (Press OK, facing enemy, and enemy not facing player)
         if (player_is_facing_enemy && game_context->last_button == GameKeyOk && !enemy_is_facing_player)
         {
-            if (game_context->game_mode == GAME_MODE_STORY && game_context->tutorial_step == 4)
+            if (game_context->game_mode == GAME_MODE_STORY && game_context->story_step == 4)
             {
                 // FURI_LOG_I("Game", "Player attacked enemy '%d'!", enemy_context->id);
-                game_context->tutorial_step++;
+                game_context->story_step++;
             }
             // Reset last button
             game_context->last_button = -1;
@@ -304,6 +303,7 @@ static void enemy_collision(Entity *self, Entity *other, GameManager *manager, v
                         player_context->health = player_context->max_health;
                         save_player_context(player_context);
                         furi_delay_ms(100);
+                        game_context->end_reason = GAME_END_PVP_ENEMY_DEAD;
                         game_manager_game_stop(manager);
                         return;
                     }
@@ -311,12 +311,16 @@ static void enemy_collision(Entity *self, Entity *other, GameManager *manager, v
                     // Reset enemy position and health
                     enemy_context->health = 100; // this needs to be set to the enemy's max health
 
-                    // remove from game context and set in safe zone
-                    game_context->enemies[enemy_context->index] = NULL;
-                    game_context->enemy_count--;
-                    entity_collider_remove(self);
-                    entity_pos_set(self, (Vector){-100, -100});
-                    return;
+                    // in PVE mode, enemies can respawn
+                    if (game_context->game_mode != GAME_MODE_PVE)
+                    {
+                        // remove from game context and set in safe zone
+                        game_context->enemies[enemy_context->index] = NULL;
+                        game_context->enemy_count--;
+                        entity_collider_remove(self);
+                        entity_pos_set(self, (Vector){-100, -100});
+                        return;
+                    }
                 }
                 else
                 {
@@ -360,6 +364,7 @@ static void enemy_collision(Entity *self, Entity *other, GameManager *manager, v
                     {
                         save_player_context(player_context);
                         furi_delay_ms(100);
+                        game_context->end_reason = GAME_END_PVP_PLAYER_DEAD;
                         game_manager_game_stop(manager);
                         return;
                     }
@@ -433,111 +438,196 @@ static void enemy_collision(Entity *self, Entity *other, GameManager *manager, v
     }
 }
 
-static void enemy_pvp_position(GameContext *game_context, EntityContext *enemy, Entity *self)
+static bool enemy_is_game_enemy(GameManager *manager, const char *username)
 {
-    if (!game_context || !enemy || !self)
+    GameContext *game_context = game_manager_game_context_get(manager);
+    if (game_context)
     {
-        FURI_LOG_E("Game", "PVP position: Invalid parameters");
+        for (int i = 0; i < MAX_ENEMIES; i++)
+        {
+            if (!game_context->enemies[i])
+                break;
+            EntityContext *enemy_context = entity_context_get(game_context->enemies[i]);
+            if (enemy_context && is_str(enemy_context->username, username))
+            {
+                return true;
+            }
+        }
+    }
+    return false;
+}
+
+static void enemy_pvp_position(GameManager *manager, EntityContext *enemy, Entity *self)
+{
+    if (!manager || !enemy || !self)
+    {
+        FURI_LOG_E("Game", "enemy_pvp_position: Invalid parameters");
         return;
     }
 
-    if (game_context->fhttp->last_response != NULL && strlen(game_context->fhttp->last_response) > 0)
+    GameContext *game_context = game_manager_game_context_get(manager);
+    if (!game_context->fhttp->last_response || strlen(game_context->fhttp->last_response) == 0)
     {
-        // for debugging
-        // save_char("received_pvp_position", game_context->fhttp->last_response);
-        // parse the response and set the enemy position
-        /* expected response:
-        {
-            "u": "JBlanked",
-            "xp": 37743,
-            "h": 207,
-            "ehr": 0.7,
-            "eat": 127.5,
-            "d": 2,
-            "s": 1,
-            "sp": {
-                "x": 381.0,
-                "y": 192.0
-            }
-        }
-        */
+        return;
+    }
 
-        // FuriStrings are probably safer but we already last_response as a char*
+    // parse the response and set the enemy position
+    /* expected response:
+    {
+        "u": "JBlanked",
+        "xp": 37743,
+        "h": 207,
+        "ehr": 0.7,
+        "eat": 127.5,
+        "d": 2,
+        "s": 1,
+        "sp": {
+            "x": 381.0,
+            "y": 192.0
+        }
+    }
+    */
 
-        // match username
-        char *u = get_json_value("u", game_context->fhttp->last_response);
-        if (!u || !is_str(u, enemy->username))
+    // match username
+    char *u = get_json_value("u", game_context->fhttp->last_response);
+    if (!u)
+    {
+        FURI_LOG_E("Game", "enemy_pvp_position: Failed to get username");
+        return;
+    }
+    // check if the username matches
+    if (!is_str(u, enemy->username))
+    {
+        if (strlen(u) == 0 || enemy_is_game_enemy(manager, u))
         {
-            if (u)
-                free(u);
+            free(u);
             return;
         }
 
-        // we need the health, elapsed attack timer, direction, and position
-        char *h = get_json_value("h", game_context->fhttp->last_response);
-        char *eat = get_json_value("eat", game_context->fhttp->last_response);
-        char *d = get_json_value("d", game_context->fhttp->last_response);
-        char *sp = get_json_value("sp", game_context->fhttp->last_response);
-        char *x = get_json_value("x", sp);
-        char *y = get_json_value("y", sp);
-
-        if (!h || !eat || !d || !sp || !x || !y)
+        PlayerContext *player_context = entity_context_get(game_context->player);
+        if (is_str(player_context->username, u))
         {
-            if (h)
-                free(h);
-            if (eat)
-                free(eat);
-            if (d)
-                free(d);
-            if (sp)
-                free(sp);
-            if (x)
-                free(x);
-            if (y)
-                free(y);
             free(u);
             return;
         }
 
-        // set enemy info
-        enemy->health = (float)atoi(h);
-        enemy->elapsed_attack_timer = (float)atof_(eat);
-        switch (atoi(d))
+        char *h = get_json_value("h", game_context->fhttp->last_response);
+        if (!h)
         {
-        case 0:
-            enemy->direction = ENTITY_LEFT;
-            break;
-        case 1:
-            enemy->direction = ENTITY_RIGHT;
-            break;
-        case 2:
-            enemy->direction = ENTITY_UP;
-            break;
-        case 3:
-            enemy->direction = ENTITY_DOWN;
-            break;
-        default:
-            enemy->direction = ENTITY_RIGHT;
-            break;
+            free(u);
+            return;
         }
+        FuriString *enemy_data = furi_string_alloc();
+        furi_string_printf(
+            enemy_data,
+            "{\"enemy_data\":[{\"id\":\"sword\",\"is_user\":\"true\",\"username\":\"%s\","
+            "\"index\":0,\"start_position\":{\"x\":350,\"y\":210},\"end_position\":{\"x\":350,\"y\":210},"
+            "\"move_timer\":1,\"speed\":1,\"attack_timer\":\"0.1\",\"strength\":\"100\",\"health\":%f}]}",
+            u,
+            (double)atoi(h) // h is an int
+        );
+        free(h);                                                                             // free health
+        enemy_spawn(game_context->levels[game_context->current_level], manager, enemy_data); // add the enemy to the game context
+        FURI_LOG_I("Game", "enemy_pvp_position: Added enemy '%s' to the game context", u);
+        free(u);
+        furi_string_free(enemy_data); // free enemy data
+        return;
+    }
 
-        Vector new_pos = (Vector){
-            .x = (float)atof_(x),
-            .y = (float)atof_(y),
-        };
-
-        // set enemy position
-        entity_pos_set(self, new_pos);
+    free(u); // free username
+
+    // we need the health, elapsed attack timer, direction, xp, and position
+    char *h = get_json_value("h", game_context->fhttp->last_response);
+    char *eat = get_json_value("eat", game_context->fhttp->last_response);
+    char *d = get_json_value("d", game_context->fhttp->last_response);
+    char *xp = get_json_value("xp", game_context->fhttp->last_response);
+    char *sp = get_json_value("sp", game_context->fhttp->last_response);
+    char *x = get_json_value("x", sp);
+    char *y = get_json_value("y", sp);
+
+    if (!h || !eat || !d || !sp || !x || !y || !xp)
+    {
+        if (h)
+            free(h);
+        if (eat)
+            free(eat);
+        if (d)
+            free(d);
+        if (sp)
+            free(sp);
+        if (x)
+            free(x);
+        if (y)
+            free(y);
+        if (xp)
+            free(xp);
+        return;
+    }
 
-        // free the strings
+    // set enemy info
+    enemy->health = (float)atoi(h); // h is an int
+    if (enemy->health <= 0)
+    {
+        enemy->health = 0;
+        enemy->state = ENTITY_DEAD;
+        entity_pos_set(self, (Vector){-100, -100});
         free(h);
         free(eat);
         free(d);
         free(sp);
         free(x);
         free(y);
-        free(u);
+        free(xp);
+        PlayerContext *player_context = entity_context_get(game_context->player);
+        if (player_context)
+        {
+            save_player_context(player_context);
+            furi_delay_ms(100);
+            game_manager_game_stop(manager);
+        }
+        return;
     }
+
+    enemy->elapsed_attack_timer = atof_(eat);
+
+    switch (atoi(d))
+    {
+    case 0:
+        enemy->direction = ENTITY_LEFT;
+        break;
+    case 1:
+        enemy->direction = ENTITY_RIGHT;
+        break;
+    case 2:
+        enemy->direction = ENTITY_UP;
+        break;
+    case 3:
+        enemy->direction = ENTITY_DOWN;
+        break;
+    default:
+        enemy->direction = ENTITY_RIGHT;
+        break;
+    }
+
+    enemy->xp = (atoi)(xp); // xp is an int
+    enemy->level = player_level_iterative_get(enemy->xp);
+
+    Vector new_pos = (Vector){
+        .x = atof_(x),
+        .y = atof_(y),
+    };
+
+    // set enemy position
+    entity_pos_set(self, new_pos);
+
+    // free the strings
+    free(h);
+    free(eat);
+    free(d);
+    free(sp);
+    free(x);
+    free(y);
+    free(xp);
 }
 
 // Enemy update function
@@ -564,10 +654,16 @@ static void enemy_update(Entity *self, GameManager *manager, void *context)
     if (game_context->game_mode == GAME_MODE_PVP)
     {
         // update enemy position
-        enemy_pvp_position(game_context, enemy_context, self);
+        enemy_pvp_position(manager, enemy_context, self);
     }
     else
     {
+        // update enemy position for pve mode
+        if (game_context->game_mode == GAME_MODE_PVE)
+        {
+            enemy_pvp_position(manager, enemy_context, self);
+        }
+
         // Increment the elapsed_attack_timer for the enemy
         enemy_context->elapsed_attack_timer += delta_time;
 

+ 85 - 16
flip_world/game/game.c

@@ -2,6 +2,28 @@
 #include <game/game.h>
 #include <game/storage.h>
 #include <alloc/alloc.h>
+#include <callback/game.h>
+
+// very simple tutorial check
+static bool game_tutorial_done(GameContext *game_context)
+{
+    furi_check(game_context);
+    char tutorial_done[32];
+    if (!load_char("tutorial_done", tutorial_done, sizeof(tutorial_done)))
+    {
+        FURI_LOG_E("Game", "Failed to load tutorial_done");
+        game_context->ended_early = true;
+        game_context->end_reason = GAME_END_TUTORIAL_INCOMPLETE;
+        return false;
+    }
+    if (!is_str(tutorial_done, "J You BLANKED on this one"))
+    {
+        FURI_LOG_E("Game", "Tutorial not done");
+        game_context->ended_early = true;
+        game_context->end_reason = GAME_END_TUTORIAL_INCOMPLETE;
+    }
+    return true;
+}
 
 /****** Game ******/
 /*
@@ -17,6 +39,7 @@ static void game_start(GameManager *game_manager, void *ctx)
         FURI_LOG_E("Game", "Not enough heap memory.. ending game early.");
         GameContext *game_context = ctx;
         game_context->ended_early = true;
+        game_context->end_reason = GAME_END_MEMORY;
         game_manager_game_stop(game_manager); // end game early
         return;
     }
@@ -29,7 +52,7 @@ static void game_start(GameManager *game_manager, void *ctx)
     game_context->level_count = 0;
     game_context->enemy_count = 0;
     game_context->npc_count = 0;
-
+    game_context->end_reason = GAME_END_MEMORY; // default value
     game_context->game_mode = game_mode_index;
 
     // set all levels to NULL
@@ -46,14 +69,17 @@ static void game_start(GameManager *game_manager, void *ctx)
 
     if (game_context->game_mode == GAME_MODE_PVE)
     {
+        game_tutorial_done(game_context); // the game will end if tutorial is not done
+
         // attempt to allocate all levels
         for (int i = 0; i < MAX_LEVELS; i++)
         {
             if (!allocate_level(game_manager, i))
             {
+                FURI_LOG_E("Game", "Failed to allocate level %d", i);
                 if (i == 0)
                 {
-                    game_context->levels[0] = game_manager_add_level(game_manager, world_training());
+                    game_context->levels[0] = game_manager_add_level(game_manager, story_world());
                     game_context->level_count = 1;
                 }
                 break;
@@ -64,12 +90,27 @@ static void game_start(GameManager *game_manager, void *ctx)
     }
     else if (game_context->game_mode == GAME_MODE_STORY)
     {
-        // show tutorial only for now
-        game_context->levels[0] = game_manager_add_level(game_manager, world_training());
+        if (load_uint32("story_step", &game_context->story_step) == false)
+        {
+            game_context->story_step = 0;
+        }
+        game_context->levels[0] = game_manager_add_level(game_manager, story_world());
         game_context->level_count = 1;
+        for (int i = 1; i < MAX_LEVELS; i++)
+        {
+            if (!allocate_level(game_manager, i))
+            {
+                FURI_LOG_E("Game", "Failed to allocate level %d", i);
+                break;
+            }
+            else
+                game_context->level_count++;
+        }
     }
     else if (game_context->game_mode == GAME_MODE_PVP)
     {
+        game_tutorial_done(game_context); // the game will end if tutorial is not done
+
         // show pvp
         game_context->levels[0] = game_manager_add_level(game_manager, world_pvp());
         game_context->level_count = 1;
@@ -80,12 +121,13 @@ static void game_start(GameManager *game_manager, void *ctx)
     game_context->imu_present = imu_present(game_context->imu);
 
     // FlipperHTTP
-    if (game_context->game_mode == GAME_MODE_PVP)
+    if (game_context->game_mode != GAME_MODE_STORY)
     {
         // check if enough memory
         if (!is_enough_heap(sizeof(FlipperHTTP), true))
         {
             FURI_LOG_E("Game", "Not enough heap memory.. ending game early.");
+            game_context->end_reason = GAME_END_MEMORY;
             game_context->ended_early = true;
             game_manager_game_stop(game_manager); // end game early
             return;
@@ -94,6 +136,7 @@ static void game_start(GameManager *game_manager, void *ctx)
         if (!game_context->fhttp)
         {
             FURI_LOG_E("Game", "Failed to allocate FlipperHTTP");
+            game_context->end_reason = GAME_END_MEMORY;
             game_context->ended_early = true;
             game_manager_game_stop(game_manager); // end game early
             return;
@@ -130,34 +173,60 @@ static void game_stop(void *ctx)
         level_clear(game_context->levels[game_context->current_level]);
     }
 
-    if (game_context->game_mode == GAME_MODE_PVP)
+    if (game_context->game_mode != GAME_MODE_STORY)
     {
         if (game_context->fhttp)
         {
             flipper_http_websocket_stop(game_context->fhttp); // close websocket
-            remove_player_from_lobby(game_context->fhttp);    // remove player from lobby
+            game_remove_from_lobby(game_context->fhttp);      // remove player from lobby
             flipper_http_free(game_context->fhttp);
         }
     }
-
-    PlayerContext *player_context = malloc(sizeof(PlayerContext));
-    if (!player_context)
+    else
     {
-        FURI_LOG_E("Game", "Failed to allocate PlayerContext");
-        return;
+        save_uint32("story_step", game_context->story_step);
     }
 
     if (!game_context->ended_early)
-        easy_flipper_dialog(
-            "Game Over",
-            "Thanks for playing FlipWorld!\nHit BACK then wait for\nthe game to save.");
+    {
+        easy_flipper_dialog("Game Over", "Thanks for playing FlipWorld!\nHit BACK then wait for\nthe game to save.");
+    }
     else
     {
         char message[128];
-        snprintf(message, sizeof(message), "Ran out of memory so the\ngame ended early. There were\n%zu bytes free.\n\nHit BACK to exit.", heap_size);
+        switch (game_context->end_reason)
+        {
+        case GAME_END_MEMORY:
+            snprintf(message, sizeof(message), "Ran out of memory so the\ngame ended early. There were\n%zu bytes free.\n\nHit BACK to exit.", heap_size);
+            break;
+        case GAME_END_TUTORIAL_INCOMPLETE:
+            snprintf(message, sizeof(message), "The tutorial is not complete.\nPlease finish the tutorial to\nsave your game.\n\nHit BACK to exit.");
+            break;
+        case GAME_END_PVP_REQUIREMENT:
+            snprintf(message, sizeof(message), "You need to be level 10 to\nplay PvP.\n\nHit BACK to exit.");
+            break;
+        case GAME_END_PVP_ENEMY_DEAD:
+            snprintf(message, sizeof(message), "You have defeated the enemy!\n\nHit BACK to exit.");
+            break;
+        case GAME_END_PVP_PLAYER_DEAD:
+            snprintf(message, sizeof(message), "You have been defeated!\n\nHit BACK to exit.");
+            break;
+        case GAME_END_NETWORK:
+            snprintf(message, sizeof(message), "Network error. Please check\nyour connection and try again.\n\nHit BACK to exit.");
+            break;
+        case GAME_END_APP:
+            snprintf(message, sizeof(message), "App error.\n\nHit BACK to exit.");
+            break;
+        };
         easy_flipper_dialog("Game Over", message);
     }
 
+    PlayerContext *player_context = malloc(sizeof(PlayerContext));
+    if (!player_context)
+    {
+        FURI_LOG_E("Game", "Failed to allocate PlayerContext");
+        return;
+    }
     // save the player context
     if (load_player_context(player_context))
     {

+ 1 - 0
flip_world/game/game.h

@@ -23,6 +23,7 @@ typedef enum
 #include "flip_world.h"
 #include <game/world.h>
 #include <game/level.h>
+#include <game/story.h>
 #include <game/enemy.h>
 #include <game/player.h>
 #include <game/npc.h>

+ 44 - 3
flip_world/game/level.c

@@ -1,6 +1,7 @@
 #include <game/level.h>
 #include <flip_storage/storage.h>
 #include <game/storage.h>
+#include <callback/game.h>
 bool allocate_level(GameManager *manager, int index)
 {
     GameContext *game_context = game_manager_game_context_get(manager);
@@ -12,7 +13,7 @@ bool allocate_level(GameManager *manager, int index)
     if (!world_list)
     {
         FURI_LOG_E("Game", "Failed to load world list");
-        game_context->levels[0] = game_manager_add_level(manager, world_training());
+        game_context->levels[0] = game_manager_add_level(manager, story_world());
         game_context->level_count = 1;
         return false;
     }
@@ -47,8 +48,9 @@ void level_set_world(Level *level, GameManager *manager, char *id)
 
     if (!is_enough_heap(20000, true))
     {
-        FURI_LOG_E("Game", "Not enough heap memory.. ending game early.");
+        FURI_LOG_E("Game", "level_set_world: Not enough heap memory.. ending game early.");
         GameContext *game_context = game_manager_game_context_get(manager);
+        game_context->end_reason = GAME_END_MEMORY;
         game_context->ended_early = true;
         game_manager_game_stop(manager); // end game early
         furi_string_free(json_data_str);
@@ -159,12 +161,51 @@ static void level_start(Level *level, GameManager *manager, void *context)
     if (!world_exists(level_context->id))
     {
         FURI_LOG_E("Game", "World does not exist.. downloading now");
-        FuriString *world_data = world_fetch(level_context->id);
+        FuriString *world_data = NULL;
+        if (game_context->game_mode != GAME_MODE_STORY)
+        {
+            if (game_context->fhttp)
+            {
+                flipper_http_free(game_context->fhttp);
+            }
+            game_context->fhttp = flipper_http_alloc();
+            if (!game_context->fhttp)
+            {
+                FURI_LOG_E("Game", "Failed to allocate FlipperHTTP");
+                game_context->is_switching_level = false;
+                game_context->ended_early = true;
+                game_context->end_reason = GAME_END_MEMORY;
+                player_spawn(level, manager);
+                return;
+            }
+            flipper_http_websocket_stop(game_context->fhttp); // close websocket if open
+            furi_delay_ms(200);
+            world_data = world_fetch(game_context->fhttp, level_context->id);
+            furi_delay_ms(200);
+            game_start_ws(game_context->fhttp, game_ws_lobby_name); // start websocket again
+        }
+        else
+        {
+            FlipperHTTP *fhttp = flipper_http_alloc();
+            if (!fhttp)
+            {
+                FURI_LOG_E("Game", "Failed to allocate FlipperHTTP");
+                game_context->is_switching_level = false;
+                game_context->ended_early = true;
+                game_context->end_reason = GAME_END_MEMORY;
+                player_spawn(level, manager);
+                return;
+            }
+            world_data = world_fetch(fhttp, "pvp_world");
+            flipper_http_free(fhttp);
+        }
         if (!world_data)
         {
             FURI_LOG_E("Game", "Failed to fetch world data");
             // draw_town_world(manager, level);
             game_context->is_switching_level = false;
+            game_context->ended_early = true;
+            game_context->end_reason = GAME_END_NETWORK;
             // furi_delay_ms(1000);
             player_spawn(level, manager);
             return;

+ 1 - 1
flip_world/game/npc.c

@@ -109,7 +109,7 @@ static void npc_render(Entity *self, GameManager *manager, Canvas *canvas, void
         current_sprite = npc_context->sprite_right;
     }
     // no NPCs in story mode for now
-    if (game_context->game_mode != GAME_MODE_STORY)
+    if (game_context->game_mode != GAME_MODE_STORY || game_context->story_step >= STORY_TUTORIAL_STEPS)
     {
         // Draw NPC sprite relative to camera, centered on the NPC's position
         canvas_draw_sprite(

+ 43 - 115
flip_world/game/player.c

@@ -12,51 +12,33 @@ static Level *player_next_level(GameManager *manager)
     if (!game_context)
     {
         FURI_LOG_E(TAG, "Failed to get game context");
-        game_context->is_switching_level = false;
         return NULL;
     }
-    // check if there are more levels to load
-    if (game_context->current_level + 1 >= game_context->level_count)
+
+    int next_index = game_context->current_level + 1;
+    if (next_index >= game_context->level_count)
     {
-        game_context->current_level = 0;
-        if (!game_context->levels[game_context->current_level])
-        {
-            if (!allocate_level(manager, game_context->current_level))
-            {
-                FURI_LOG_E(TAG, "Failed to allocate level %d", game_context->current_level);
-                game_context->is_switching_level = false;
-                furi_delay_ms(100);
-                return NULL;
-            }
-        }
-        game_context->is_switching_level = false;
-        furi_delay_ms(100);
-        return game_context->levels[game_context->current_level];
+        FURI_LOG_E(TAG, "No more levels to load (index %d)", next_index);
+        return game_context->levels[0];
     }
-    for (int i = game_context->current_level + 1; i < game_context->level_count; i++)
+
+    // Allocate the level if it hasn't been loaded yet
+    if (!game_context->levels[next_index])
     {
-        if (!game_context->levels[i])
+        if (!allocate_level(manager, next_index))
         {
-            if (!allocate_level(manager, i))
-            {
-                FURI_LOG_E(TAG, "Failed to allocate level %d", i);
-                game_context->is_switching_level = false;
-                furi_delay_ms(100);
-                return NULL;
-            }
+            FURI_LOG_E(TAG, "Failed to allocate level %d", next_index);
+            return NULL;
         }
-        game_context->current_level = i;
-        game_context->is_switching_level = false;
-        furi_delay_ms(100);
-        return game_context->levels[i];
     }
-    game_context->is_switching_level = false;
-    furi_delay_ms(100);
-    return NULL;
+
+    // Update current level and return it
+    game_context->current_level = next_index;
+    return game_context->levels[next_index];
 }
 
 // Update player stats based on XP using iterative method
-static int player_level_iterative_get(uint32_t xp)
+int player_level_iterative_get(uint32_t xp)
 {
     int level = 1;
     uint32_t xp_required = 100; // Base XP for level 2
@@ -134,6 +116,14 @@ void player_spawn(Level *level, GameManager *manager)
         pctx->elapsed_health_regen = 0;
         pctx->max_health = 100 + ((pctx->level - 1) * 10); // 10 health per level
 
+        // level 10 level required for PvP
+        if (game_context->game_mode == GAME_MODE_PVP)
+        {
+            FURI_LOG_E(TAG, "Player level is not high enough for PvP");
+            game_context->end_reason = GAME_END_PVP_REQUIREMENT;
+            game_context->ended_early = true;
+        }
+
         // Set player username
         if (!load_char("Flip-Social-Username", pctx->username, sizeof(pctx->username)))
         {
@@ -162,6 +152,14 @@ void player_spawn(Level *level, GameManager *manager)
     // Determine the player's level based on XP
     pctx->level = player_level_iterative_get(pctx->xp);
 
+    // level 10 level required for PvP
+    if (game_context->game_mode == GAME_MODE_PVP && pctx->level < 10)
+    {
+        FURI_LOG_E(TAG, "Player level %ld is not high enough for PvP", pctx->level);
+        game_context->end_reason = GAME_END_PVP_REQUIREMENT;
+        game_context->ended_early = true;
+    }
+
     // Update strength and max health based on the new level
     pctx->strength = 10 + (pctx->level * 1);           // 1 strength per level
     pctx->max_health = 100 + ((pctx->level - 1) * 10); // 10 health per level
@@ -243,6 +241,7 @@ static void player_handle_collision(Entity *playerEntity, Vector playerPos, Play
 }
 
 uint16_t elapsed_ws_timer = 0;
+uint16_t elpased_ws_info = 0;
 static void player_update(Entity *self, GameManager *manager, void *context)
 {
     if (!self || !manager || !context)
@@ -253,9 +252,17 @@ static void player_update(Entity *self, GameManager *manager, void *context)
     Vector pos = entity_pos_get(self);
     GameContext *game_context = game_manager_game_context_get(manager);
 
+    // ensure game is stopped
+    if (game_context->ended_early)
+    {
+        game_manager_game_stop(manager);
+        return;
+    }
+
     // update websocket player context
     if (game_context->game_mode == GAME_MODE_PVP)
     {
+
         // if pvp, end the game if the player is dead
         if (player->health <= 0)
         {
@@ -464,37 +471,7 @@ static void player_update(Entity *self, GameManager *manager, void *context)
     // adjust tutorial step
     if (game_context->game_mode == GAME_MODE_STORY)
     {
-        switch (game_context->tutorial_step)
-        {
-        case 0:
-            if (input.held & GameKeyLeft)
-                game_context->tutorial_step++;
-            break;
-        case 1:
-            if (input.held & GameKeyRight)
-                game_context->tutorial_step++;
-            break;
-        case 2:
-            if (input.held & GameKeyUp)
-                game_context->tutorial_step++;
-            break;
-        case 3:
-            if (input.held & GameKeyDown)
-                game_context->tutorial_step++;
-            break;
-        case 5:
-            if (input.held & GameKeyOk && game_context->is_menu_open)
-                game_context->tutorial_step++;
-            break;
-        case 6:
-            if (input.held & GameKeyBack)
-                game_context->tutorial_step++;
-            break;
-        case 7:
-            if (input.held & GameKeyBack)
-                game_context->tutorial_step++;
-            break;
-        }
+        story_update(self, manager);
     }
 
     // Clamp the player's position to stay within world bounds
@@ -518,50 +495,6 @@ static void player_update(Entity *self, GameManager *manager, void *context)
     player_handle_collision(self, pos, player);
 }
 
-static void player_draw_tutorial(Canvas *canvas, GameManager *manager)
-{
-    GameContext *game_context = game_manager_game_context_get(manager);
-    canvas_set_font(canvas, FontPrimary);
-    canvas_draw_str(canvas, 45, 12, "Tutorial");
-    canvas_set_font_custom(canvas, FONT_SIZE_SMALL);
-    switch (game_context->tutorial_step)
-    {
-    case 0:
-        canvas_draw_str(canvas, 15, 20, "Press LEFT to move left");
-        break;
-    case 1:
-        canvas_draw_str(canvas, 15, 20, "Press RIGHT to move right");
-        break;
-    case 2:
-        canvas_draw_str(canvas, 15, 20, "Press UP to move up");
-        break;
-    case 3:
-        canvas_draw_str(canvas, 15, 20, "Press DOWN to move down");
-        break;
-    case 4:
-        canvas_draw_str(canvas, 0, 20, "Press OK + collide with an enemy to attack");
-        break;
-    case 5:
-        canvas_draw_str(canvas, 15, 20, "Hold OK to open the menu");
-        break;
-    case 6:
-        canvas_draw_str(canvas, 15, 20, "Press BACK to escape the menu");
-        break;
-    case 7:
-        canvas_draw_str(canvas, 15, 20, "Hold BACK to save and exit");
-        break;
-    case 8:
-        // end of tutorial so quit
-        game_context->tutorial_step = 0;
-        game_context->is_menu_open = false;
-        game_context->is_switching_level = true;
-        game_manager_game_stop(manager);
-        return;
-    default:
-        break;
-    }
-}
-
 static void player_render(Entity *self, GameManager *manager, Canvas *canvas, void *context)
 {
     if (!self || !context || !canvas || !manager)
@@ -612,12 +545,7 @@ static void player_render(Entity *self, GameManager *manager, Canvas *canvas, vo
     // render tutorial
     if (game_context->game_mode == GAME_MODE_STORY)
     {
-        player_draw_tutorial(canvas, manager);
-
-        if (game_context->is_menu_open)
-        {
-            draw_background_render(canvas, manager);
-        }
+        story_draw(self, canvas, manager);
     }
     else
     {

+ 19 - 2
flip_world/game/player.h

@@ -4,7 +4,7 @@
 #include <game/game.h>
 #include "engine/sensors/imu.h"
 
-#define MAX_ENEMIES 5
+#define MAX_ENEMIES 12
 #define MAX_LEVELS 5
 #define MAX_NPCS 1
 
@@ -43,6 +43,8 @@ typedef struct
     char message[64];           // Message to display when interacting with the entity
     bool is_user;               // Flag to indicate if the entity is a live player or not
     char username[32];          // entity username
+    uint32_t xp;                // experience points
+    uint32_t level;             // entity level
 } EntityContext;
 
 typedef struct
@@ -84,6 +86,18 @@ typedef enum
     GAME_MODE_STORY = 2, // story mode
 } GameMode;
 
+// game ending reasons
+typedef enum
+{
+    GAME_END_MEMORY = 0,              // ran out of memory
+    GAME_END_TUTORIAL_INCOMPLETE = 1, // tutorial incomplete
+    GAME_END_PVP_REQUIREMENT = 2,     // player level too low for pvp
+    GAME_END_PVP_ENEMY_DEAD = 3,      // enemy dead
+    GAME_END_PVP_PLAYER_DEAD = 4,     // player dead
+    GAME_END_NETWORK = 5,             // network error
+    GAME_END_APP = 6,                 // app issue
+} GameEndReason;
+
 typedef struct
 {
     Level *levels[MAX_LEVELS];
@@ -110,15 +124,17 @@ typedef struct
     uint8_t menu_selection;
     //
     GameMode game_mode;
+    GameEndReason end_reason;
     //
     uint32_t icon_count;
     uint16_t icon_offset;
     //
     char message[64];
     //
-    uint8_t tutorial_step;
+    uint32_t story_step;
     //
     FlipperHTTP *fhttp;
+    //
 } GameContext;
 
 typedef struct
@@ -133,3 +149,4 @@ typedef struct
 extern const EntityDescription player_desc;
 void player_spawn(Level *level, GameManager *manager);
 SpriteContext *sprite_context_get(const char *name);
+int player_level_iterative_get(uint32_t xp);

+ 30 - 64
flip_world/game/storage.c

@@ -1,6 +1,6 @@
 #include <game/storage.h>
 
-static bool save_uint32(const char *path_name, uint32_t value)
+bool save_uint32(const char *path_name, uint32_t value)
 {
     char buffer[32];
     snprintf(buffer, sizeof(buffer), "%lu", value);
@@ -499,43 +499,6 @@ bool websocket_player_context(PlayerContext *player_context, FlipperHTTP *fhttp)
     return true;
 }
 
-bool remove_player_from_lobby(FlipperHTTP *fhttp)
-{
-    if (!fhttp)
-    {
-        FURI_LOG_E(TAG, "FlipperHTTP is NULL");
-        return false;
-    }
-    char username[32];
-    if (!load_char("Flip-Social-Username", username, sizeof(username)))
-    {
-        FURI_LOG_E(TAG, "Failed to load data/Flip-Social-Username");
-        return false;
-    }
-    char lobby_name[32];
-    if (!load_char("pvp_lobby_name", lobby_name, sizeof(lobby_name)))
-    {
-        FURI_LOG_E(TAG, "Failed to load data/pvp_lobby_name");
-        return false;
-    }
-    char url[128];
-    char payload[128];
-    snprintf(payload, sizeof(payload), "{\"username\":\"%s\", \"game_id\":\"%s\"}", username, lobby_name);
-    snprintf(url, sizeof(url), "https://www.jblanked.com/flipper/api/world/pvp/lobby/remove/");
-    fhttp->state = IDLE;
-    if (!flipper_http_request(fhttp, POST, url, "{\"Content-Type\":\"application/json\"}", payload))
-    {
-        FURI_LOG_E(TAG, "Failed to remove player from lobby");
-        return false;
-    }
-    fhttp->state = RECEIVING;
-    while (fhttp->state != IDLE)
-    {
-        furi_delay_ms(100);
-    }
-    return true;
-}
-
 // Helper function to load an integer
 static bool load_number(const char *path_name, int *value)
 {
@@ -621,7 +584,7 @@ static bool load_int8(const char *path_name, int8_t *value)
 }
 
 // Helper function to load a uint32_t
-static bool load_uint32(const char *path_name, uint32_t *value)
+bool load_uint32(const char *path_name, uint32_t *value)
 {
     if (!path_name || !value)
     {
@@ -1154,37 +1117,40 @@ bool separate_world_data(char *id, FuriString *world_data)
     FuriString *file_npc_data = json_data(world_data, "npc_data");
     if (!file_npc_data)
     {
-        FURI_LOG_E("Game", "Failed to get npc data");
-        return false;
+        FURI_LOG_I("Game", "NPC data is not present");
+        // not every map has npc_data, so we can skip this
     }
+    else
+    {
 
-    snprintf(file_path, sizeof(file_path),
-             STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world/worlds/%s/%s_npc_data.json",
-             id, id);
+        snprintf(file_path, sizeof(file_path),
+                 STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world/worlds/%s/%s_npc_data.json",
+                 id, id);
 
-    if (!storage_file_open(file, file_path, FSAM_WRITE, FSOM_CREATE_ALWAYS))
-    {
-        FURI_LOG_E("Game", "Failed to open file for writing: %s", file_path);
-        storage_file_free(file);
-        furi_record_close(RECORD_STORAGE);
-        furi_string_free(file_npc_data);
-        return false;
-    }
+        if (!storage_file_open(file, file_path, FSAM_WRITE, FSOM_CREATE_ALWAYS))
+        {
+            FURI_LOG_E("Game", "Failed to open file for writing: %s", file_path);
+            storage_file_free(file);
+            furi_record_close(RECORD_STORAGE);
+            furi_string_free(file_npc_data);
+            return false;
+        }
 
-    data_size = furi_string_size(file_npc_data);
-    if (storage_file_write(file, furi_string_get_cstr(file_npc_data), data_size) != data_size)
-    {
-        FURI_LOG_E("Game", "Failed to write npc_data");
-    }
-    storage_file_close(file);
+        data_size = furi_string_size(file_npc_data);
+        if (storage_file_write(file, furi_string_get_cstr(file_npc_data), data_size) != data_size)
+        {
+            FURI_LOG_E("Game", "Failed to write npc_data");
+        }
+        storage_file_close(file);
 
-    furi_string_replace_at(file_npc_data, 0, 1, "");
-    furi_string_replace_at(file_npc_data, furi_string_size(file_npc_data) - 1, 1, "");
-    // include the comma at the end of the npc_data array
-    furi_string_cat_str(file_npc_data, ",");
+        furi_string_replace_at(file_npc_data, 0, 1, "");
+        furi_string_replace_at(file_npc_data, furi_string_size(file_npc_data) - 1, 1, "");
+        // include the comma at the end of the npc_data array
+        furi_string_cat_str(file_npc_data, ",");
 
-    furi_string_remove_str(world_data, furi_string_get_cstr(file_npc_data));
-    furi_string_free(file_npc_data);
+        furi_string_remove_str(world_data, furi_string_get_cstr(file_npc_data));
+        furi_string_free(file_npc_data);
+    }
 
     // Save enemy_data to disk
     FuriString *file_enemy_data = json_data(world_data, "enemy_data");

+ 2 - 0
flip_world/game/storage.h

@@ -4,10 +4,12 @@
 #include <flip_world.h>
 #include <flip_storage/storage.h>
 
+bool save_uint32(const char *path_name, uint32_t value);
 bool save_player_context(PlayerContext *player_context);
 bool save_player_context_api(PlayerContext *player_context, FlipperHTTP *fhttp);
 bool websocket_player_context(PlayerContext *player_context, FlipperHTTP *fhttp);
 bool remove_player_from_lobby(FlipperHTTP *fhttp);
+bool load_uint32(const char *path_name, uint32_t *value);
 bool load_player_context(PlayerContext *player_context);
 bool set_player_context();
 

ファイルの差分が大きいため隠しています
+ 121 - 0
flip_world/game/story.c


+ 7 - 0
flip_world/game/story.h

@@ -0,0 +1,7 @@
+#pragma once
+#include <game/game.h>
+#include "flip_world.h"
+#define STORY_TUTORIAL_STEPS 9
+void story_draw(Entity *player, Canvas *canvas, GameManager *manager);
+void story_update(Entity *player, GameManager *manager);
+const LevelBehaviour *story_world();

ファイルの差分が大きいため隠しています
+ 12 - 15
flip_world/game/world.c


+ 1 - 2
flip_world/game/world.h

@@ -11,7 +11,6 @@
 // Maximum number of world objects
 #define MAX_WORLD_OBJECTS 25
 
-const LevelBehaviour *world_training();
 const LevelBehaviour *world_pvp();
 bool world_json_draw(GameManager *manager, Level *level, const FuriString *json_data);
-FuriString *world_fetch(const char *name);
+FuriString *world_fetch(FlipperHTTP *fhttp, const char *name);

+ 546 - 0
flip_world/update/update.c

@@ -0,0 +1,546 @@
+#include <update/update.h>
+#include <storage/storage.h>
+
+static bool update_is_str(const char *src, const char *dst) { return strcmp(src, dst) == 0; }
+static bool update_save_char(
+    const char *path_name, const char *value)
+{
+    if (!value)
+    {
+        return false;
+    }
+    // Create the directory for saving settings
+    char directory_path[256];
+    snprintf(directory_path, sizeof(directory_path), STORAGE_EXT_PATH_PREFIX "/apps_data/%s", APP_ID);
+
+    // Create the directory
+    Storage *storage = furi_record_open(RECORD_STORAGE);
+    storage_common_mkdir(storage, directory_path);
+    snprintf(directory_path, sizeof(directory_path), STORAGE_EXT_PATH_PREFIX "/apps_data/%s/data", APP_ID);
+    storage_common_mkdir(storage, directory_path);
+
+    // Open the settings file
+    File *file = storage_file_alloc(storage);
+    char file_path[256];
+    snprintf(file_path, sizeof(file_path), STORAGE_EXT_PATH_PREFIX "/apps_data/%s/data/%s.txt", APP_ID, path_name);
+
+    // Open the file in write mode
+    if (!storage_file_open(file, file_path, FSAM_WRITE, FSOM_CREATE_ALWAYS))
+    {
+        FURI_LOG_E(HTTP_TAG, "Failed to open file for writing: %s", file_path);
+        storage_file_free(file);
+        furi_record_close(RECORD_STORAGE);
+        return false;
+    }
+
+    // Write the data to the file
+    size_t data_size = strlen(value) + 1; // Include null terminator
+    if (storage_file_write(file, value, data_size) != data_size)
+    {
+        FURI_LOG_E(HTTP_TAG, "Failed to append data to file");
+        storage_file_close(file);
+        storage_file_free(file);
+        furi_record_close(RECORD_STORAGE);
+        return false;
+    }
+
+    storage_file_close(file);
+    storage_file_free(file);
+    furi_record_close(RECORD_STORAGE);
+
+    return true;
+}
+
+static bool update_load_char(
+    const char *path_name,
+    char *value,
+    size_t value_size)
+{
+    if (!value)
+    {
+        return false;
+    }
+    Storage *storage = furi_record_open(RECORD_STORAGE);
+    File *file = storage_file_alloc(storage);
+
+    char file_path[256];
+    snprintf(file_path, sizeof(file_path), STORAGE_EXT_PATH_PREFIX "/apps_data/%s/data/%s.txt", APP_ID, path_name);
+
+    // Open the file for reading
+    if (!storage_file_open(file, file_path, FSAM_READ, FSOM_OPEN_EXISTING))
+    {
+        storage_file_free(file);
+        furi_record_close(RECORD_STORAGE);
+        return false;
+    }
+
+    // Read data into the buffer
+    size_t read_count = storage_file_read(file, value, value_size);
+    if (storage_file_get_error(file) != FSE_OK)
+    {
+        FURI_LOG_E(HTTP_TAG, "Error reading from file.");
+        storage_file_close(file);
+        storage_file_free(file);
+        furi_record_close(RECORD_STORAGE);
+        return false;
+    }
+
+    // Ensure null-termination
+    value[read_count - 1] = '\0';
+
+    storage_file_close(file);
+    storage_file_free(file);
+    furi_record_close(RECORD_STORAGE);
+
+    return strlen(value) > 0;
+}
+static bool update_json_to_datetime(DateTime *rtc_time, FuriString *str)
+{
+    if (!rtc_time || !str)
+    {
+        FURI_LOG_E(TAG, "rtc_time or str is NULL");
+        return false;
+    }
+    FuriString *hour = get_json_value_furi("hour", str);
+    if (hour)
+    {
+        rtc_time->hour = atoi(furi_string_get_cstr(hour));
+        furi_string_free(hour);
+    }
+    FuriString *minute = get_json_value_furi("minute", str);
+    if (minute)
+    {
+        rtc_time->minute = atoi(furi_string_get_cstr(minute));
+        furi_string_free(minute);
+    }
+    FuriString *second = get_json_value_furi("second", str);
+    if (second)
+    {
+        rtc_time->second = atoi(furi_string_get_cstr(second));
+        furi_string_free(second);
+    }
+    FuriString *day = get_json_value_furi("day", str);
+    if (day)
+    {
+        rtc_time->day = atoi(furi_string_get_cstr(day));
+        furi_string_free(day);
+    }
+    FuriString *month = get_json_value_furi("month", str);
+    if (month)
+    {
+        rtc_time->month = atoi(furi_string_get_cstr(month));
+        furi_string_free(month);
+    }
+    FuriString *year = get_json_value_furi("year", str);
+    if (year)
+    {
+        rtc_time->year = atoi(furi_string_get_cstr(year));
+        furi_string_free(year);
+    }
+    FuriString *weekday = get_json_value_furi("weekday", str);
+    if (weekday)
+    {
+        rtc_time->weekday = atoi(furi_string_get_cstr(weekday));
+        furi_string_free(weekday);
+    }
+    return datetime_validate_datetime(rtc_time);
+}
+
+static FuriString *update_datetime_to_json(DateTime *rtc_time)
+{
+    if (!rtc_time)
+    {
+        FURI_LOG_E(TAG, "rtc_time is NULL");
+        return NULL;
+    }
+    char json[256];
+    snprintf(
+        json,
+        sizeof(json),
+        "{\"hour\":%d,\"minute\":%d,\"second\":%d,\"day\":%d,\"month\":%d,\"year\":%d,\"weekday\":%d}",
+        rtc_time->hour,
+        rtc_time->minute,
+        rtc_time->second,
+        rtc_time->day,
+        rtc_time->month,
+        rtc_time->year,
+        rtc_time->weekday);
+    return furi_string_alloc_set_str(json);
+}
+
+static bool update_save_rtc_time(DateTime *rtc_time)
+{
+    if (!rtc_time)
+    {
+        FURI_LOG_E(TAG, "rtc_time is NULL");
+        return false;
+    }
+    FuriString *json = update_datetime_to_json(rtc_time);
+    if (!json)
+    {
+        FURI_LOG_E(TAG, "Failed to convert DateTime to JSON");
+        return false;
+    }
+    update_save_char("last_checked", furi_string_get_cstr(json));
+    furi_string_free(json);
+    return true;
+}
+
+//
+// Returns true if time_current is one hour (or more) later than the stored last_checked time
+//
+static bool update_is_update_time(DateTime *time_current)
+{
+    if (!time_current)
+    {
+        FURI_LOG_E(TAG, "time_current is NULL");
+        return false;
+    }
+    char last_checked_old[128];
+    if (!update_load_char("last_checked", last_checked_old, sizeof(last_checked_old)))
+    {
+        FURI_LOG_E(TAG, "Failed to load last_checked");
+        FuriString *json = update_datetime_to_json(time_current);
+        if (json)
+        {
+            update_save_char("last_checked", furi_string_get_cstr(json));
+            furi_string_free(json);
+        }
+        return false;
+    }
+
+    DateTime last_updated_time;
+
+    FuriString *last_updated_furi = char_to_furi_string(last_checked_old);
+    if (!last_updated_furi)
+    {
+        FURI_LOG_E(TAG, "Failed to convert char to FuriString");
+        return false;
+    }
+    if (!update_json_to_datetime(&last_updated_time, last_updated_furi))
+    {
+        FURI_LOG_E(TAG, "Failed to convert JSON to DateTime");
+        furi_string_free(last_updated_furi);
+        return false;
+    }
+    furi_string_free(last_updated_furi); // Free after usage.
+
+    bool time_diff = false;
+    // If the date is different assume more than one hour has passed.
+    if (time_current->year != last_updated_time.year ||
+        time_current->month != last_updated_time.month ||
+        time_current->day != last_updated_time.day)
+    {
+        time_diff = true;
+    }
+    else
+    {
+        // For the same day, compute seconds from midnight.
+        int seconds_current = time_current->hour * 3600 + time_current->minute * 60 + time_current->second;
+        int seconds_last = last_updated_time.hour * 3600 + last_updated_time.minute * 60 + last_updated_time.second;
+        if ((seconds_current - seconds_last) >= 3600)
+        {
+            time_diff = true;
+        }
+    }
+
+    return time_diff;
+}
+
+// Sends a request to fetch the last updated date of the app.
+static bool update_last_app_update(FlipperHTTP *fhttp, bool flipper_server)
+{
+    if (!fhttp)
+    {
+        FURI_LOG_E(TAG, "fhttp is NULL");
+        return false;
+    }
+    char url[256];
+    if (flipper_server)
+    {
+        // make sure folder is created
+        char directory_path[256];
+        snprintf(directory_path, sizeof(directory_path), STORAGE_EXT_PATH_PREFIX "/apps_data/%s", APP_ID);
+
+        // Create the directory
+        Storage *storage = furi_record_open(RECORD_STORAGE);
+        storage_common_mkdir(storage, directory_path);
+        snprintf(directory_path, sizeof(directory_path), STORAGE_EXT_PATH_PREFIX "/apps_data/%s/data", APP_ID);
+        storage_common_mkdir(storage, directory_path);
+        snprintf(directory_path, sizeof(directory_path), STORAGE_EXT_PATH_PREFIX "/apps_data/%s/data/last_update_request.txt", APP_ID);
+        storage_simply_remove_recursive(storage, directory_path); // ensure the file is empty
+        furi_record_close(RECORD_STORAGE);
+
+        fhttp->save_received_data = false;
+        fhttp->is_bytes_request = true;
+
+        snprintf(fhttp->file_path, sizeof(fhttp->file_path), STORAGE_EXT_PATH_PREFIX "/apps_data/%s/data/last_update_request.txt", APP_ID);
+        snprintf(url, sizeof(url), "https://raw.githubusercontent.com/flipperdevices/flipper-application-catalog/main/applications/%s/%s/manifest.yml", APP_FOLDER, FAP_ID);
+        return flipper_http_request(fhttp, BYTES, url, "{\"Content-Type\":\"application/json\"}", NULL);
+    }
+    else
+    {
+        snprintf(url, sizeof(url), "https://www.jblanked.com/flipper/api/app/last-updated/%s/", FAP_ID);
+        return flipper_http_request(fhttp, GET, url, "{\"Content-Type\":\"application/json\"}", NULL);
+    }
+}
+
+static bool parse_yaml_version(const char *yaml, char *out_version, size_t out_len)
+{
+    const char *p = strstr(yaml, "\nversion:");
+    if (!p)
+    {
+        // maybe it's the very first line
+        p = yaml;
+    }
+    else
+    {
+        // skip the “\n”
+        p++;
+    }
+    // skip the key name and colon
+    p = strstr(p, "version");
+    if (!p)
+        return false;
+    p += strlen("version");
+    // skip whitespace and colon
+    while (*p == ' ' || *p == ':')
+        p++;
+    // handle optional quote
+    bool quoted = (*p == '"');
+    if (quoted)
+        p++;
+    // copy up until end‐quote or newline/space
+    size_t i = 0;
+    while (*p && i + 1 < out_len)
+    {
+        if ((quoted && *p == '"') ||
+            (!quoted && (*p == '\n' || *p == ' ')))
+        {
+            break;
+        }
+        out_version[i++] = *p++;
+    }
+    out_version[i] = '\0';
+    return (i > 0);
+}
+
+// Parses the server response and returns true if an update is available.
+static bool update_parse_last_app_update(FlipperHTTP *fhttp, DateTime *time_current, bool flipper_server)
+{
+    if (!fhttp)
+    {
+        FURI_LOG_E(TAG, "fhttp is NULL");
+        return false;
+    }
+    if (fhttp->state == ISSUE)
+    {
+        FURI_LOG_E(TAG, "Failed to fetch last app update");
+        return false;
+    }
+    char version_str[32];
+    if (!flipper_server)
+    {
+        if (fhttp->last_response == NULL || strlen(fhttp->last_response) == 0)
+        {
+            FURI_LOG_E(TAG, "fhttp->last_response is NULL or empty");
+            return false;
+        }
+
+        char *app_version = get_json_value("version", fhttp->last_response);
+        if (app_version)
+        {
+            // Save the server app version: it should save something like: 0.8
+            update_save_char("server_app_version", app_version);
+            snprintf(version_str, sizeof(version_str), "%s", app_version);
+            free(app_version);
+        }
+        else
+        {
+            FURI_LOG_E(TAG, "Failed to get app version");
+            return false;
+        }
+    }
+    else
+    {
+        FuriString *manifest_data = flipper_http_load_from_file(fhttp->file_path);
+        if (!manifest_data)
+        {
+            FURI_LOG_E(TAG, "Failed to load app data");
+            return false;
+        }
+        // parse version out of the YAML
+        if (!parse_yaml_version(furi_string_get_cstr(manifest_data), version_str, sizeof(version_str)))
+        {
+            FURI_LOG_E(TAG, "Failed to parse version from YAML manifest");
+            return false;
+        }
+        update_save_char("server_app_version", version_str);
+        furi_string_free(manifest_data);
+    }
+    // Only check for an update if an hour or more has passed.
+    if (update_is_update_time(time_current))
+    {
+        char app_version[32];
+        if (!update_load_char("app_version", app_version, sizeof(app_version)))
+        {
+            FURI_LOG_E(TAG, "Failed to load app version");
+            return false;
+        }
+        // Check if the app version is different from the server version.
+        if (!update_is_str(app_version, version_str))
+        {
+            easy_flipper_dialog("Update available", "New update available!\nPress BACK to download.");
+            return true; // Update available.
+        }
+        FURI_LOG_I(TAG, "No update available");
+        return false; // No update available.
+    }
+    FURI_LOG_I(TAG, "Not enough time has passed since the last update check");
+    return false; // Not yet time to update.
+}
+
+static bool update_get_fap_file(FlipperHTTP *fhttp, bool flipper_server)
+{
+    if (!fhttp)
+    {
+        FURI_LOG_E(TAG, "FlipperHTTP is NULL.");
+        return false;
+    }
+    char url[256];
+    fhttp->save_received_data = false;
+    fhttp->is_bytes_request = true;
+#ifndef FW_ORIGIN_Momentum
+    snprintf(
+        fhttp->file_path,
+        sizeof(fhttp->file_path),
+        STORAGE_EXT_PATH_PREFIX "/apps/%s/%s.fap", APP_FOLDER, FAP_ID);
+#else
+    if (strlen(MOM_FOLDER) == 0)
+        snprintf(
+            fhttp->file_path,
+            sizeof(fhttp->file_path),
+            STORAGE_EXT_PATH_PREFIX "/apps/%s/%s.fap", APP_FOLDER, FAP_ID);
+    else
+        snprintf(
+            fhttp->file_path,
+            sizeof(fhttp->file_path),
+            STORAGE_EXT_PATH_PREFIX "/apps/%s/%s/%s.fap", APP_FOLDER, MOM_FOLDER, FAP_ID);
+#endif
+    if (flipper_server)
+    {
+        uint8_t target;
+        target = furi_hal_version_get_hw_target();
+        uint16_t api_major, api_minor;
+        furi_hal_info_get_api_version(&api_major, &api_minor);
+        snprintf(
+            url,
+            sizeof(url),
+            "https://catalog.flipperzero.one/api/v0/application/version/%s/build/compatible?target=f%d&api=%d.%d",
+            BUILD_ID,
+            target,
+            api_major,
+            api_minor);
+    }
+    else
+    {
+        snprintf(url, sizeof(url), "https://www.jblanked.com/flipper/api/app/download/%s/", FAP_ID);
+    }
+    return flipper_http_request(fhttp, BYTES, url, "{\"Content-Type\": \"application/octet-stream\"}", NULL);
+}
+
+// Updates the app. Uses the supplied current time for validating if update check should proceed.
+static bool update_update_app(FlipperHTTP *fhttp, DateTime *time_current, bool use_flipper_api)
+{
+    if (!fhttp)
+    {
+        FURI_LOG_E(TAG, "fhttp is NULL");
+        return false;
+    }
+    if (!update_last_app_update(fhttp, use_flipper_api))
+    {
+        FURI_LOG_E(TAG, "Failed to fetch last app update");
+        return false;
+    }
+    fhttp->state = RECEIVING;
+    furi_timer_start(fhttp->get_timeout_timer, TIMEOUT_DURATION_TICKS);
+    while (fhttp->state == RECEIVING && furi_timer_is_running(fhttp->get_timeout_timer) > 0)
+    {
+        furi_delay_ms(100);
+    }
+    furi_timer_stop(fhttp->get_timeout_timer);
+    if (update_parse_last_app_update(fhttp, time_current, use_flipper_api))
+    {
+        if (!update_get_fap_file(fhttp, false))
+        {
+            FURI_LOG_E(TAG, "Failed to fetch fap file 1");
+            return false;
+        }
+        fhttp->state = RECEIVING;
+
+        while (fhttp->state == RECEIVING)
+        {
+            furi_delay_ms(100);
+        }
+
+        if (fhttp->state == ISSUE)
+        {
+            FURI_LOG_E(TAG, "Failed to fetch fap file 2");
+            easy_flipper_dialog("Update Error", "Failed to download the\nupdate file.\nPlease try again.");
+            return false;
+        }
+        return true;
+    }
+
+    FURI_LOG_I(TAG, "No update available");
+    return false; // No update available.
+}
+
+// Handles the app update routine. This function obtains the current RTC time,
+// checks the "last_checked" value, and if it is more than one hour old, calls for an update.
+bool update_is_ready(FlipperHTTP *fhttp, bool use_flipper_api)
+{
+    if (!fhttp)
+    {
+        FURI_LOG_E(TAG, "fhttp is NULL");
+        return false;
+    }
+    DateTime rtc_time;
+    furi_hal_rtc_get_datetime(&rtc_time);
+    char last_checked[32];
+    if (!update_load_char("last_checked", last_checked, sizeof(last_checked)))
+    {
+        // First time – save the current time and check for an update.
+        if (!update_save_rtc_time(&rtc_time))
+        {
+            FURI_LOG_E(TAG, "Failed to save RTC time");
+            return false;
+        }
+        return update_update_app(fhttp, &rtc_time, use_flipper_api);
+    }
+    else
+    {
+        // Check if the current RTC time is at least one hour past the stored time.
+        if (update_is_update_time(&rtc_time))
+        {
+            if (!update_update_app(fhttp, &rtc_time, use_flipper_api))
+            {
+                // save the last_checked for the next check.
+                if (!update_save_rtc_time(&rtc_time))
+                {
+                    FURI_LOG_E(TAG, "Failed to save RTC time");
+                    return false;
+                }
+                return false;
+            }
+            // Save the current time for the next check.
+            if (!update_save_rtc_time(&rtc_time))
+            {
+                FURI_LOG_E(TAG, "Failed to save RTC time");
+                return false;
+            }
+            return true;
+        }
+        return false; // No update necessary.
+    }
+}

+ 8 - 0
flip_world/update/update.h

@@ -0,0 +1,8 @@
+#pragma once
+#include <flip_world.h>
+#define BUILD_ID "676900d983aa88302bc114c6"
+#define APP_ID "flip_world"
+#define FAP_ID "flip_world"
+#define APP_FOLDER "GPIO"
+#define MOM_FOLDER "FlipperHTTP"
+bool update_is_ready(FlipperHTTP *fhttp, bool use_flipper_api);

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません