diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 912223e500..fbab30ba54 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -37,10 +37,12 @@ jobs: liballegro4-dev \ libcurl4-openssl-dev \ libfontconfig-dev \ + libharfbuzz-dev \ libicu-dev \ liblzma-dev \ liblzo2-dev \ libsdl2-dev \ + nlohmann-json3-dev \ zlib1g-dev \ # EOF echo "::endgroup::" diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml index 78080af80b..30c2a04ee4 100644 --- a/.github/workflows/release-linux.yml +++ b/.github/workflows/release-linux.yml @@ -2,6 +2,11 @@ name: Release (Linux) on: workflow_call: + inputs: + survey_key: + required: false + type: string + default: "" jobs: linux: @@ -119,6 +124,7 @@ jobs: cmake ${GITHUB_WORKSPACE} \ -DCMAKE_TOOLCHAIN_FILE=/vcpkg/scripts/buildsystems/vcpkg.cmake \ -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DOPTION_SURVEY_KEY=${{ inputs.survey_key }} \ -DOPTION_PACKAGE_DEPENDENCIES=ON \ # EOF echo "::endgroup::" diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml index 0654e99424..596692eaab 100644 --- a/.github/workflows/release-macos.yml +++ b/.github/workflows/release-macos.yml @@ -2,6 +2,11 @@ name: Release (MacOS) on: workflow_call: + inputs: + survey_key: + required: false + type: string + default: "" jobs: macos: @@ -104,6 +109,7 @@ jobs: -DCMAKE_TOOLCHAIN_FILE=/usr/local/share/vcpkg/scripts/buildsystems/vcpkg.cmake \ -DHOST_BINARY_DIR=${GITHUB_WORKSPACE}/build-host \ -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DOPTION_SURVEY_KEY=${{ inputs.survey_key }} \ # EOF echo "::endgroup::" @@ -124,6 +130,7 @@ jobs: -DCMAKE_TOOLCHAIN_FILE=/usr/local/share/vcpkg/scripts/buildsystems/vcpkg.cmake \ -DHOST_BINARY_DIR=${GITHUB_WORKSPACE}/build-host \ -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DOPTION_SURVEY_KEY=${{ inputs.survey_key }} \ -DCPACK_BUNDLE_APPLE_CERT_APP=${{ secrets.APPLE_DEVELOPER_CERTIFICATE_ID }} \ "-DCPACK_BUNDLE_APPLE_CODESIGN_PARAMETER=--deep -f --options runtime" \ -DAPPLE_UNIVERSAL_PACKAGE=1 \ diff --git a/.github/workflows/release-source.yml b/.github/workflows/release-source.yml index 2213efed25..9cf9c779a1 100644 --- a/.github/workflows/release-source.yml +++ b/.github/workflows/release-source.yml @@ -11,6 +11,8 @@ on: value: ${{ jobs.source.outputs.trigger_type }} folder: value: ${{ jobs.source.outputs.folder }} + survey_key: + value: ${{ jobs.source.outputs.survey_key }} jobs: source: @@ -23,6 +25,7 @@ jobs: is_tag: ${{ steps.metadata.outputs.is_tag }} trigger_type: ${{ steps.metadata.outputs.trigger_type }} folder: ${{ steps.metadata.outputs.folder }} + survey_key: ${{ steps.survey_key.outputs.survey_key }} steps: - name: Checkout (Release) @@ -146,6 +149,19 @@ jobs: FOLDER_NIGHTLIES: openttd-nightlies FOLDER_BRANCHES: openttd-branches + - name: Generate survey key + id: survey_key + run: | + PAYLOAD='{"version":"${{ steps.metadata.outputs.version }}","type":"${{ vars.SURVEY_TYPE }}"}' + + echo "${{ secrets.SURVEY_SIGNING_KEY }}" > survey_signing_key.pem + SIGNATURE=$(echo -n "${PAYLOAD}" | openssl dgst -sha256 -sign survey_signing_key.pem | base64 -w0) + rm -f survey_signing_key.pem + + SURVEY_KEY=$(curl -f -s -X POST -d "${PAYLOAD}" -H "Content-Type: application/json" -H "X-Signature: ${SIGNATURE}" https://survey-participate.openttd.org/create-survey-key/${{ vars.SURVEY_TYPE }}) + + echo "survey_key=${SURVEY_KEY}" >> $GITHUB_OUTPUT + - name: Remove VCS information run: | rm -rf .git diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index 2f934139e1..c55a4f8d4a 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -3,6 +3,10 @@ name: Release (Windows) on: workflow_call: inputs: + survey_key: + required: false + type: string + default: "" is_tag: required: true type: string @@ -129,6 +133,7 @@ jobs: -DOPTION_USE_NSIS=ON \ -DHOST_BINARY_DIR=${GITHUB_WORKSPACE}/build-host \ -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DOPTION_SURVEY_KEY=${{ inputs.survey_key }} \ -DWINDOWS_CERTIFICATE_COMMON_NAME="${WINDOWS_CERTIFICATE_COMMON_NAME}" \ # EOF echo "::endgroup::" @@ -153,6 +158,7 @@ jobs: -DCMAKE_TOOLCHAIN_FILE="c:\vcpkg\scripts\buildsystems\vcpkg.cmake" \ -DHOST_BINARY_DIR=${GITHUB_WORKSPACE}/build-host \ -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DOPTION_SURVEY_KEY=${{ inputs.survey_key }} \ -DWINDOWS_CERTIFICATE_COMMON_NAME="${WINDOWS_CERTIFICATE_COMMON_NAME}" \ # EOF echo "::endgroup::" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bc0225153f..5aeccfb39d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,6 +38,9 @@ jobs: uses: ./.github/workflows/release-linux.yml secrets: inherit + with: + survey_key: ${{ needs.source.outputs.survey_key }} + macos: name: MacOS needs: source @@ -45,6 +48,9 @@ jobs: uses: ./.github/workflows/release-macos.yml secrets: inherit + with: + survey_key: ${{ needs.source.outputs.survey_key }} + windows: name: Windows needs: source @@ -54,6 +60,7 @@ jobs: with: is_tag: ${{ needs.source.outputs.is_tag }} + survey_key: ${{ needs.source.outputs.survey_key }} windows-store: name: Windows Store diff --git a/cmake/Options.cmake b/cmake/Options.cmake index 42d1127906..371b03d841 100644 --- a/cmake/Options.cmake +++ b/cmake/Options.cmake @@ -71,6 +71,8 @@ function(set_options) if (OPTION_DOCS_ONLY) set(OPTION_TOOLS_ONLY ON PARENT_SCOPE) endif() + + option(OPTION_SURVEY_KEY "Survey-key to use for the opt-in survey (empty if you have none)" "") endfunction() # Show the values of the generic options. @@ -84,6 +86,12 @@ function(show_options) message(STATUS "Option Use assert - ${OPTION_USE_ASSERTS}") message(STATUS "Option Use threads - ${OPTION_USE_THREADS}") message(STATUS "Option Use NSIS - ${OPTION_USE_NSIS}") + + if(OPTION_SURVEY_KEY) + message(STATUS "Option Survey Key - USED") + else() + message(STATUS "Option Survey Key - NOT USED") + endif() endfunction() # Add the definitions for the options that are selected. @@ -104,4 +112,8 @@ function(add_definitions_based_on_options) else() add_definitions(-DNDEBUG) endif() + + if(OPTION_SURVEY_KEY) + add_definitions(-DSURVEY_KEY="${OPTION_SURVEY_KEY}") + endif() endfunction() diff --git a/src/ai/ai_gui.cpp b/src/ai/ai_gui.cpp index ac2daee27d..a74586246d 100644 --- a/src/ai/ai_gui.cpp +++ b/src/ai/ai_gui.cpp @@ -183,7 +183,7 @@ struct AIConfigWindow : public Window { void OnClick(Point pt, int widget, int click_count) override { - if (widget >= WID_AIC_TEXTFILE && widget < WID_AIC_TEXTFILE + TFT_END) { + if (widget >= WID_AIC_TEXTFILE && widget < WID_AIC_TEXTFILE + TFT_CONTENT_END) { if (this->selected_slot == INVALID_COMPANY || AIConfig::GetConfig(this->selected_slot) == nullptr) return; ShowScriptTextfileWindow((TextfileType)(widget - WID_AIC_TEXTFILE), this->selected_slot); @@ -284,7 +284,7 @@ struct AIConfigWindow : public Window { this->SetWidgetDisabledState(WID_AIC_MOVE_UP, this->selected_slot == INVALID_COMPANY || !IsEditable((CompanyID)(this->selected_slot - 1))); this->SetWidgetDisabledState(WID_AIC_MOVE_DOWN, this->selected_slot == INVALID_COMPANY || !IsEditable((CompanyID)(this->selected_slot + 1))); - for (TextfileType tft = TFT_BEGIN; tft < TFT_END; tft++) { + for (TextfileType tft = TFT_CONTENT_BEGIN; tft < TFT_CONTENT_END; tft++) { this->SetWidgetDisabledState(WID_AIC_TEXTFILE + tft, this->selected_slot == INVALID_COMPANY || !AIConfig::GetConfig(this->selected_slot)->GetTextfile(tft, this->selected_slot).has_value()); } } diff --git a/src/crashlog.cpp b/src/crashlog.cpp index c4ab30a8a0..e3be578da1 100644 --- a/src/crashlog.cpp +++ b/src/crashlog.cpp @@ -23,6 +23,7 @@ #include "screenshot.h" #include "gfx_func.h" #include "network/network.h" +#include "network/network_survey.h" #include "language.h" #include "fontcache.h" #include "news_gui.h" @@ -511,6 +512,10 @@ bool CrashLog::MakeCrashLog() const printf("Writing crash screenshot failed.\n\n"); } + if (_game_mode == GM_NORMAL) { + _survey.Transmit(NetworkSurveyHandler::Reason::CRASH, true); + } + return ret; } diff --git a/src/game/game_gui.cpp b/src/game/game_gui.cpp index e4c24e7f25..8b0c1a3333 100644 --- a/src/game/game_gui.cpp +++ b/src/game/game_gui.cpp @@ -240,7 +240,7 @@ struct GSConfigWindow : public Window { void OnClick(Point pt, int widget, int click_count) override { - if (widget >= WID_GSC_TEXTFILE && widget < WID_GSC_TEXTFILE + TFT_END) { + if (widget >= WID_GSC_TEXTFILE && widget < WID_GSC_TEXTFILE + TFT_CONTENT_END) { if (GameConfig::GetConfig() == nullptr) return; ShowScriptTextfileWindow((TextfileType)(widget - WID_GSC_TEXTFILE), (CompanyID)OWNER_DEITY); @@ -404,7 +404,7 @@ struct GSConfigWindow : public Window { this->SetWidgetDisabledState(WID_GSC_CHANGE, (_game_mode == GM_NORMAL) || !IsEditable()); - for (TextfileType tft = TFT_BEGIN; tft < TFT_END; tft++) { + for (TextfileType tft = TFT_CONTENT_BEGIN; tft < TFT_CONTENT_END; tft++) { this->SetWidgetDisabledState(WID_GSC_TEXTFILE + tft, !GameConfig::GetConfig()->GetTextfile(tft, (CompanyID)OWNER_DEITY).has_value()); } this->RebuildVisibleSettings(); diff --git a/src/gfx.cpp b/src/gfx.cpp index 53ca6ab349..f9d587e258 100644 --- a/src/gfx.cpp +++ b/src/gfx.cpp @@ -47,6 +47,7 @@ bool _screen_disable_anim = false; ///< Disable palette animation (important f std::atomic _exit_game; GameMode _game_mode; SwitchMode _switch_mode; ///< The next mainloop command. +std::chrono::steady_clock::time_point _switch_mode_time; ///< The time when the switch mode was requested. PauseMode _pause_mode; Palette _cur_palette; diff --git a/src/intro_gui.cpp b/src/intro_gui.cpp index 1d84732bf7..f445b0d62d 100644 --- a/src/intro_gui.cpp +++ b/src/intro_gui.cpp @@ -17,6 +17,7 @@ #include "genworld.h" #include "network/network_gui.h" #include "network/network_content.h" +#include "network/network_survey.h" #include "landscape_type.h" #include "landscape.h" #include "strings_func.h" @@ -504,7 +505,10 @@ void ShowSelectGameWindow() static void AskExitGameCallback(Window *w, bool confirmed) { - if (confirmed) _exit_game = true; + if (confirmed) { + _survey.Transmit(NetworkSurveyHandler::Reason::EXIT, true); + _exit_game = true; + } } void AskExitGame() diff --git a/src/lang/english.txt b/src/lang/english.txt index 18c9909454..f5ed779815 100644 --- a/src/lang/english.txt +++ b/src/lang/english.txt @@ -1041,6 +1041,14 @@ STR_GAME_OPTIONS_GUI_SCALE_3X :3x STR_GAME_OPTIONS_GUI_SCALE_4X :4x STR_GAME_OPTIONS_GUI_SCALE_5X :5x +STR_GAME_OPTIONS_PARTICIPATE_SURVEY_FRAME :{BLACK}Automated survey +STR_GAME_OPTIONS_PARTICIPATE_SURVEY :{BLACK}Participate in automated survey +STR_GAME_OPTIONS_PARTICIPATE_SURVEY_TOOLTIP :{BLACK}When enabled, OpenTTD will transmit a survey when leaving a game +STR_GAME_OPTIONS_PARTICIPATE_SURVEY_LINK :{BLACK}About survey and privacy +STR_GAME_OPTIONS_PARTICIPATE_SURVEY_LINK_TOOLTIP :{BLACK}This opens a browser with more information about the automated survey +STR_GAME_OPTIONS_PARTICIPATE_SURVEY_PREVIEW :{BLACK}Preview survey result +STR_GAME_OPTIONS_PARTICIPATE_SURVEY_PREVIEW_TOOLTIP :{BLACK}Show the survey result of the current running game + STR_GAME_OPTIONS_GRAPHICS :{BLACK}Graphics STR_GAME_OPTIONS_REFRESH_RATE :{BLACK}Display refresh rate @@ -2402,6 +2410,13 @@ STR_NETWORK_ASK_RELAY_NO :{BLACK}No STR_NETWORK_ASK_RELAY_YES_ONCE :{BLACK}Yes, this once STR_NETWORK_ASK_RELAY_YES_ALWAYS :{BLACK}Yes, don't ask again +STR_NETWORK_ASK_SURVEY_CAPTION :Participate in automated survey? +STR_NETWORK_ASK_SURVEY_TEXT :Would you like to participate in the automated survey?{}OpenTTD will transmit a survey when leaving a game.{}You can change this at any time under "Game Options". +STR_NETWORK_ASK_SURVEY_PREVIEW :Preview survey result +STR_NETWORK_ASK_SURVEY_LINK :About survey and privacy +STR_NETWORK_ASK_SURVEY_NO :No +STR_NETWORK_ASK_SURVEY_YES :Yes + STR_NETWORK_SPECTATORS :Spectators # Network set password @@ -4654,10 +4669,11 @@ STR_TEXTFILE_WRAP_TEXT_TOOLTIP :{BLACK}Wrap the STR_TEXTFILE_VIEW_README :{BLACK}View readme STR_TEXTFILE_VIEW_CHANGELOG :{BLACK}Changelog STR_TEXTFILE_VIEW_LICENCE :{BLACK}Licence -###length 3 +###length 4 STR_TEXTFILE_README_CAPTION :{WHITE}{STRING} readme of {RAW_STRING} STR_TEXTFILE_CHANGELOG_CAPTION :{WHITE}{STRING} changelog of {RAW_STRING} STR_TEXTFILE_LICENCE_CAPTION :{WHITE}{STRING} licence of {RAW_STRING} +STR_TEXTFILE_SURVEY_RESULT_CAPTION :{WHITE}Preview of survey result # Vehicle loading indicators diff --git a/src/misc.cpp b/src/misc.cpp index b8748d7b3b..5169a6dfc5 100644 --- a/src/misc.cpp +++ b/src/misc.cpp @@ -31,9 +31,11 @@ #include "town_kdtree.h" #include "viewport_kdtree.h" #include "newgrf_profiling.h" +#include "3rdparty/md5/md5.h" #include "safeguards.h" +std::string _savegame_id; ///< Unique ID of the current savegame. extern TileIndex _cur_tileloop_tile; extern void MakeNewgameSettingsLive(); @@ -56,6 +58,40 @@ void InitializeCheats(); void InitializeNPF(); void InitializeOldNames(); +/** + * Generate an unique ID. + * + * It isn't as much of an unique ID as we would like, but our random generator + * can only produce 32bit random numbers. + * That is why we combine InteractiveRandom with the current (steady) clock. + * The first to add a bit of randomness, the second to ensure you can't get + * the same unique ID when you run it twice from the same state at different + * times. + * + * This makes it unlikely that two users generate the same ID for different + * subjects. But as this is not an UUID, so it can't be ruled out either. + */ +std::string GenerateUid(std::string_view subject) +{ + auto current_time = std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count(); + std::string coding_string = fmt::format("{}{}{}", InteractiveRandom(), current_time, subject); + + Md5 checksum; + uint8 digest[16]; + checksum.Append(coding_string.c_str(), coding_string.length()); + checksum.Finish(digest); + + return MD5SumToString(digest); +} + +/** + * Generate an unique savegame ID. + */ +void GenerateSavegameId() +{ + _savegame_id = GenerateUid("OpenTTD Savegame ID"); +} + void InitializeGame(uint size_x, uint size_y, bool reset_date, bool reset_settings) { /* Make sure there isn't any window that can influence anything diff --git a/src/network/CMakeLists.txt b/src/network/CMakeLists.txt index 07a4dfd97b..5b97de5cd2 100644 --- a/src/network/CMakeLists.txt +++ b/src/network/CMakeLists.txt @@ -28,6 +28,8 @@ add_files( network_server.h network_stun.cpp network_stun.h + network_survey.cpp + network_survey.h network_turn.cpp network_turn.h network_type.h diff --git a/src/network/core/config.cpp b/src/network/core/config.cpp index 21de508ccf..8451ecac58 100644 --- a/src/network/core/config.cpp +++ b/src/network/core/config.cpp @@ -66,3 +66,13 @@ const char *NetworkContentMirrorUriString() { return GetEnv("OTTD_CONTENT_MIRROR_URI", "https://binaries.openttd.org/bananas"); } + +/** + * Get the URI string for the survey from the environment variable OTTD_SURVEY_URI, + * or when it has not been set a hard coded URI of the production server. + * @return The survey's URI string. + */ +const char *NetworkSurveyUriString() +{ + return GetEnv("OTTD_SURVEY_URI", "https://survey-participate.openttd.org/"); +} diff --git a/src/network/core/config.h b/src/network/core/config.h index 7571f05a54..023b96a044 100644 --- a/src/network/core/config.h +++ b/src/network/core/config.h @@ -16,6 +16,7 @@ const char *NetworkCoordinatorConnectionString(); const char *NetworkStunConnectionString(); const char *NetworkContentServerConnectionString(); const char *NetworkContentMirrorUriString(); +const char *NetworkSurveyUriString(); static const uint16 NETWORK_COORDINATOR_SERVER_PORT = 3976; ///< The default port of the Game Coordinator server (TCP) static const uint16 NETWORK_STUN_SERVER_PORT = 3975; ///< The default port of the STUN server (TCP) @@ -26,6 +27,8 @@ static const uint16 NETWORK_ADMIN_PORT = 3977; ///< The d static const uint16 NETWORK_DEFAULT_DEBUGLOG_PORT = 3982; ///< The default port debug-log is sent to (TCP) static const uint16 UDP_MTU = 1460; ///< Number of bytes we can pack in a single UDP packet + +static const std::string NETWORK_SURVEY_DETAILS_LINK = "https://survey.openttd.org/participate"; ///< Link with more details & privacy statement of the survey. /* * Technically a TCP packet could become 64kiB, however the high bit is kept so it becomes possible in the future * to go to (significantly) larger packets if needed. This would entail a strategy such as employed for UTF-8. @@ -46,6 +49,7 @@ static const uint16 COMPAT_MTU = 1460; ///< Numbe static const byte NETWORK_GAME_ADMIN_VERSION = 3; ///< What version of the admin network do we use? static const byte NETWORK_GAME_INFO_VERSION = 6; ///< What version of game-info do we use? static const byte NETWORK_COORDINATOR_VERSION = 6; ///< What version of game-coordinator-protocol do we use? +static const byte NETWORK_SURVEY_VERSION = 1; ///< What version of the survey do we use? static const uint NETWORK_NAME_LENGTH = 80; ///< The maximum length of the server name and map name, in bytes including '\0' static const uint NETWORK_COMPANY_NAME_LENGTH = 128; ///< The maximum length of the company name, in bytes including '\0' diff --git a/src/network/core/http.h b/src/network/core/http.h index 78b5be87af..d851faf9c6 100644 --- a/src/network/core/http.h +++ b/src/network/core/http.h @@ -14,6 +14,8 @@ #include "tcp.h" +constexpr int HTTP_429_TOO_MANY_REQUESTS = 429; + /** Callback for when the HTTP handler has something to tell us. */ struct HTTPCallback { /** diff --git a/src/network/core/http_curl.cpp b/src/network/core/http_curl.cpp index 4b8337b411..f8f591eaed 100644 --- a/src/network/core/http_curl.cpp +++ b/src/network/core/http_curl.cpp @@ -116,6 +116,7 @@ void HttpThread() /* Reset to default settings. */ curl_easy_reset(curl); + curl_slist *headers = nullptr; if (_debug_net_level >= 5) { curl_easy_setopt(curl, CURLOPT_VERBOSE, 1L); @@ -146,8 +147,16 @@ void HttpThread() /* Prepare POST body and URI. */ if (!request->data.empty()) { + /* When the payload starts with a '{', it is a JSON payload. */ + if (StrStartsWith(request->data, "{")) { + headers = curl_slist_append(headers, "Content-Type: application/json"); + } else { + headers = curl_slist_append(headers, "Content-Type: application/x-www-form-urlencoded"); + } + curl_easy_setopt(curl, CURLOPT_POST, 1L); curl_easy_setopt(curl, CURLOPT_POSTFIELDS, request->data.c_str()); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); } curl_easy_setopt(curl, CURLOPT_URL, request->uri.c_str()); @@ -174,11 +183,17 @@ void HttpThread() /* Perform the request. */ CURLcode res = curl_easy_perform(curl); + curl_slist_free_all(headers); + if (res == CURLE_OK) { Debug(net, 1, "HTTP request succeeded"); request->callback->OnReceiveData(nullptr, 0); } else { - Debug(net, (request->callback->IsCancelled() || _http_thread_exit) ? 1 : 0, "HTTP request failed: {}", curl_easy_strerror(res)); + long status_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status_code); + + /* No need to be verbose about rate limiting. */ + Debug(net, (request->callback->IsCancelled() || _http_thread_exit || status_code == HTTP_429_TOO_MANY_REQUESTS) ? 1 : 0, "HTTP request failed: status_code: {}, error: {}", status_code, curl_easy_strerror(res)); request->callback->OnFailure(); } } diff --git a/src/network/core/http_winhttp.cpp b/src/network/core/http_winhttp.cpp index 9fc28d62cb..0d4744a808 100644 --- a/src/network/core/http_winhttp.cpp +++ b/src/network/core/http_winhttp.cpp @@ -131,7 +131,8 @@ void NetworkHTTPRequest::WinHttpCallback(DWORD code, void *info, DWORD length) /* If there is any error, we simply abort the request. */ if (status_code >= 400) { - Debug(net, 0, "HTTP request failed: status-code {}", status_code); + /* No need to be verbose about rate limiting. */ + Debug(net, status_code == HTTP_429_TOO_MANY_REQUESTS ? 1 : 0, "HTTP request failed: status-code {}", status_code); this->finished = true; this->callback->OnFailure(); return; @@ -242,7 +243,9 @@ void NetworkHTTPRequest::Connect() if (data.empty()) { WinHttpSendRequest(this->request, WINHTTP_NO_ADDITIONAL_HEADERS, 0, WINHTTP_NO_REQUEST_DATA, 0, 0, reinterpret_cast(this)); } else { - WinHttpSendRequest(this->request, L"Content-Type: application/x-www-form-urlencoded\r\n", -1, const_cast(data.c_str()), static_cast(data.size()), static_cast(data.size()), reinterpret_cast(this)); + /* When the payload starts with a '{', it is a JSON payload. */ + LPCWSTR content_type = StrStartsWith(data, "{") ? L"Content-Type: application/json\r\n" : L"Content-Type: application/x-www-form-urlencoded\r\n"; + WinHttpSendRequest(this->request, content_type, -1, const_cast(data.c_str()), static_cast(data.size()), static_cast(data.size()), reinterpret_cast(this)); } } diff --git a/src/network/network.cpp b/src/network/network.cpp index 4b0ef622d3..b12b5b440c 100644 --- a/src/network/network.cpp +++ b/src/network/network.cpp @@ -85,6 +85,8 @@ static_assert((int)NETWORK_COMPANY_NAME_LENGTH == MAX_LENGTH_COMPANY_NAME_CHARS /** The amount of clients connected */ byte _network_clients_connected = 0; +extern std::string GenerateUid(std::string_view subject); + /** * Return whether there is any client connected or trying to connect at all. * @return whether we have any client activity @@ -1204,24 +1206,7 @@ void NetworkGameLoop() static void NetworkGenerateServerId() { - Md5 checksum; - uint8 digest[16]; - char hex_output[16 * 2 + 1]; - char coding_string[NETWORK_NAME_LENGTH]; - int di; - - seprintf(coding_string, lastof(coding_string), "%d%s", (uint)Random(), "OpenTTD Server ID"); - - /* Generate the MD5 hash */ - checksum.Append((const uint8*)coding_string, strlen(coding_string)); - checksum.Finish(digest); - - for (di = 0; di < 16; ++di) { - seprintf(hex_output + di * 2, lastof(hex_output), "%02x", digest[di]); - } - - /* _settings_client.network.network_id is our id */ - _settings_client.network.network_id = hex_output; + _settings_client.network.network_id = GenerateUid("OpenTTD Server ID"); } class TCPNetworkDebugConnecter : TCPConnecter { diff --git a/src/network/network_content_gui.cpp b/src/network/network_content_gui.cpp index 33950e43dc..ff049d2d58 100644 --- a/src/network/network_content_gui.cpp +++ b/src/network/network_content_gui.cpp @@ -790,7 +790,7 @@ public: void OnClick(Point pt, int widget, int click_count) override { - if (widget >= WID_NCL_TEXTFILE && widget < WID_NCL_TEXTFILE + TFT_END) { + if (widget >= WID_NCL_TEXTFILE && widget < WID_NCL_TEXTFILE + TFT_CONTENT_END) { if (this->selected == nullptr || this->selected->state != ContentInfo::ALREADY_HERE) return; ShowContentTextfileWindow((TextfileType)(widget - WID_NCL_TEXTFILE), this->selected); @@ -997,7 +997,7 @@ public: this->SetWidgetDisabledState(WID_NCL_SELECT_ALL, !show_select_all); this->SetWidgetDisabledState(WID_NCL_SELECT_UPDATE, !show_select_upgrade); this->SetWidgetDisabledState(WID_NCL_OPEN_URL, this->selected == nullptr || this->selected->url.empty()); - for (TextfileType tft = TFT_BEGIN; tft < TFT_END; tft++) { + for (TextfileType tft = TFT_CONTENT_BEGIN; tft < TFT_CONTENT_END; tft++) { this->SetWidgetDisabledState(WID_NCL_TEXTFILE + tft, this->selected == nullptr || this->selected->state != ContentInfo::ALREADY_HERE || !this->selected->GetTextfile(tft).has_value()); } diff --git a/src/network/network_gui.cpp b/src/network/network_gui.cpp index 2771d0c074..6353b899d2 100644 --- a/src/network/network_gui.cpp +++ b/src/network/network_gui.cpp @@ -18,6 +18,7 @@ #include "network_content.h" #include "network_server.h" #include "network_coordinator.h" +#include "network_survey.h" #include "../gui.h" #include "network_udp.h" #include "../window_func.h" @@ -38,6 +39,7 @@ #include "../timer/timer.h" #include "../timer/timer_window.h" #include "../timer/timer_game_calendar.h" +#include "../textfile_gui.h" #include "../widgets/network_widget.h" @@ -2515,3 +2517,119 @@ void ShowNetworkAskRelay(const std::string &server_connection_string, const std: Window *parent = GetMainWindow(); new NetworkAskRelayWindow(&_network_ask_relay_desc, parent, server_connection_string, relay_connection_string, token); } + +/** + * Window used for asking if the user wants to participate in the automated survey. + */ +struct NetworkAskSurveyWindow : public Window { + NetworkAskSurveyWindow(WindowDesc *desc, Window *parent) : + Window(desc) + { + this->parent = parent; + this->InitNested(0); + } + + void UpdateWidgetSize(int widget, Dimension *size, const Dimension &padding, Dimension *fill, Dimension *resize) override + { + if (widget == WID_NAS_TEXT) { + *size = GetStringBoundingBox(STR_NETWORK_ASK_SURVEY_TEXT); + size->width += WidgetDimensions::scaled.frametext.Horizontal(); + size->height += WidgetDimensions::scaled.frametext.Vertical(); + } + } + + void DrawWidget(const Rect &r, int widget) const override + { + if (widget == WID_NAS_TEXT) { + DrawStringMultiLine(r.Shrink(WidgetDimensions::scaled.frametext), STR_NETWORK_ASK_SURVEY_TEXT, TC_BLACK, SA_CENTER); + } + } + + void FindWindowPlacementAndResize(int def_width, int def_height) override + { + /* Position query window over the calling window, ensuring it's within screen bounds. */ + this->left = Clamp(parent->left + (parent->width / 2) - (this->width / 2), 0, _screen.width - this->width); + this->top = Clamp(parent->top + (parent->height / 2) - (this->height / 2), 0, _screen.height - this->height); + this->SetDirty(); + } + + void OnClick(Point pt, int widget, int click_count) override + { + switch (widget) { + case WID_NAS_PREVIEW: + ShowSurveyResultTextfileWindow(); + break; + + case WID_NAS_LINK: + OpenBrowser(NETWORK_SURVEY_DETAILS_LINK.c_str()); + break; + + case WID_NAS_NO: + _settings_client.network.participate_survey = PS_NO; + this->Close(); + break; + + case WID_NAS_YES: + _settings_client.network.participate_survey = PS_YES; + this->Close(); + break; + } + } +}; + +static const NWidgetPart _nested_network_ask_survey_widgets[] = { + NWidget(NWID_HORIZONTAL), + NWidget(WWT_CLOSEBOX, COLOUR_GREY), + NWidget(WWT_CAPTION, COLOUR_GREY, WID_NAS_CAPTION), SetDataTip(STR_NETWORK_ASK_SURVEY_CAPTION, STR_NULL), + EndContainer(), + NWidget(WWT_PANEL, COLOUR_GREY), SetPIP(0, 4, 8), + NWidget(WWT_TEXT, COLOUR_GREY, WID_NAS_TEXT), SetAlignment(SA_HOR_CENTER), SetFill(1, 1), + NWidget(NWID_HORIZONTAL, NC_EQUALSIZE), SetPIP(10, 15, 10), + NWidget(WWT_PUSHTXTBTN, COLOUR_WHITE, WID_NAS_PREVIEW), SetMinimalSize(71, 12), SetFill(1, 1), SetDataTip(STR_NETWORK_ASK_SURVEY_PREVIEW, STR_NULL), + NWidget(WWT_PUSHTXTBTN, COLOUR_WHITE, WID_NAS_LINK), SetMinimalSize(71, 12), SetFill(1, 1), SetDataTip(STR_NETWORK_ASK_SURVEY_LINK, STR_NULL), + EndContainer(), + NWidget(NWID_HORIZONTAL, NC_EQUALSIZE), SetPIP(10, 15, 10), + NWidget(WWT_PUSHTXTBTN, COLOUR_GREY, WID_NAS_NO), SetMinimalSize(71, 12), SetFill(1, 1), SetDataTip(STR_NETWORK_ASK_SURVEY_NO, STR_NULL), + NWidget(WWT_PUSHTXTBTN, COLOUR_GREY, WID_NAS_YES), SetMinimalSize(71, 12), SetFill(1, 1), SetDataTip(STR_NETWORK_ASK_SURVEY_YES, STR_NULL), + EndContainer(), + EndContainer(), +}; + +static WindowDesc _network_ask_survey_desc( + WDP_CENTER, nullptr, 0, 0, + WC_NETWORK_ASK_SURVEY, WC_NONE, + WDF_MODAL, + _nested_network_ask_survey_widgets, lengthof(_nested_network_ask_survey_widgets) +); + +/** + * Show a modal confirmation window with "no" / "preview" / "yes" buttons. + */ +void ShowNetworkAskSurvey() +{ + /* If we can't send a survey, don't ask the question. */ + if constexpr (!NetworkSurveyHandler::IsSurveyPossible()) return; + + CloseWindowByClass(WC_NETWORK_ASK_SURVEY); + + Window *parent = GetMainWindow(); + new NetworkAskSurveyWindow(&_network_ask_survey_desc, parent); +} + +/** Window for displaying the textfile of a survey result. */ +struct SurveyResultTextfileWindow : public TextfileWindow { + const GRFConfig *grf_config; ///< View the textfile of this GRFConfig. + + SurveyResultTextfileWindow(TextfileType file_type) : TextfileWindow(file_type) + { + auto result = _survey.CreatePayload(NetworkSurveyHandler::Reason::PREVIEW, true); + this->LoadText(result); + this->InvalidateData(); + } +}; + +void ShowSurveyResultTextfileWindow() +{ + CloseWindowById(WC_TEXTFILE, TFT_SURVEY_RESULT); + new SurveyResultTextfileWindow(TFT_SURVEY_RESULT); +} diff --git a/src/network/network_gui.h b/src/network/network_gui.h index 24cdbf7e76..4cd6ef4f19 100644 --- a/src/network/network_gui.h +++ b/src/network/network_gui.h @@ -24,7 +24,8 @@ void ShowNetworkGameWindow(); void ShowClientList(); void ShowNetworkCompanyPasswordWindow(Window *parent); void ShowNetworkAskRelay(const std::string &server_connection_string, const std::string &relay_connection_string, const std::string &token); - +void ShowNetworkAskSurvey(); +void ShowSurveyResultTextfileWindow(); /** Company information stored at the client side */ struct NetworkCompanyInfo : NetworkCompanyStats { diff --git a/src/network/network_survey.cpp b/src/network/network_survey.cpp new file mode 100644 index 0000000000..aa9aa84f11 --- /dev/null +++ b/src/network/network_survey.cpp @@ -0,0 +1,397 @@ +/* + * This file is part of OpenTTD. + * OpenTTD is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 2. + * OpenTTD is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with OpenTTD. If not, see . + */ + +/** @file network_survey.cpp Opt-in survey part of the network protocol. */ + +#include "../stdafx.h" +#include "network_survey.h" +#include "settings_table.h" +#include "network.h" +#include "../debug.h" +#include "../rev.h" +#include "../settings_type.h" +#include "../timer/timer_game_tick.h" + +#include "../currency.h" +#include "../fontcache.h" +#include "../language.h" + +#include "../ai/ai_info.hpp" +#include "../game/game.hpp" +#include "../game/game_info.hpp" + +#include "../music/music_driver.hpp" +#include "../sound/sound_driver.hpp" +#include "../video/video_driver.hpp" + +#include "../base_media_base.h" +#include "../blitter/factory.hpp" + +#ifdef WITH_NLOHMANN_JSON +#include +#endif /* WITH_NLOHMANN_JSON */ + +#include "../safeguards.h" + +extern std::string _savegame_id; + +NetworkSurveyHandler _survey = {}; + +#ifdef WITH_NLOHMANN_JSON + +NLOHMANN_JSON_SERIALIZE_ENUM(NetworkSurveyHandler::Reason, { + {NetworkSurveyHandler::Reason::PREVIEW, "preview"}, + {NetworkSurveyHandler::Reason::LEAVE, "leave"}, + {NetworkSurveyHandler::Reason::EXIT, "exit"}, + {NetworkSurveyHandler::Reason::CRASH, "crash"}, +}) + +NLOHMANN_JSON_SERIALIZE_ENUM(GRFStatus, { + {GRFStatus::GCS_UNKNOWN, "unknown"}, + {GRFStatus::GCS_DISABLED, "disabled"}, + {GRFStatus::GCS_NOT_FOUND, "not found"}, + {GRFStatus::GCS_INITIALISED, "initialised"}, + {GRFStatus::GCS_ACTIVATED, "activated"}, +}) + +static const std::string _vehicle_type_to_string[] = { + "train", + "roadveh", + "ship", + "aircraft", +}; + +/* Defined in one of the os/ survey files. */ +extern void SurveyOS(nlohmann::json &json); + +/** + * List of all the generic setting tables. + * + * There are a few tables that are special and not processed like the rest: + * - _currency_settings + * - _misc_settings + * - _company_settings + * - _win32_settings + * As such, they are not part of this list. + */ +static auto &GenericSettingTables() +{ + static const SettingTable _generic_setting_tables[] = { + _difficulty_settings, + _economy_settings, + _game_settings, + _gui_settings, + _linkgraph_settings, + _locale_settings, + _multimedia_settings, + _network_settings, + _news_display_settings, + _pathfinding_settings, + _script_settings, + _world_settings, + }; + return _generic_setting_tables; +} + +/** + * Convert a settings table to JSON. + * + * @param survey The JSON object. + * @param table The settings table to convert. + * @param object The object to get the settings from. + */ +static void SurveySettingsTable(nlohmann::json &survey, const SettingTable &table, void *object) +{ + char buf[512]; + for (auto &desc : table) { + const SettingDesc *sd = GetSettingDesc(desc); + /* Skip any old settings we no longer save/load. */ + if (!SlIsObjectCurrentlyValid(sd->save.version_from, sd->save.version_to)) continue; + + auto name = sd->GetName(); + sd->FormatValue(buf, lastof(buf), object); + + survey[name] = buf; + } +} + +/** + * Convert settings to JSON. + * + * @param survey The JSON object. + */ +static void SurveySettings(nlohmann::json &survey) +{ + SurveySettingsTable(survey, _misc_settings, nullptr); +#if defined(_WIN32) && !defined(DEDICATED) + SurveySettingsTable(survey, _win32_settings, nullptr); +#endif + for (auto &table : GenericSettingTables()) { + SurveySettingsTable(survey, table, &_settings_game); + } + SurveySettingsTable(survey, _currency_settings, &_custom_currency); + SurveySettingsTable(survey, _company_settings, &_settings_client.company); +} + +/** + * Convert generic OpenTTD information to JSON. + * + * @param survey The JSON object. + */ +static void SurveyOpenTTD(nlohmann::json &survey) +{ + survey["version"] = std::string(_openttd_revision); + survey["newgrf_version"] = _openttd_newgrf_version; + survey["build_date"] = std::string(_openttd_build_date); + survey["bits"] = +#ifdef POINTER_IS_64BIT + 64 +#else + 32 +#endif + ; + survey["endian"] = +#if (TTD_ENDIAN == TTD_LITTLE_ENDIAN) + "little" +#else + "big" +#endif + ; + survey["dedicated_build"] = +#ifdef DEDICATED + "yes" +#else + "no" +#endif + ; +} + +/** + * Convert generic game information to JSON. + * + * @param survey The JSON object. + */ +static void SurveyConfiguration(nlohmann::json &survey) +{ + survey["network"] = _networking ? (_network_server ? "server" : "client") : "no"; + if (_current_language != nullptr) { + std::string_view language_basename(_current_language->file); + auto e = language_basename.rfind(PATHSEPCHAR); + if (e != std::string::npos) { + language_basename = language_basename.substr(e + 1); + } + + survey["language"]["filename"] = language_basename; + survey["language"]["name"] = _current_language->name; + survey["language"]["isocode"] = _current_language->isocode; + } + if (BlitterFactory::GetCurrentBlitter() != nullptr) { + survey["blitter"] = BlitterFactory::GetCurrentBlitter()->GetName(); + } + if (MusicDriver::GetInstance() != nullptr) { + survey["music_driver"] = MusicDriver::GetInstance()->GetName(); + } + if (SoundDriver::GetInstance() != nullptr) { + survey["sound_driver"] = SoundDriver::GetInstance()->GetName(); + } + if (VideoDriver::GetInstance() != nullptr) { + survey["video_driver"] = VideoDriver::GetInstance()->GetName(); + survey["video_info"] = VideoDriver::GetInstance()->GetInfoString(); + } + if (BaseGraphics::GetUsedSet() != nullptr) { + survey["graphics_set"] = fmt::format("{}.{}", BaseGraphics::GetUsedSet()->name, BaseGraphics::GetUsedSet()->version); + } + if (BaseMusic::GetUsedSet() != nullptr) { + survey["music_set"] = fmt::format("{}.{}", BaseMusic::GetUsedSet()->name, BaseMusic::GetUsedSet()->version); + } + if (BaseSounds::GetUsedSet() != nullptr) { + survey["sound_set"] = fmt::format("{}.{}", BaseSounds::GetUsedSet()->name, BaseSounds::GetUsedSet()->version); + } +} + +/** + * Convert font information to JSON. + * + * @param survey The JSON object. + */ +static void SurveyFont(nlohmann::json &survey) +{ + survey["small"] = FontCache::Get(FS_SMALL)->GetFontName(); + survey["medium"] = FontCache::Get(FS_NORMAL)->GetFontName(); + survey["large"] = FontCache::Get(FS_LARGE)->GetFontName(); + survey["mono"] = FontCache::Get(FS_MONO)->GetFontName(); +} + +/** + * Convert company information to JSON. + * + * @param survey The JSON object. + */ +static void SurveyCompanies(nlohmann::json &survey) +{ + for (const Company *c : Company::Iterate()) { + auto &company = survey[std::to_string(c->index)]; + if (c->ai_info == nullptr) { + company["type"] = "human"; + } else { + company["type"] = "ai"; + company["script"] = fmt::format("{}.{}", c->ai_info->GetName(), c->ai_info->GetVersion()); + } + + for (VehicleType type = VEH_BEGIN; type < VEH_COMPANY_END; type++) { + uint amount = c->group_all[type].num_vehicle; + company["vehicles"][_vehicle_type_to_string[type]] = amount; + } + + company["infrastructure"]["road"] = c->infrastructure.GetRoadTotal(); + company["infrastructure"]["tram"] = c->infrastructure.GetTramTotal(); + company["infrastructure"]["rail"] = c->infrastructure.GetRailTotal(); + company["infrastructure"]["signal"] = c->infrastructure.signal; + company["infrastructure"]["water"] = c->infrastructure.water; + company["infrastructure"]["station"] = c->infrastructure.station; + company["infrastructure"]["airport"] = c->infrastructure.airport; + } +} + +/** + * Convert GRF information to JSON. + * + * @param survey The JSON object. + */ +static void SurveyGrfs(nlohmann::json &survey) +{ + for (GRFConfig *c = _grfconfig; c != nullptr; c = c->next) { + auto grfid = fmt::format("{:08x}", BSWAP32(c->ident.grfid)); + auto &grf = survey[grfid]; + + grf["md5sum"] = MD5SumToString(c->ident.md5sum); + grf["status"] = c->status; + + if ((c->palette & GRFP_GRF_MASK) == GRFP_GRF_UNSET) grf["palette"] = "unset"; + if ((c->palette & GRFP_GRF_MASK) == GRFP_GRF_DOS) grf["palette"] = "dos"; + if ((c->palette & GRFP_GRF_MASK) == GRFP_GRF_WINDOWS) grf["palette"] = "windows"; + if ((c->palette & GRFP_GRF_MASK) == GRFP_GRF_ANY) grf["palette"] = "any"; + + if ((c->palette & GRFP_BLT_MASK) == GRFP_BLT_UNSET) grf["blitter"] = "unset"; + if ((c->palette & GRFP_BLT_MASK) == GRFP_BLT_32BPP) grf["blitter"] = "32bpp"; + + grf["is_static"] = HasBit(c->flags, GCF_STATIC); + + std::vector parameters; + for (int i = 0; i < c->num_params; i++) { + parameters.push_back(c->param[i]); + } + grf["parameters"] = parameters; + } +} + +/** + * Convert game-script information to JSON. + * + * @param survey The JSON object. + */ +static void SurveyGameScript(nlohmann::json &survey) +{ + if (Game::GetInfo() == nullptr) return; + + survey = fmt::format("{}.{}", Game::GetInfo()->GetName(), Game::GetInfo()->GetVersion()); +} + +#endif /* WITH_NLOHMANN_JSON */ + +/** + * Create the payload for the survey. + * + * @param reason The reason for sending the survey. + * @param for_preview Whether the payload is meant for preview. This indents the result, and filters out the id/key. + * @return std::string The JSON payload as string for the survey. + */ +std::string NetworkSurveyHandler::CreatePayload(Reason reason, bool for_preview) +{ +#ifndef WITH_NLOHMANN_JSON + return ""; +#else + nlohmann::json survey; + + survey["schema"] = NETWORK_SURVEY_VERSION; + survey["reason"] = reason; + survey["id"] = _savegame_id; + +#ifdef SURVEY_KEY + /* We censor the key to avoid people trying to be "clever" and use it to send their own surveys. */ + survey["key"] = for_preview ? "(redacted)" : SURVEY_KEY; +#else + survey["key"] = ""; +#endif + + { + auto &info = survey["info"]; + SurveyOS(info["os"]); + info["os"]["hardware_concurrency"] = std::thread::hardware_concurrency(); + + SurveyOpenTTD(info["openttd"]); + SurveyConfiguration(info["configuration"]); + SurveyFont(info["font"]); + } + + { + auto &game = survey["game"]; + game["ticks"] = TimerGameTick::counter; + game["time"] = std::chrono::duration_cast(std::chrono::steady_clock::now() - _switch_mode_time).count(); + SurveyCompanies(game["companies"]); + SurveySettings(game["settings"]); + SurveyGrfs(game["grfs"]); + SurveyGameScript(game["game_script"]); + } + + /* For preview, we indent with 4 whitespaces to make things more readable. */ + int indent = for_preview ? 4 : -1; + return survey.dump(indent); +#endif /* WITH_NLOHMANN_JSON */ +} + +/** + * Transmit the survey. + * + * @param reason The reason for sending the survey. + * @param blocking Whether to block until the survey is sent. + */ +void NetworkSurveyHandler::Transmit(Reason reason, bool blocking) +{ + if constexpr (!NetworkSurveyHandler::IsSurveyPossible()) { + Debug(net, 4, "Survey: not possible to send survey; most likely due to missing JSON library at compile-time"); + return; + } + + if (_settings_client.network.participate_survey != PS_YES) { + Debug(net, 5, "Survey: user is not participating in survey; skipping survey"); + return; + } + + Debug(net, 1, "Survey: sending survey results"); + NetworkHTTPSocketHandler::Connect(NetworkSurveyUriString(), this, this->CreatePayload(reason)); + + if (blocking) { + std::unique_lock lock(this->mutex); + /* Block no longer than 2 seconds. If we failed to send the survey in that time, so be it. */ + this->loaded.wait_for(lock, std::chrono::seconds(2)); + } +} + +void NetworkSurveyHandler::OnFailure() +{ + Debug(net, 1, "Survey: failed to send survey results"); + this->loaded.notify_all(); +} + +void NetworkSurveyHandler::OnReceiveData(const char *data, size_t length) +{ + if (data == nullptr) { + Debug(net, 1, "Survey: survey results sent"); + this->loaded.notify_all(); + } +} diff --git a/src/network/network_survey.h b/src/network/network_survey.h new file mode 100644 index 0000000000..c957108ecf --- /dev/null +++ b/src/network/network_survey.h @@ -0,0 +1,54 @@ +/* + * This file is part of OpenTTD. + * OpenTTD is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 2. + * OpenTTD is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with OpenTTD. If not, see . + */ + +/** @file network_survey.h Part of the network protocol handling opt-in survey. */ + +#ifndef NETWORK_SURVEY_H +#define NETWORK_SURVEY_H + +#include +#include +#include "core/http.h" + +/** + * Socket handler for the survey connection + */ +class NetworkSurveyHandler : public HTTPCallback { +protected: + void OnFailure() override; + void OnReceiveData(const char *data, size_t length) override; + bool IsCancelled() const override { return false; } + +public: + enum class Reason { + PREVIEW, ///< User is previewing the survey result. + LEAVE, ///< User is leaving the game (but not exiting the application). + EXIT, ///< User is exiting the application. + CRASH, ///< Game crashed. + }; + + void Transmit(Reason reason, bool blocking = false); + std::string CreatePayload(Reason reason, bool for_preview = false); + + constexpr static bool IsSurveyPossible() + { +#ifndef WITH_NLOHMANN_JSON + /* Without JSON library, we cannot send a payload; so we disable the survey. */ + return false; +#else + return true; +#endif /* WITH_NLOHMANN_JSON */ + } + +private: + std::mutex mutex; ///< Mutex for the condition variable. + std::condition_variable loaded; ///< Condition variable to wait for the survey to be sent. +}; + +extern NetworkSurveyHandler _survey; + +#endif /* NETWORK_SURVEY_H */ diff --git a/src/newgrf_gui.cpp b/src/newgrf_gui.cpp index b293cef711..2f3e5d2f09 100644 --- a/src/newgrf_gui.cpp +++ b/src/newgrf_gui.cpp @@ -925,7 +925,7 @@ struct NewGRFWindow : public Window, NewGRFScanCallback { void OnClick(Point pt, int widget, int click_count) override { - if (widget >= WID_NS_NEWGRF_TEXTFILE && widget < WID_NS_NEWGRF_TEXTFILE + TFT_END) { + if (widget >= WID_NS_NEWGRF_TEXTFILE && widget < WID_NS_NEWGRF_TEXTFILE + TFT_CONTENT_END) { if (this->active_sel == nullptr && this->avail_sel == nullptr) return; ShowNewGRFTextfileWindow((TextfileType)(widget - WID_NS_NEWGRF_TEXTFILE), this->active_sel != nullptr ? this->active_sel : this->avail_sel); @@ -1286,7 +1286,7 @@ struct NewGRFWindow : public Window, NewGRFScanCallback { ); const GRFConfig *selected_config = (this->avail_sel == nullptr) ? this->active_sel : this->avail_sel; - for (TextfileType tft = TFT_BEGIN; tft < TFT_END; tft++) { + for (TextfileType tft = TFT_CONTENT_BEGIN; tft < TFT_CONTENT_END; tft++) { this->SetWidgetDisabledState(WID_NS_NEWGRF_TEXTFILE + tft, selected_config == nullptr || !selected_config->GetTextfile(tft).has_value()); } this->SetWidgetDisabledState(WID_NS_OPEN_URL, selected_config == nullptr || StrEmpty(selected_config->GetURL())); diff --git a/src/openttd.cpp b/src/openttd.cpp index b048f1c6c9..62a55f223c 100644 --- a/src/openttd.cpp +++ b/src/openttd.cpp @@ -67,6 +67,7 @@ #include "framerate_type.h" #include "industry.h" #include "network/network_gui.h" +#include "network/network_survey.h" #include "misc_cmd.h" #include "timer/timer.h" #include "timer/timer_game_calendar.h" @@ -822,6 +823,7 @@ void HandleExitGameRequest() _exit_game = true; } else if (_settings_client.gui.autosave_on_exit) { DoExitSave(); + _survey.Transmit(NetworkSurveyHandler::Reason::EXIT, true); _exit_game = true; } else { AskExitGame(); @@ -1036,9 +1038,16 @@ void SwitchToMode(SwitchMode new_mode) /* When we change mode, reset the autosave. */ if (new_mode != SM_SAVE_GAME) ChangeAutosaveFrequency(true); + /* Transmit the survey if we were in normal-mode and not saving. It always means we leaving the current game. */ + if (_game_mode == GM_NORMAL && new_mode != SM_SAVE_GAME) _survey.Transmit(NetworkSurveyHandler::Reason::LEAVE); + + /* Keep track when we last switch mode. Used for survey, to know how long someone was in a game. */ + if (new_mode != SM_SAVE_GAME) _switch_mode_time = std::chrono::steady_clock::now(); + switch (new_mode) { case SM_EDITOR: // Switch to scenario editor MakeNewEditorWorld(); + GenerateSavegameId(); break; case SM_RELOADGAME: // Reload with what-ever started the game @@ -1055,11 +1064,13 @@ void SwitchToMode(SwitchMode new_mode) } MakeNewGame(false, new_mode == SM_NEWGAME); + GenerateSavegameId(); break; case SM_RESTARTGAME: // Restart --> 'Random game' with current settings case SM_NEWGAME: // New Game --> 'Random game' MakeNewGame(false, new_mode == SM_NEWGAME); + GenerateSavegameId(); break; case SM_LOAD_GAME: { // Load game, Play Scenario @@ -1083,18 +1094,21 @@ void SwitchToMode(SwitchMode new_mode) case SM_RESTART_HEIGHTMAP: // Load a heightmap and start a new game from it with current settings case SM_START_HEIGHTMAP: // Load a heightmap and start a new game from it MakeNewGame(true, new_mode == SM_START_HEIGHTMAP); + GenerateSavegameId(); break; case SM_LOAD_HEIGHTMAP: // Load heightmap from scenario editor SetLocalCompany(OWNER_NONE); GenerateWorld(GWM_HEIGHTMAP, 1 << _settings_game.game_creation.map_x, 1 << _settings_game.game_creation.map_y); + GenerateSavegameId(); MarkWholeScreenDirty(); break; case SM_LOAD_SCENARIO: { // Load scenario from scenario editor if (SafeLoad(_file_to_saveload.name, _file_to_saveload.file_op, _file_to_saveload.detail_ftype, GM_EDITOR, NO_DIRECTORY)) { SetLocalCompany(OWNER_NONE); + GenerateSavegameId(); _settings_newgame.game_creation.starting_year = TimerGameCalendar::year; /* Cancel the saveload pausing */ Command::Post(PM_PAUSED_SAVELOAD, false); @@ -1116,6 +1130,14 @@ void SwitchToMode(SwitchMode new_mode) ShowErrorMessage(STR_WARNING_FALLBACK_SOUNDSET, INVALID_STRING_ID, WL_CRITICAL); BaseSounds::ini_set = BaseSounds::GetUsedSet()->name; } + if (_settings_client.network.participate_survey == PS_ASK) { + /* No matter how often you go back to the main menu, only ask the first time. */ + static bool asked_once = false; + if (!asked_once) { + asked_once = true; + ShowNetworkAskSurvey(); + } + } break; case SM_SAVE_GAME: // Save game. diff --git a/src/openttd.h b/src/openttd.h index 0c1547178c..282c0a033b 100644 --- a/src/openttd.h +++ b/src/openttd.h @@ -11,6 +11,7 @@ #define OPENTTD_H #include +#include #include "core/enum_type.hpp" /** Mode which defines the state of the game. */ @@ -53,6 +54,7 @@ enum DisplayOptions { extern GameMode _game_mode; extern SwitchMode _switch_mode; +extern std::chrono::steady_clock::time_point _switch_mode_time; extern std::atomic _exit_game; extern bool _save_config; @@ -86,6 +88,7 @@ void HandleExitGameRequest(); void SwitchToMode(SwitchMode new_mode); bool RequestNewGRFScan(struct NewGRFScanCallback *callback = nullptr); +void GenerateSavegameId(); void OpenBrowser(const char *url); void ChangeAutosaveFrequency(bool reset); diff --git a/src/os/macosx/CMakeLists.txt b/src/os/macosx/CMakeLists.txt index acb30baf88..a494825b49 100644 --- a/src/os/macosx/CMakeLists.txt +++ b/src/os/macosx/CMakeLists.txt @@ -7,6 +7,7 @@ add_files( osx_stdafx.h string_osx.cpp string_osx.h + survey_osx.cpp CONDITION APPLE ) diff --git a/src/os/macosx/macos.h b/src/os/macosx/macos.h index 2a7a12a5fb..5dc6b3e095 100644 --- a/src/os/macosx/macos.h +++ b/src/os/macosx/macos.h @@ -38,6 +38,8 @@ bool IsMonospaceFont(CFStringRef name); void MacOSSetThreadName(const char *name); +uint64 MacOSGetPhysicalMemory(); + /** Deleter that calls CFRelease rather than deleting the pointer. */ template struct CFDeleter { diff --git a/src/os/macosx/macos.mm b/src/os/macosx/macos.mm index 90bcd6dc40..452b151e9c 100644 --- a/src/os/macosx/macos.mm +++ b/src/os/macosx/macos.mm @@ -272,3 +272,8 @@ void MacOSSetThreadName(const char *name) [ cur performSelector:@selector(setName:) withObject:[ NSString stringWithUTF8String:name ] ]; } } + +uint64 MacOSGetPhysicalMemory() +{ + return [ [ NSProcessInfo processInfo ] physicalMemory ]; +} diff --git a/src/os/macosx/survey_osx.cpp b/src/os/macosx/survey_osx.cpp new file mode 100644 index 0000000000..88edebd9f6 --- /dev/null +++ b/src/os/macosx/survey_osx.cpp @@ -0,0 +1,38 @@ +/* + * This file is part of OpenTTD. + * OpenTTD is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 2. + * OpenTTD is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with OpenTTD. If not, see . + */ + +/** @file survey_osx.cpp OSX implementation of OS-specific survey information. */ + +#ifdef WITH_NLOHMANN_JSON + +#include "../../stdafx.h" + +#include "../../3rdparty/fmt/format.h" +#include "macos.h" + +#include +#include + +#include "../../safeguards.h" + +void SurveyOS(nlohmann::json &json) +{ + int ver_maj, ver_min, ver_bug; + GetMacOSVersion(&ver_maj, &ver_min, &ver_bug); + + const NXArchInfo *arch = NXGetLocalArchInfo(); + + json["os"] = "MacOS"; + json["release"] = fmt::format("{}.{}.{}", ver_maj, ver_min, ver_bug); + json["machine"] = arch != nullptr ? arch->description : "unknown"; + json["min_ver"] = MAC_OS_X_VERSION_MIN_REQUIRED; + json["max_ver"] = MAC_OS_X_VERSION_MAX_ALLOWED; + + json["memory"] = MacOSGetPhysicalMemory(); +} + +#endif /* WITH_NLOHMANN_JSON */ diff --git a/src/os/unix/CMakeLists.txt b/src/os/unix/CMakeLists.txt index a95d8ce2db..acdc73831f 100644 --- a/src/os/unix/CMakeLists.txt +++ b/src/os/unix/CMakeLists.txt @@ -1,5 +1,6 @@ add_files( crashlog_unix.cpp + survey_unix.cpp CONDITION UNIX AND NOT APPLE AND NOT OPTION_OS2 ) diff --git a/src/os/unix/survey_unix.cpp b/src/os/unix/survey_unix.cpp new file mode 100644 index 0000000000..d86b58e98c --- /dev/null +++ b/src/os/unix/survey_unix.cpp @@ -0,0 +1,38 @@ +/* + * This file is part of OpenTTD. + * OpenTTD is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 2. + * OpenTTD is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with OpenTTD. If not, see . + */ + +/** @file survey_unix.cpp Unix implementation of OS-specific survey information. */ + +#ifdef WITH_NLOHMANN_JSON + +#include "../../stdafx.h" + +#include +#include +#include + +#include "../../safeguards.h" + +void SurveyOS(nlohmann::json &json) +{ + struct utsname name; + if (uname(&name) < 0) { + json["os"] = "Unix"; + return; + } + + json["os"] = name.sysname; + json["release"] = name.release; + json["machine"] = name.machine; + json["version"] = name.version; + + long pages = sysconf(_SC_PHYS_PAGES); + long page_size = sysconf(_SC_PAGE_SIZE); + json["memory"] = pages * page_size; +} + +#endif /* WITH_NLOHMANN_JSON */ diff --git a/src/os/windows/CMakeLists.txt b/src/os/windows/CMakeLists.txt index a1b73a6d8f..9215514fa2 100644 --- a/src/os/windows/CMakeLists.txt +++ b/src/os/windows/CMakeLists.txt @@ -4,6 +4,7 @@ add_files( font_win32.h string_uniscribe.cpp string_uniscribe.h + survey_win.cpp win32.cpp win32.h CONDITION WIN32 diff --git a/src/os/windows/survey_win.cpp b/src/os/windows/survey_win.cpp new file mode 100644 index 0000000000..407ddb31d4 --- /dev/null +++ b/src/os/windows/survey_win.cpp @@ -0,0 +1,37 @@ +/* + * This file is part of OpenTTD. + * OpenTTD is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 2. + * OpenTTD is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with OpenTTD. If not, see . + */ + +/** @file survey_win.cpp Windows implementation of OS-specific survey information. */ + +#ifdef WITH_NLOHMANN_JSON + +#include "../../stdafx.h" + +#include "../../3rdparty/fmt/format.h" + +#include +#include + +#include "../../safeguards.h" + +void SurveyOS(nlohmann::json &json) +{ + _OSVERSIONINFOA os; + os.dwOSVersionInfoSize = sizeof(os); + GetVersionExA(&os); + + json["os"] = "Windows"; + json["release"] = fmt::format("{}.{}.{} ({})", os.dwMajorVersion, os.dwMinorVersion, os.dwBuildNumber, os.szCSDVersion); + + MEMORYSTATUSEX status; + status.dwLength = sizeof(status); + GlobalMemoryStatusEx(&status); + + json["memory"] = status.ullTotalPhys; +} + +#endif /* WITH_NLOHMANN_JSON */ diff --git a/src/saveload/afterload.cpp b/src/saveload/afterload.cpp index 3f27b6c23f..15213ca7d0 100644 --- a/src/saveload/afterload.cpp +++ b/src/saveload/afterload.cpp @@ -3224,6 +3224,10 @@ bool AfterLoadGame() for (Station *st : Station::Iterate()) UpdateStationAcceptance(st, false); } + if (IsSavegameVersionBefore(SLV_SAVEGAME_ID)) { + GenerateSavegameId(); + } + if (IsSavegameVersionBefore(SLV_AI_START_DATE)) { /* For older savegames, we don't now the actual interval; so set it to the newgame value. */ _settings_game.difficulty.competitors_interval = _settings_newgame.difficulty.competitors_interval; diff --git a/src/saveload/misc_sl.cpp b/src/saveload/misc_sl.cpp index 5ac6e93b02..1a147c25ff 100644 --- a/src/saveload/misc_sl.cpp +++ b/src/saveload/misc_sl.cpp @@ -29,6 +29,7 @@ extern TileIndex _cur_tileloop_tile; extern uint16 _disaster_delay; extern byte _trees_tick_ctr; +extern std::string _savegame_id; /* Keep track of current game position */ int _saved_scrollpos_x; @@ -87,6 +88,7 @@ static const SaveLoad _date_desc[] = { SLEG_VAR("company_tick_counter", _cur_company_tick_index, SLE_FILE_U8 | SLE_VAR_U32), SLEG_VAR("trees_tick_counter", _trees_tick_ctr, SLE_UINT8), SLEG_CONDVAR("pause_mode", _pause_mode, SLE_UINT8, SLV_4, SL_MAX_VERSION), + SLEG_CONDSSTR("id", _savegame_id, SLE_STR, SLV_SAVEGAME_ID, SL_MAX_VERSION), /* For older savegames, we load the current value as the "period"; afterload will set the "fired" and "elapsed". */ SLEG_CONDVAR("next_competitor_start", _new_competitor_timeout.period, SLE_FILE_U16 | SLE_VAR_U32, SL_MIN_VERSION, SLV_109), SLEG_CONDVAR("next_competitor_start", _new_competitor_timeout.period, SLE_UINT32, SLV_109, SLV_AI_START_DATE), diff --git a/src/saveload/saveload.h b/src/saveload/saveload.h index a30033078e..42729a5fb4 100644 --- a/src/saveload/saveload.h +++ b/src/saveload/saveload.h @@ -353,6 +353,7 @@ enum SaveLoadVersion : uint16 { SLV_EXTEND_VEHICLE_RANDOM, ///< 310 PR#10701 Extend vehicle random bits. SLV_EXTEND_ENTITY_MAPPING, ///< 311 PR#10672 Extend entity mapping range. SLV_DISASTER_VEH_STATE, ///< 312 PR#10798 Explicit storage of disaster vehicle state. + SLV_SAVEGAME_ID, ///< 313 PR#10719 Add an unique ID to every savegame (used to deduplicate surveys). SL_MAX_VERSION, ///< Highest possible saveload version }; diff --git a/src/settings_gui.cpp b/src/settings_gui.cpp index ac7cfe09e1..9fe9c3a78a 100644 --- a/src/settings_gui.cpp +++ b/src/settings_gui.cpp @@ -41,6 +41,9 @@ #include "music/music_driver.hpp" #include "gui.h" #include "mixer.h" +#include "network/core/config.h" +#include "network/network_gui.h" +#include "network/network_survey.h" #include "safeguards.h" @@ -190,6 +193,8 @@ struct GameOptionsWindow : Window { this->OnInvalidateData(0); this->SetTab(WID_GO_TAB_GENERAL); + + if constexpr (!NetworkSurveyHandler::IsSurveyPossible()) this->GetWidget(WID_GO_SURVEY_SEL)->SetDisplayedPlane(SZSP_NONE); } void Close() override @@ -464,19 +469,19 @@ struct GameOptionsWindow : Window { void OnClick(Point pt, int widget, int click_count) override { - if (widget >= WID_GO_BASE_GRF_TEXTFILE && widget < WID_GO_BASE_GRF_TEXTFILE + TFT_END) { + if (widget >= WID_GO_BASE_GRF_TEXTFILE && widget < WID_GO_BASE_GRF_TEXTFILE + TFT_CONTENT_END) { if (BaseGraphics::GetUsedSet() == nullptr) return; ShowBaseSetTextfileWindow((TextfileType)(widget - WID_GO_BASE_GRF_TEXTFILE), BaseGraphics::GetUsedSet(), STR_CONTENT_TYPE_BASE_GRAPHICS); return; } - if (widget >= WID_GO_BASE_SFX_TEXTFILE && widget < WID_GO_BASE_SFX_TEXTFILE + TFT_END) { + if (widget >= WID_GO_BASE_SFX_TEXTFILE && widget < WID_GO_BASE_SFX_TEXTFILE + TFT_CONTENT_END) { if (BaseSounds::GetUsedSet() == nullptr) return; ShowBaseSetTextfileWindow((TextfileType)(widget - WID_GO_BASE_SFX_TEXTFILE), BaseSounds::GetUsedSet(), STR_CONTENT_TYPE_BASE_SOUNDS); return; } - if (widget >= WID_GO_BASE_MUSIC_TEXTFILE && widget < WID_GO_BASE_MUSIC_TEXTFILE + TFT_END) { + if (widget >= WID_GO_BASE_MUSIC_TEXTFILE && widget < WID_GO_BASE_MUSIC_TEXTFILE + TFT_CONTENT_END) { if (BaseMusic::GetUsedSet() == nullptr) return; ShowBaseSetTextfileWindow((TextfileType)(widget - WID_GO_BASE_MUSIC_TEXTFILE), BaseMusic::GetUsedSet(), STR_CONTENT_TYPE_BASE_MUSIC); @@ -489,6 +494,30 @@ struct GameOptionsWindow : Window { this->SetTab(widget); break; + case WID_GO_SURVEY_PARTICIPATE_BUTTON: + switch (_settings_client.network.participate_survey) { + case PS_ASK: + case PS_NO: + _settings_client.network.participate_survey = PS_YES; + break; + + case PS_YES: + _settings_client.network.participate_survey = PS_NO; + break; + } + + this->SetWidgetLoweredState(WID_GO_SURVEY_PARTICIPATE_BUTTON, _settings_client.network.participate_survey == PS_YES); + this->SetWidgetDirty(WID_GO_SURVEY_PARTICIPATE_BUTTON); + break; + + case WID_GO_SURVEY_LINK_BUTTON: + OpenBrowser(NETWORK_SURVEY_DETAILS_LINK.c_str()); + break; + + case WID_GO_SURVEY_PREVIEW_BUTTON: + ShowSurveyResultTextfileWindow(); + break; + case WID_GO_FULLSCREEN_BUTTON: // Click fullscreen on/off /* try to toggle full-screen on/off */ if (!ToggleFullScreen(!_fullscreen)) { @@ -686,6 +715,7 @@ struct GameOptionsWindow : Window { void OnInvalidateData(int data = 0, bool gui_scope = true) override { if (!gui_scope) return; + this->SetWidgetLoweredState(WID_GO_SURVEY_PARTICIPATE_BUTTON, _settings_client.network.participate_survey == PS_YES); this->SetWidgetLoweredState(WID_GO_FULLSCREEN_BUTTON, _fullscreen); this->SetWidgetLoweredState(WID_GO_VIDEO_ACCEL_BUTTON, _video_hw_accel); this->SetWidgetDisabledState(WID_GO_REFRESH_RATE_DROPDOWN, _video_vsync); @@ -701,7 +731,7 @@ struct GameOptionsWindow : Window { bool missing_files = BaseGraphics::GetUsedSet()->GetNumMissing() == 0; this->GetWidget(WID_GO_BASE_GRF_STATUS)->SetDataTip(missing_files ? STR_EMPTY : STR_GAME_OPTIONS_BASE_GRF_STATUS, STR_NULL); - for (TextfileType tft = TFT_BEGIN; tft < TFT_END; tft++) { + for (TextfileType tft = TFT_CONTENT_BEGIN; tft < TFT_CONTENT_END; tft++) { this->SetWidgetDisabledState(WID_GO_BASE_GRF_TEXTFILE + tft, BaseGraphics::GetUsedSet() == nullptr || !BaseGraphics::GetUsedSet()->GetTextfile(tft).has_value()); this->SetWidgetDisabledState(WID_GO_BASE_SFX_TEXTFILE + tft, BaseSounds::GetUsedSet() == nullptr || !BaseSounds::GetUsedSet()->GetTextfile(tft).has_value()); this->SetWidgetDisabledState(WID_GO_BASE_MUSIC_TEXTFILE + tft, BaseMusic::GetUsedSet() == nullptr || !BaseMusic::GetUsedSet()->GetTextfile(tft).has_value()); @@ -739,6 +769,20 @@ static const NWidgetPart _nested_game_options_widgets[] = { NWidget(WWT_FRAME, COLOUR_GREY), SetDataTip(STR_GAME_OPTIONS_CURRENCY_UNITS_FRAME, STR_NULL), NWidget(WWT_DROPDOWN, COLOUR_GREY, WID_GO_CURRENCY_DROPDOWN), SetMinimalSize(100, 12), SetDataTip(STR_JUST_STRING, STR_GAME_OPTIONS_CURRENCY_UNITS_DROPDOWN_TOOLTIP), SetFill(1, 0), EndContainer(), + + NWidget(NWID_SELECTION, INVALID_COLOUR, WID_GO_SURVEY_SEL), + NWidget(WWT_FRAME, COLOUR_GREY), SetDataTip(STR_GAME_OPTIONS_PARTICIPATE_SURVEY_FRAME, STR_NULL), SetPIP(0, WidgetDimensions::unscaled.vsep_normal, 0), + NWidget(NWID_HORIZONTAL), + NWidget(WWT_TEXT, COLOUR_GREY), SetMinimalSize(0, 12), SetDataTip(STR_GAME_OPTIONS_PARTICIPATE_SURVEY, STR_NULL), + NWidget(NWID_SPACER), SetMinimalSize(1, 0), SetFill(1, 0), + NWidget(WWT_TEXTBTN, COLOUR_GREY, WID_GO_SURVEY_PARTICIPATE_BUTTON), SetMinimalSize(21, 9), SetDataTip(STR_EMPTY, STR_GAME_OPTIONS_PARTICIPATE_SURVEY_TOOLTIP), + EndContainer(), + NWidget(NWID_HORIZONTAL, NC_EQUALSIZE), SetPIP(7, 0, 7), + NWidget(WWT_TEXTBTN, COLOUR_GREY, WID_GO_SURVEY_PREVIEW_BUTTON), SetFill(1, 0), SetResize(1, 0), SetDataTip(STR_GAME_OPTIONS_PARTICIPATE_SURVEY_PREVIEW, STR_GAME_OPTIONS_PARTICIPATE_SURVEY_PREVIEW_TOOLTIP), + NWidget(WWT_TEXTBTN, COLOUR_GREY, WID_GO_SURVEY_LINK_BUTTON), SetFill(1, 0), SetResize(1, 0), SetDataTip(STR_GAME_OPTIONS_PARTICIPATE_SURVEY_LINK, STR_GAME_OPTIONS_PARTICIPATE_SURVEY_LINK_TOOLTIP), + EndContainer(), + EndContainer(), + EndContainer(), EndContainer(), /* Graphics tab */ diff --git a/src/settings_type.h b/src/settings_type.h index 34284bc6a8..5b97fa78dc 100644 --- a/src/settings_type.h +++ b/src/settings_type.h @@ -63,13 +63,20 @@ enum IndustryDensity { ID_END, ///< Number of industry density settings. }; -/** Possible values for "userelayservice" setting. */ +/** Possible values for "use_relay_service" setting. */ enum UseRelayService { URS_NEVER = 0, URS_ASK, URS_ALLOW, }; +/** Possible values for "participate_survey" setting. */ +enum ParticipateSurvey { + PS_ASK = 0, + PS_NO, + PS_YES, +}; + /** Settings related to the difficulty of the game */ struct DifficultySettings { byte competitor_start_time; ///< Unused value, used to load old savegames. @@ -306,7 +313,8 @@ struct NetworkSettings { bool reload_cfg; ///< reload the config file before restarting std::string last_joined; ///< Last joined server bool no_http_content_downloads; ///< do not do content downloads over HTTP - UseRelayService use_relay_service; ///< Use relay service? + UseRelayService use_relay_service; ///< Use relay service? + ParticipateSurvey participate_survey; ///< Participate in the automated survey }; /** Settings related to the creation of games. */ diff --git a/src/table/settings/network_private_settings.ini b/src/table/settings/network_private_settings.ini index d984011903..f178cdcca8 100644 --- a/src/table/settings/network_private_settings.ini +++ b/src/table/settings/network_private_settings.ini @@ -8,6 +8,7 @@ [pre-amble] static constexpr std::initializer_list _use_relay_service{"never", "ask", "allow"}; +static constexpr std::initializer_list _participate_survey{"ask", "no", "yes"}; static const SettingVariant _network_private_settings_table[] = { [post-amble] @@ -16,6 +17,7 @@ static const SettingVariant _network_private_settings_table[] = { SDTC_BOOL = SDTC_BOOL( $var, $flags, $def, $str, $strhelp, $strval, $pre_cb, $post_cb, $from, $to, $cat, $extra, $startup), SDTC_OMANY = SDTC_OMANY( $var, $type, $flags, $def, $max, $full, $str, $strhelp, $strval, $pre_cb, $post_cb, $from, $to, $cat, $extra, $startup), SDTC_SSTR = SDTC_SSTR( $var, $type, $flags, $def, $length, $pre_cb, $post_cb, $from, $to, $cat, $extra, $startup), +SDTC_OMANY = SDTC_OMANY( $var, $type, $flags, $def, $max, $full, $str, $strhelp, $strval, $pre_cb, $post_cb, $from, $to, $cat, $extra, $startup), [validation] SDTC_OMANY = static_assert($max <= MAX_$type, "Maximum value for $var exceeds storage size"); @@ -90,3 +92,12 @@ str = STR_CONFIG_SETTING_USE_RELAY_SERVICE strhelp = STR_CONFIG_SETTING_USE_RELAY_SERVICE_HELPTEXT strval = STR_CONFIG_SETTING_USE_RELAY_SERVICE_NEVER cat = SC_BASIC + +[SDTC_OMANY] +var = network.participate_survey +type = SLE_UINT8 +flags = SF_NOT_IN_SAVE | SF_NO_NETWORK_SYNC +def = PS_ASK +min = PS_ASK +max = PS_YES +full = _participate_survey diff --git a/src/textfile_gui.cpp b/src/textfile_gui.cpp index ba067daead..210cdb3070 100644 --- a/src/textfile_gui.cpp +++ b/src/textfile_gui.cpp @@ -366,8 +366,21 @@ static void Xunzip(byte **bufp, size_t *sizep) if (StrStartsWith(sv_buf, u8"\ufeff")) sv_buf.remove_prefix(3); /* Replace any invalid characters with a question-mark. This copies the buf in the process. */ - this->text = StrMakeValid(sv_buf, SVS_REPLACE_WITH_QUESTION_MARK | SVS_ALLOW_NEWLINE | SVS_REPLACE_TAB_CR_NL_WITH_SPACE); + this->LoadText(sv_buf); free(buf); +} + +/** + * Load a text into the textfile viewer. + * + * This will split the text into newlines and stores it for fast drawing. + * + * @param buf The text to load. + */ +void TextfileWindow::LoadText(std::string_view buf) +{ + this->text = StrMakeValid(buf, SVS_REPLACE_WITH_QUESTION_MARK | SVS_ALLOW_NEWLINE | SVS_REPLACE_TAB_CR_NL_WITH_SPACE); + this->lines.clear(); /* Split the string on newlines. */ std::string_view p(this->text); @@ -406,7 +419,7 @@ std::optional GetTextfile(TextfileType type, Subdirectory dir, cons "changelog", "license", }; - static_assert(lengthof(prefixes) == TFT_END); + static_assert(lengthof(prefixes) == TFT_CONTENT_END); std::string_view prefix = prefixes[type]; diff --git a/src/textfile_gui.h b/src/textfile_gui.h index c233aba638..6b57abfa24 100644 --- a/src/textfile_gui.h +++ b/src/textfile_gui.h @@ -42,6 +42,9 @@ struct TextfileWindow : public Window, MissingGlyphSearcher { virtual void LoadTextfile(const std::string &textfile, Subdirectory dir); +protected: + void LoadText(std::string_view buf); + private: struct Line { int top; ///< Top scroll position. diff --git a/src/textfile_type.h b/src/textfile_type.h index ddb26f6a65..f45c016440 100644 --- a/src/textfile_type.h +++ b/src/textfile_type.h @@ -12,13 +12,15 @@ /** Additional text files accompanying Tar archives */ enum TextfileType { - TFT_BEGIN, + TFT_CONTENT_BEGIN, - TFT_README = TFT_BEGIN, ///< NewGRF readme - TFT_CHANGELOG, ///< NewGRF changelog - TFT_LICENSE, ///< NewGRF license + TFT_README = TFT_CONTENT_BEGIN, ///< Content readme + TFT_CHANGELOG, ///< Content changelog + TFT_LICENSE, ///< Content license - TFT_END, + TFT_CONTENT_END, // This marker is used to generate the above three buttons in sequence by various of places in the code. + + TFT_SURVEY_RESULT = TFT_CONTENT_END, ///< Survey result (preview) }; DECLARE_POSTFIX_INCREMENT(TextfileType) diff --git a/src/widget.cpp b/src/widget.cpp index 23924c2895..ae17fde96f 100644 --- a/src/widget.cpp +++ b/src/widget.cpp @@ -1172,7 +1172,7 @@ NWidgetCore::NWidgetCore(WidgetType tp, Colours colour, uint fill_x, uint fill_y this->widget_data = widget_data; this->tool_tip = tool_tip; this->scrollbar_index = -1; - this->text_colour = TC_BLACK; + this->text_colour = tp == WWT_CAPTION ? TC_WHITE : TC_BLACK; this->text_size = FS_NORMAL; this->align = SA_CENTER; } diff --git a/src/widgets/ai_widget.h b/src/widgets/ai_widget.h index 4d6418bcfe..3753941609 100644 --- a/src/widgets/ai_widget.h +++ b/src/widgets/ai_widget.h @@ -29,7 +29,7 @@ enum AIConfigWidgets { WID_AIC_CONFIGURE, ///< Change AI settings button. WID_AIC_CLOSE, ///< Close window button. WID_AIC_TEXTFILE, ///< Open AI readme, changelog (+1) or license (+2). - WID_AIC_CONTENT_DOWNLOAD = WID_AIC_TEXTFILE + TFT_END, ///< Download content button. + WID_AIC_CONTENT_DOWNLOAD = WID_AIC_TEXTFILE + TFT_CONTENT_END, ///< Download content button. }; #endif /* WIDGETS_AI_WIDGET_H */ diff --git a/src/widgets/game_widget.h b/src/widgets/game_widget.h index f791a43749..5694bc4607 100644 --- a/src/widgets/game_widget.h +++ b/src/widgets/game_widget.h @@ -20,7 +20,7 @@ enum GSConfigWidgets { WID_GSC_SCROLLBAR, ///< Scrollbar to scroll through the selected AIs. WID_GSC_CHANGE, ///< Select another Game Script button. WID_GSC_TEXTFILE, ///< Open GS readme, changelog (+1) or license (+2). - WID_GSC_CONTENT_DOWNLOAD = WID_GSC_TEXTFILE + TFT_END, ///< Download content button. + WID_GSC_CONTENT_DOWNLOAD = WID_GSC_TEXTFILE + TFT_CONTENT_END, ///< Download content button. WID_GSC_ACCEPT, ///< Accept ("Close") button WID_GSC_RESET, ///< Reset button. }; diff --git a/src/widgets/network_content_widget.h b/src/widgets/network_content_widget.h index 49c8153d4a..c8092aefa8 100644 --- a/src/widgets/network_content_widget.h +++ b/src/widgets/network_content_widget.h @@ -36,7 +36,7 @@ enum NetworkContentListWidgets { WID_NCL_DETAILS, ///< Panel with content details. WID_NCL_TEXTFILE, ///< Open readme, changelog (+1) or license (+2) of a file in the content window. - WID_NCL_SELECT_ALL = WID_NCL_TEXTFILE + TFT_END, ///< 'Select all' button. + WID_NCL_SELECT_ALL = WID_NCL_TEXTFILE + TFT_CONTENT_END, ///< 'Select all' button. WID_NCL_SELECT_UPDATE, ///< 'Select updates' button. WID_NCL_UNSELECT, ///< 'Unselect all' button. WID_NCL_OPEN_URL, ///< 'Open url' button. diff --git a/src/widgets/network_widget.h b/src/widgets/network_widget.h index 058cb090b0..8bfc3818f5 100644 --- a/src/widgets/network_widget.h +++ b/src/widgets/network_widget.h @@ -119,4 +119,14 @@ enum NetworkAskRelayWidgets { WID_NAR_YES_ALWAYS, ///< "Yes, always" button. }; +/** Widgets of the #NetworkAskSurveyWindow class. */ +enum NetworkAskSurveyWidgets { + WID_NAS_CAPTION, ///< Caption of the window. + WID_NAS_TEXT, ///< Text in the window. + WID_NAS_PREVIEW, ///< "Preview" button. + WID_NAS_LINK, ///< "Details & Privacy" button. + WID_NAS_NO, ///< "No" button. + WID_NAS_YES, ///< "Yes" button. +}; + #endif /* WIDGETS_NETWORK_WIDGET_H */ diff --git a/src/widgets/newgrf_widget.h b/src/widgets/newgrf_widget.h index 7f5fefde06..03b81bb584 100644 --- a/src/widgets/newgrf_widget.h +++ b/src/widgets/newgrf_widget.h @@ -47,7 +47,7 @@ enum NewGRFStateWidgets { WID_NS_NEWGRF_INFO, ///< Panel for Info on selected NewGRF. WID_NS_OPEN_URL, ///< Open URL of NewGRF. WID_NS_NEWGRF_TEXTFILE, ///< Open NewGRF readme, changelog (+1) or license (+2). - WID_NS_SET_PARAMETERS = WID_NS_NEWGRF_TEXTFILE + TFT_END, ///< Open Parameters Window for selected NewGRF for editing parameters. + WID_NS_SET_PARAMETERS = WID_NS_NEWGRF_TEXTFILE + TFT_CONTENT_END, ///< Open Parameters Window for selected NewGRF for editing parameters. WID_NS_VIEW_PARAMETERS, ///< Open Parameters Window for selected NewGRF for viewing parameters. WID_NS_TOGGLE_PALETTE, ///< Toggle Palette of selected, active NewGRF. WID_NS_APPLY_CHANGES, ///< Apply changes to NewGRF config. diff --git a/src/widgets/settings_widget.h b/src/widgets/settings_widget.h index 8e324f0d55..5f372eaec5 100644 --- a/src/widgets/settings_widget.h +++ b/src/widgets/settings_widget.h @@ -28,23 +28,27 @@ enum GameOptionsWidgets { WID_GO_BASE_GRF_DROPDOWN, ///< Use to select a base GRF. WID_GO_BASE_GRF_STATUS, ///< Info about missing files etc. WID_GO_BASE_GRF_TEXTFILE, ///< Open base GRF readme, changelog (+1) or license (+2). - WID_GO_BASE_GRF_DESCRIPTION = WID_GO_BASE_GRF_TEXTFILE + TFT_END, ///< Description of selected base GRF. + WID_GO_BASE_GRF_DESCRIPTION = WID_GO_BASE_GRF_TEXTFILE + TFT_CONTENT_END, ///< Description of selected base GRF. WID_GO_BASE_SFX_DROPDOWN, ///< Use to select a base SFX. WID_GO_TEXT_SFX_VOLUME, ///< Sound effects volume label. WID_GO_BASE_SFX_VOLUME, ///< Change sound effects volume. WID_GO_BASE_SFX_TEXTFILE, ///< Open base SFX readme, changelog (+1) or license (+2). - WID_GO_BASE_SFX_DESCRIPTION = WID_GO_BASE_SFX_TEXTFILE + TFT_END, ///< Description of selected base SFX. + WID_GO_BASE_SFX_DESCRIPTION = WID_GO_BASE_SFX_TEXTFILE + TFT_CONTENT_END, ///< Description of selected base SFX. WID_GO_BASE_MUSIC_DROPDOWN, ///< Use to select a base music set. WID_GO_TEXT_MUSIC_VOLUME, ///< Music volume label. WID_GO_BASE_MUSIC_VOLUME, ///< Change music volume. WID_GO_BASE_MUSIC_JUKEBOX, ///< Open the jukebox. WID_GO_BASE_MUSIC_STATUS, ///< Info about corrupted files etc. WID_GO_BASE_MUSIC_TEXTFILE, ///< Open base music readme, changelog (+1) or license (+2). - WID_GO_BASE_MUSIC_DESCRIPTION = WID_GO_BASE_MUSIC_TEXTFILE + TFT_END, ///< Description of selected base music set. + WID_GO_BASE_MUSIC_DESCRIPTION = WID_GO_BASE_MUSIC_TEXTFILE + TFT_CONTENT_END, ///< Description of selected base music set. WID_GO_VIDEO_ACCEL_BUTTON, ///< Toggle for video acceleration. WID_GO_VIDEO_VSYNC_BUTTON, ///< Toggle for video vsync. WID_GO_REFRESH_RATE_DROPDOWN, ///< Dropdown for all available refresh rates. WID_GO_VIDEO_DRIVER_INFO, ///< Label showing details about the current video driver. + WID_GO_SURVEY_SEL, ///< Selection to hide survey if no JSON library is compiled in. + WID_GO_SURVEY_PARTICIPATE_BUTTON, ///< Toggle for participating in the automated survey. + WID_GO_SURVEY_LINK_BUTTON, ///< Button to open browser to go to the survey website. + WID_GO_SURVEY_PREVIEW_BUTTON, ///< Button to open a preview window with the survey results }; /** Widgets of the #GameSettingsWindow class. */ diff --git a/src/window_type.h b/src/window_type.h index a31150f21d..e35814e9e5 100644 --- a/src/window_type.h +++ b/src/window_type.h @@ -483,6 +483,12 @@ enum WindowClass { */ WC_NETWORK_ASK_RELAY, + /** + * Network ask survey window; %Window numbers: + * - 0 - #NetworkAskSurveyWidgets + */ + WC_NETWORK_ASK_SURVEY, + /** * Chatbox; %Window numbers: * - #DestType = #NetWorkChatWidgets