diff --git a/.gitignore b/.gitignore index df282c66..d9e6ee28 100644 --- a/.gitignore +++ b/.gitignore @@ -1,115 +1,85 @@ -# Compiled class file -*.class +# OSX +# +.DS_Store -# Log file -*.log - -# BlueJ files -*.ctxt - -# Mobile Tools for Java (J2ME) -.mtj.tmp/ - -# Package Files # -*.jar -*.war -*.nar -*.ear -*.zip -*.tar.gz -*.rar - -# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml -hs_err_pid* - -/opencv - -# Built application files -*.apk -*.aar -*.ap_ -*.aab - -# Files for the ART/Dalvik VM -*.dex - -# Java class files -*.class - -# Generated files -bin/ -gen/ -out/ -# Uncomment the following line in case you need and you don't have the release build type files in your app -# release/ - -# Gradle files -.gradle/ +# Xcode +# build/ - -# Local configuration file (sdk path, etc) +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.xcuserstate +**/.xcode.env.local + +# Android/IntelliJ +# +build/ +.idea +.gradle local.properties - -# Proguard folder generated by Eclipse -proguard/ - -# Log Files -*.log - -# Android Studio Navigation editor temp files -.navigation/ - -# Android Studio captures folder -captures/ - -# IntelliJ *.iml -.idea/workspace.xml -.idea/tasks.xml -.idea/gradle.xml -.idea/assetWizardSettings.xml -.idea/dictionaries -.idea/libraries -# Android Studio 3 in .gitignore file. -.idea/caches -.idea/modules.xml -# Comment next line if keeping position of elements in Navigation Editor is relevant for you -.idea/navEditor.xml - -# Keystore files -# Uncomment the following lines if you do not want to check your keystore files in. -#*.jks -#*.keystore - -# External native build folder generated in Android Studio 2.2 and later -.externalNativeBuild +*.hprof .cxx/ +*.keystore +!debug.keystore +.kotlin/ -# Google Services (e.g. APIs or Firebase) -# google-services.json - -# Freeline -freeline.py -freeline/ -freeline_project_description.json +# node.js +# +node_modules/ +npm-debug.log +yarn-error.log # fastlane -fastlane/report.xml -fastlane/Preview.html -fastlane/screenshots -fastlane/test_output -fastlane/readme.md +# +# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the +# screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/ + +**/fastlane/report.xml +**/fastlane/Preview.html +**/fastlane/screenshots +**/fastlane/test_output + +# Bundle artifact +*.jsbundle + +# Ruby / CocoaPods +**/Pods/ +/vendor/bundle/ + +# Temporary files created by Metro to check the health of the file watcher +.metro-health-check* + +# testing +/coverage + +# Yarn +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions +android/app/src/main/assets/index.android.bundle + +android/app/src/main/raw/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts -# Version control -vcs.xml - -# lint -lint/intermediates/ -lint/generated/ -lint/outputs/ -lint/tmp/ -# lint/reports/ - -# Android Profiling -*.hprof -.idea/ diff --git a/README.md b/README.md index 0b04b57d..43efee26 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ This project is purely for educational purposes to learn about Android automatio - Android Device or Emulator (Nougat 7.0+) - For the best performance and stability on phones, the device needs to be at 1080p display resolution. The speed is also best at 1080p and for other resolutions, it becomes dependent on the manual scale that you can set in the settings. Right now it defaults to 1.0 which uses 1080p as the baseline. You can determine what scales may be good for you with the template match test that you can enable in the settings as well. - If you change the display resolution while the overlay button is still active, you will need to restart the app in order for the display changes to persist to the `MediaProjection` service. - - Tested emulator was on Bluestacks 5 (Pie 64-bit). The following setup is required: + - Tested emulator was on Bluestacks 5 (Pie 64-bit). Later versions of Bluestacks also should work. The following setup is required: - Portrait Mode needs to be forced on always. - Bluestacks itself needs to be updated to the latest version to avoid Uma Musume crashing. - In the Bluestacks Settings > Phone, the predefined profile needs to be set to a modern high-end phone like the Samsung Galaxy S22. @@ -26,6 +26,10 @@ This project is purely for educational purposes to learn about Android automatio - 4GB Memory - Display resolution set to Portrait 1080 x 1920 - Pixel density 240 DPI (Medium) + - Note that other emulators like MuMu may have their 1080p at a different DPI other than 240. MuMu by default uses 480 DPI which will throw template matching and OCR off. It is highly recommended to force it to 240 DPI in the emulator settings for the best performance. The following are tested resolutions + DPIs: + - 1080x1920 240 DPI (from Bluestacks emulator default settings) + - 1080x2340 450 DPI (from native Samsung phone) +- The in-game graphics need to be set to `Standard` instead of `Basic` for best performance. # Features @@ -88,24 +92,11 @@ Make sure to use 1.0 scaling, as well as 80% confidence for best results in 1080 1. Download and extract the project repository. 2. Go to `https://opencv.org/releases/` and download OpenCV (make sure to download the Android version of OpenCV) and extract it. As of 2025-07-20, the OpenCV version used in this project is 4.12.0. -3. Create a new folder inside the root of the project repository named `opencv` and copy the extracted files in `/OpenCV-android-sdk/sdk/` from Step 2 into it. -4. Open the project repository in `Android Studio`. -5. Open up the `opencv` module's `build.gradle`. At the end of the file, paste the following JVM Toolchain block: - -```kotlin -// Explicitly set Kotlin JVM toolchain to Java 17 to match the OpenCV module's Java target. -// Without this, Kotlin defaults to JVM 21 (especially with Kotlin 2.x), which causes a build failure: -// "Inconsistent JVM Target Compatibility Between Java and Kotlin Tasks". -// See: https://kotl.in/gradle/jvm/toolchain for details. -kotlin { - jvmToolchain { - languageVersion.set(JavaLanguageVersion.of(17)) - } -} -``` - -6. You can now build and run on your Android Device or build your own .apk file. -7. You can set `universalApk` to `true` in the app's `build.gradle` to build a one-for-all .apk file or adjust the `include 'arm64-v8a'` to customize which ABI to build the .apk file for. +3. Create a new folder inside the `/android` folder named `opencv` and copy the extracted files in `/OpenCV-android-sdk/sdk/` from Step 2 into it. +4. Open up the root of the folder in your preferred IDE/terminal and execute `yarn install` to install the React Native dependencies from the `package.json`. +5. Your dev environment should now be set up. You can run the app on your connected Android device/emulator to have hot-reload changes for the frontend via the Metro HTTP server with `yarn android`. You can also now build the APK with `yarn build` or `yarn build:clean` if you encounter problems. +6. You can set `universalApk` to `true` in the app's `build.gradle` to build a one-for-all .apk file or adjust the `include 'arm64-v8a'` to customize which ABI to build the .apk file for. +7. Note: New to React Native, you should not run the app directly from Android Studio. Have the Metro bundler run the app for you. # Technologies Used @@ -117,3 +108,4 @@ kotlin { 6. [Tesseract4Android - For performing OCR on the screen](https://github.com/adaptech-cz/Tesseract4Android) 7. [string-similarity - For comparing string similarities during text detection](https://github.com/rrice/java-string-similarity) 8. [AppUpdater - For automatically checking and notifying the user for new app updates](https://github.com/javiersantos/AppUpdater) +9. [React Native - Used as the frontend](https://reactnative.dev/) diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 00000000..b3b84464 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,118 @@ +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* + +/opencv + +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +# Android Profiling +*.hprof +.idea/ + +# Generated image files by react-navigation +app/src/main/drawable-*/ \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 00000000..da8320cd --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,249 @@ +apply plugin: "com.android.application" +apply plugin: "org.jetbrains.kotlin.android" +apply plugin: "com.facebook.react" + +def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath() + +/** + * This is the configuration block to customize your React Native Android app. + * By default you don't need to apply any configuration, just uncomment the lines you need. + */ +react { + entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim()) + reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile() + hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc" + codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile() + + enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean() + // Use Expo CLI to bundle the app, this ensures the Metro config + // works correctly with Expo projects. + cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim()) + bundleCommand = "export:embed" + + /* Folders */ + // The root of your project, i.e. where "package.json" lives. Default is '../..' + // root = file("../../") + // The folder where the react-native NPM package is. Default is ../../node_modules/react-native + // reactNativeDir = file("../../node_modules/react-native") + // The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen + // codegenDir = file("../../node_modules/@react-native/codegen") + + /* Variants */ + // The list of variants to that are debuggable. For those we're going to + // skip the bundling of the JS bundle and the assets. By default is just 'debug'. + // If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants. + // debuggableVariants = ["liteDebug", "prodDebug"] + + /* Bundling */ + // A list containing the node command and its flags. Default is just 'node'. + // nodeExecutableAndArgs = ["node"] + + // + // The path to the CLI configuration file. Default is empty. + // bundleConfig = file(../rn-cli.config.js) + // + // The name of the generated asset file containing your JS bundle + // bundleAssetName = "MyApplication.android.bundle" + // + // The entry file for bundle generation. Default is 'index.android.js' or 'index.js' + // entryFile = file("../js/MyApplication.android.js") + // + // A list of extra flags to pass to the 'bundle' commands. + // See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle + // extraPackagerArgs = [] + + /* Hermes Commands */ + // The hermes compiler command to run. By default it is 'hermesc' + // hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc" + // + // The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map" + // hermesFlags = ["-O", "-output-source-map"] + + /* Autolinking */ + autolinkLibrariesWithApp() +} + +/** + * Set this to true in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization). + */ +def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean() + +/** + * The preferred build flavor of JavaScriptCore (JSC) + * + * For example, to use the international variant, you can use: + * `def jscFlavor = 'org.webkit:android-jsc-intl:+'` + * + * The international variant includes ICU i18n library and necessary data + * allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that + * give correct results when using with locales other than en-US. Note that + * this variant is about 6MiB larger per architecture than default. + */ +def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+' + +android { + ndkVersion rootProject.ext.ndkVersion + + buildToolsVersion libs.versions.app.buildToolsVersion.get() + compileSdkVersion libs.versions.app.compileSdk.get() as Integer + + namespace 'com.steve1316.uma_android_automation' + defaultConfig { + applicationId 'com.steve1316.uma_android_automation' + minSdkVersion libs.versions.app.minSdk.get() as Integer + //noinspection ExpiredTargetSdkVersion + targetSdkVersion libs.versions.app.targetSdk.get() as Integer + versionCode libs.versions.app.versionCode.get() as Integer + versionName libs.versions.app.versionName.get() + + buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" + } + signingConfigs { + debug { + storeFile file('debug.keystore') + storePassword 'android' + keyAlias 'androiddebugkey' + keyPassword 'android' + } + } + buildTypes { + debug { + signingConfig signingConfigs.debug + crunchPngs true // Good for reducing APK size. + // Usually no minification or resource shrinking. + } + release { + // Caution! In production, you need to generate your own keystore file. + // see https://reactnative.dev/docs/signed-apk-android. + signingConfig signingConfigs.debug + shrinkResources (findProperty('android.enableShrinkResourcesInReleaseBuilds')?.toBoolean() ?: false) + minifyEnabled enableMinifyInReleaseBuilds + proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" + crunchPngs (findProperty('android.enablePngCrunchInReleaseBuilds')?.toBoolean() ?: true) + } + } + packagingOptions { + jniLibs { + useLegacyPackaging (findProperty('expo.useLegacyPackaging')?.toBoolean() ?: false) + } + } + androidResources { + ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~' + } + + // Specify which architecture to make apks for, or set universalApk to true for an all-in-one apk with increased file size. + splits { + abi { + enable true + reset() + //noinspection ChromeOsAbiSupport + include("armeabi-v7a", "arm64-v8a") + // include "armeabi","armeabi-v7a",'arm64-v8a',"mips","x86","x86_64" + universalApk false + } + } + + applicationVariants.configureEach { variant -> + def releaseType = variant.buildType.name + // Allow layout XMLs to get a reference to the application's version number. + variant.resValue("string", "versionName", "v${variant.versionName}") + + // Auto-generate the file name. + // To access the output file name, the apk variants must be explicitly cast to, + // as in the previous groovy version (where they were implicitly cast). + variant.outputs.configureEach { output -> + def type = releaseType + def versionName = variant.versionName + def architecture = output.filters.first().identifier + output.outputFileName = "v${versionName}-UmaAndroidAutomation-${architecture}-${type}.apk" + } + } + + androidResources { + noCompress += ["bundle"] + } +} + +// Apply static values from `gradle.properties` to the `android.packagingOptions` +// Accepts values in comma delimited lists, example: +// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini +["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop -> + // Split option: 'foo,bar' -> ['foo', 'bar'] + def options = (findProperty("android.packagingOptions.$prop") ?: "").split(",") + // Trim all elements in place. + for (i in 0.. 0) { + println "android.packagingOptions.$prop += $options ($options.length)" + // Ex: android.packagingOptions.pickFirsts += '**/SCCS/**' + options.each { + android.packagingOptions[prop] += it + } + } +} + +dependencies { + // Automation Library + implementation(libs.android.cv.automation.library) + + // React Native + implementation(libs.react.android) + implementation(libs.hermes.android) + + // Lottie for the native splash screen + implementation(libs.lottie) + + if (hermesEnabled.toBoolean()) { + implementation(libs.hermes.android) + } else { + implementation jscFlavor + } +} + +// ============================================================================ +// React Native Bundle Configuration and Build Tasks +// ============================================================================ + +// Clean task to remove old React Native bundles and generated assets. +// This prevents stale bundles from being included in new builds. +tasks.register('cleanBundle', Delete) { + delete("${projectDir}/src/main/assets/index.android.bundle") + delete("${projectDir}/build") + delete("${projectDir}/.cxx") + + doLast { + println("CleanBundle task completed. Deleted:") + println("- ${projectDir}/src/main/assets/index.android.bundle") + println("- ${projectDir}/build") + println("- ${projectDir}/.cxx") + } +} + +// Make the main clean task depend on cleanBundle to ensure React Native assets are cleaned. +clean.dependsOn(cleanBundle) + +// Generate the React Native JavaScript bundle before building the APK. +// This task runs the generate-bundle.js script to create the JavaScript bundle. +tasks.register("generateBundle", Exec) { + workingDir = projectDir.parentFile + commandLine("node", "generate-bundle.js") + + // Only run if the bundle generation script exists. + onlyIf { + file("${projectDir.parentFile}/generate-bundle.js").exists() + } + + // Handle case where the bundle generation script doesn't exist. + doFirst { + if (!file("${projectDir.parentFile}/generate-bundle.js").exists()) { + println("Bundle generation script not found, skipping...") + enabled = false + } + } +} + +// Ensure the React Native bundle is generated before the preBuild task runs. +// This guarantees the bundle is available for the build process. +preBuild.dependsOn(generateBundle) diff --git a/android/app/debug.keystore b/android/app/debug.keystore new file mode 100644 index 00000000..364e105e Binary files /dev/null and b/android/app/debug.keystore differ diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 00000000..551eb41d --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,14 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# react-native-reanimated +-keep class com.swmansion.reanimated.** { *; } +-keep class com.facebook.react.turbomodule.** { *; } + +# Add any project specific keep options here: diff --git a/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml similarity index 70% rename from app/src/main/AndroidManifest.xml rename to android/app/src/main/AndroidManifest.xml index 09c9b029..1509d9cd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,7 +1,6 @@ + xmlns:tools="http://schemas.android.com/tools"> @@ -19,29 +18,39 @@ + tools:ignore="AllowBackup" + android:usesCleartextTraffic="true"> - + + + + android:exported="true" + android:launchMode="singleTask" + android:resizeableActivity="true" + android:supportsPictureInPicture="true" + android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation" + android:theme="@style/Theme.SplashTheme" + android:taskAffinity=""> - @@ -55,15 +64,15 @@ - + diff --git a/app/src/main/assets/eng.traineddata b/android/app/src/main/assets/eng.traineddata similarity index 100% rename from app/src/main/assets/eng.traineddata rename to android/app/src/main/assets/eng.traineddata diff --git a/app/src/main/assets/images/+.png b/android/app/src/main/assets/images/+.png similarity index 100% rename from app/src/main/assets/images/+.png rename to android/app/src/main/assets/images/+.png diff --git a/app/src/main/assets/images/0.png b/android/app/src/main/assets/images/0.png similarity index 100% rename from app/src/main/assets/images/0.png rename to android/app/src/main/assets/images/0.png diff --git a/app/src/main/assets/images/1.png b/android/app/src/main/assets/images/1.png similarity index 100% rename from app/src/main/assets/images/1.png rename to android/app/src/main/assets/images/1.png diff --git a/app/src/main/assets/images/2.png b/android/app/src/main/assets/images/2.png similarity index 100% rename from app/src/main/assets/images/2.png rename to android/app/src/main/assets/images/2.png diff --git a/app/src/main/assets/images/3.png b/android/app/src/main/assets/images/3.png similarity index 100% rename from app/src/main/assets/images/3.png rename to android/app/src/main/assets/images/3.png diff --git a/app/src/main/assets/images/4.png b/android/app/src/main/assets/images/4.png similarity index 100% rename from app/src/main/assets/images/4.png rename to android/app/src/main/assets/images/4.png diff --git a/app/src/main/assets/images/5.png b/android/app/src/main/assets/images/5.png similarity index 100% rename from app/src/main/assets/images/5.png rename to android/app/src/main/assets/images/5.png diff --git a/app/src/main/assets/images/6.png b/android/app/src/main/assets/images/6.png similarity index 100% rename from app/src/main/assets/images/6.png rename to android/app/src/main/assets/images/6.png diff --git a/app/src/main/assets/images/7.png b/android/app/src/main/assets/images/7.png similarity index 100% rename from app/src/main/assets/images/7.png rename to android/app/src/main/assets/images/7.png diff --git a/app/src/main/assets/images/8.png b/android/app/src/main/assets/images/8.png similarity index 100% rename from app/src/main/assets/images/8.png rename to android/app/src/main/assets/images/8.png diff --git a/app/src/main/assets/images/9.png b/android/app/src/main/assets/images/9.png similarity index 100% rename from app/src/main/assets/images/9.png rename to android/app/src/main/assets/images/9.png diff --git a/app/src/main/assets/images/aoharu_final_race.webp b/android/app/src/main/assets/images/aoharu_final_race.webp similarity index 100% rename from app/src/main/assets/images/aoharu_final_race.webp rename to android/app/src/main/assets/images/aoharu_final_race.webp diff --git a/app/src/main/assets/images/aoharu_race.webp b/android/app/src/main/assets/images/aoharu_race.webp similarity index 100% rename from app/src/main/assets/images/aoharu_race.webp rename to android/app/src/main/assets/images/aoharu_race.webp diff --git a/app/src/main/assets/images/aoharu_race_header.webp b/android/app/src/main/assets/images/aoharu_race_header.webp similarity index 100% rename from app/src/main/assets/images/aoharu_race_header.webp rename to android/app/src/main/assets/images/aoharu_race_header.webp diff --git a/app/src/main/assets/images/aoharu_race_option.webp b/android/app/src/main/assets/images/aoharu_race_option.webp similarity index 100% rename from app/src/main/assets/images/aoharu_race_option.webp rename to android/app/src/main/assets/images/aoharu_race_option.webp diff --git a/app/src/main/assets/images/aoharu_run_race.webp b/android/app/src/main/assets/images/aoharu_run_race.webp similarity index 100% rename from app/src/main/assets/images/aoharu_run_race.webp rename to android/app/src/main/assets/images/aoharu_run_race.webp diff --git a/app/src/main/assets/images/aoharu_select_race.webp b/android/app/src/main/assets/images/aoharu_select_race.webp similarity index 100% rename from app/src/main/assets/images/aoharu_select_race.webp rename to android/app/src/main/assets/images/aoharu_select_race.webp diff --git a/app/src/main/assets/images/aoharu_set_initial_team_header.webp b/android/app/src/main/assets/images/aoharu_set_initial_team_header.webp similarity index 100% rename from app/src/main/assets/images/aoharu_set_initial_team_header.webp rename to android/app/src/main/assets/images/aoharu_set_initial_team_header.webp diff --git a/app/src/main/assets/images/aoharu_special_training.webp b/android/app/src/main/assets/images/aoharu_special_training.webp similarity index 100% rename from app/src/main/assets/images/aoharu_special_training.webp rename to android/app/src/main/assets/images/aoharu_special_training.webp diff --git a/app/src/main/assets/images/aoharu_spirit_explosion.webp b/android/app/src/main/assets/images/aoharu_spirit_explosion.webp similarity index 100% rename from app/src/main/assets/images/aoharu_spirit_explosion.webp rename to android/app/src/main/assets/images/aoharu_spirit_explosion.webp diff --git a/app/src/main/assets/images/aoharu_stat_speed.webp b/android/app/src/main/assets/images/aoharu_stat_speed.webp similarity index 100% rename from app/src/main/assets/images/aoharu_stat_speed.webp rename to android/app/src/main/assets/images/aoharu_stat_speed.webp diff --git a/app/src/main/assets/images/aoharu_tutorial_header.webp b/android/app/src/main/assets/images/aoharu_tutorial_header.webp similarity index 100% rename from app/src/main/assets/images/aoharu_tutorial_header.webp rename to android/app/src/main/assets/images/aoharu_tutorial_header.webp diff --git a/app/src/main/assets/images/back.png b/android/app/src/main/assets/images/back.png similarity index 100% rename from app/src/main/assets/images/back.png rename to android/app/src/main/assets/images/back.png diff --git a/app/src/main/assets/images/cancel.png b/android/app/src/main/assets/images/cancel.png similarity index 100% rename from app/src/main/assets/images/cancel.png rename to android/app/src/main/assets/images/cancel.png diff --git a/app/src/main/assets/images/complete_career.png b/android/app/src/main/assets/images/complete_career.png similarity index 100% rename from app/src/main/assets/images/complete_career.png rename to android/app/src/main/assets/images/complete_career.png diff --git a/android/app/src/main/assets/images/confirm.png b/android/app/src/main/assets/images/confirm.png new file mode 100644 index 00000000..18b0afa6 Binary files /dev/null and b/android/app/src/main/assets/images/confirm.png differ diff --git a/app/src/main/assets/images/connecting.png b/android/app/src/main/assets/images/connecting.png similarity index 100% rename from app/src/main/assets/images/connecting.png rename to android/app/src/main/assets/images/connecting.png diff --git a/app/src/main/assets/images/connection_error.png b/android/app/src/main/assets/images/connection_error.png similarity index 100% rename from app/src/main/assets/images/connection_error.png rename to android/app/src/main/assets/images/connection_error.png diff --git a/app/src/main/assets/images/crane_game.png b/android/app/src/main/assets/images/crane_game.png similarity index 100% rename from app/src/main/assets/images/crane_game.png rename to android/app/src/main/assets/images/crane_game.png diff --git a/app/src/main/assets/images/energy.png b/android/app/src/main/assets/images/energy.png similarity index 100% rename from app/src/main/assets/images/energy.png rename to android/app/src/main/assets/images/energy.png diff --git a/android/app/src/main/assets/images/guts_training_header.png b/android/app/src/main/assets/images/guts_training_header.png new file mode 100644 index 00000000..d0053f7c Binary files /dev/null and b/android/app/src/main/assets/images/guts_training_header.png differ diff --git a/app/src/main/assets/images/inheritance.png b/android/app/src/main/assets/images/inheritance.png similarity index 100% rename from app/src/main/assets/images/inheritance.png rename to android/app/src/main/assets/images/inheritance.png diff --git a/app/src/main/assets/images/main_status.png b/android/app/src/main/assets/images/main_status.png similarity index 100% rename from app/src/main/assets/images/main_status.png rename to android/app/src/main/assets/images/main_status.png diff --git a/app/src/main/assets/images/mood_good.png b/android/app/src/main/assets/images/mood_good.png similarity index 100% rename from app/src/main/assets/images/mood_good.png rename to android/app/src/main/assets/images/mood_good.png diff --git a/app/src/main/assets/images/mood_great.png b/android/app/src/main/assets/images/mood_great.png similarity index 100% rename from app/src/main/assets/images/mood_great.png rename to android/app/src/main/assets/images/mood_great.png diff --git a/app/src/main/assets/images/mood_normal.png b/android/app/src/main/assets/images/mood_normal.png similarity index 100% rename from app/src/main/assets/images/mood_normal.png rename to android/app/src/main/assets/images/mood_normal.png diff --git a/app/src/main/assets/images/next.png b/android/app/src/main/assets/images/next.png similarity index 100% rename from app/src/main/assets/images/next.png rename to android/app/src/main/assets/images/next.png diff --git a/app/src/main/assets/images/now_loading.png b/android/app/src/main/assets/images/now_loading.png similarity index 100% rename from app/src/main/assets/images/now_loading.png rename to android/app/src/main/assets/images/now_loading.png diff --git a/app/src/main/assets/images/ok.png b/android/app/src/main/assets/images/ok.png similarity index 100% rename from app/src/main/assets/images/ok.png rename to android/app/src/main/assets/images/ok.png diff --git a/android/app/src/main/assets/images/power_training_header.png b/android/app/src/main/assets/images/power_training_header.png new file mode 100644 index 00000000..7a3251eb Binary files /dev/null and b/android/app/src/main/assets/images/power_training_header.png differ diff --git a/app/src/main/assets/images/race_accept_trophy.png b/android/app/src/main/assets/images/race_accept_trophy.png similarity index 100% rename from app/src/main/assets/images/race_accept_trophy.png rename to android/app/src/main/assets/images/race_accept_trophy.png diff --git a/app/src/main/assets/images/race_change_strategy.png b/android/app/src/main/assets/images/race_change_strategy.png similarity index 100% rename from app/src/main/assets/images/race_change_strategy.png rename to android/app/src/main/assets/images/race_change_strategy.png diff --git a/app/src/main/assets/images/race_confirm.png b/android/app/src/main/assets/images/race_confirm.png similarity index 100% rename from app/src/main/assets/images/race_confirm.png rename to android/app/src/main/assets/images/race_confirm.png diff --git a/app/src/main/assets/images/race_end.png b/android/app/src/main/assets/images/race_end.png similarity index 100% rename from app/src/main/assets/images/race_end.png rename to android/app/src/main/assets/images/race_end.png diff --git a/app/src/main/assets/images/race_extra_double_prediction.png b/android/app/src/main/assets/images/race_extra_double_prediction.png similarity index 100% rename from app/src/main/assets/images/race_extra_double_prediction.png rename to android/app/src/main/assets/images/race_extra_double_prediction.png diff --git a/app/src/main/assets/images/race_extra_selection.png b/android/app/src/main/assets/images/race_extra_selection.png similarity index 100% rename from app/src/main/assets/images/race_extra_selection.png rename to android/app/src/main/assets/images/race_extra_selection.png diff --git a/android/app/src/main/assets/images/race_fans_criteria.png b/android/app/src/main/assets/images/race_fans_criteria.png new file mode 100644 index 00000000..cfca6acf Binary files /dev/null and b/android/app/src/main/assets/images/race_fans_criteria.png differ diff --git a/app/src/main/assets/images/race_manual.png b/android/app/src/main/assets/images/race_manual.png similarity index 100% rename from app/src/main/assets/images/race_manual.png rename to android/app/src/main/assets/images/race_manual.png diff --git a/app/src/main/assets/images/race_none_available.png b/android/app/src/main/assets/images/race_none_available.png similarity index 100% rename from app/src/main/assets/images/race_none_available.png rename to android/app/src/main/assets/images/race_none_available.png diff --git a/app/src/main/assets/images/race_not_enough_fans.png b/android/app/src/main/assets/images/race_not_enough_fans.png similarity index 100% rename from app/src/main/assets/images/race_not_enough_fans.png rename to android/app/src/main/assets/images/race_not_enough_fans.png diff --git a/app/src/main/assets/images/race_prediction_double_circle.png b/android/app/src/main/assets/images/race_prediction_double_circle.png similarity index 100% rename from app/src/main/assets/images/race_prediction_double_circle.png rename to android/app/src/main/assets/images/race_prediction_double_circle.png diff --git a/app/src/main/assets/images/race_repeat_warning.png b/android/app/src/main/assets/images/race_repeat_warning.png similarity index 100% rename from app/src/main/assets/images/race_repeat_warning.png rename to android/app/src/main/assets/images/race_repeat_warning.png diff --git a/app/src/main/assets/images/race_retry.png b/android/app/src/main/assets/images/race_retry.png similarity index 100% rename from app/src/main/assets/images/race_retry.png rename to android/app/src/main/assets/images/race_retry.png diff --git a/app/src/main/assets/images/race_select_extra.png b/android/app/src/main/assets/images/race_select_extra.png similarity index 100% rename from app/src/main/assets/images/race_select_extra.png rename to android/app/src/main/assets/images/race_select_extra.png diff --git a/app/src/main/assets/images/race_select_extra_locked.png b/android/app/src/main/assets/images/race_select_extra_locked.png similarity index 100% rename from app/src/main/assets/images/race_select_extra_locked.png rename to android/app/src/main/assets/images/race_select_extra_locked.png diff --git a/app/src/main/assets/images/race_select_extra_locked_uma_finals.png b/android/app/src/main/assets/images/race_select_extra_locked_uma_finals.png similarity index 100% rename from app/src/main/assets/images/race_select_extra_locked_uma_finals.png rename to android/app/src/main/assets/images/race_select_extra_locked_uma_finals.png diff --git a/app/src/main/assets/images/race_select_mandatory.png b/android/app/src/main/assets/images/race_select_mandatory.png similarity index 100% rename from app/src/main/assets/images/race_select_mandatory.png rename to android/app/src/main/assets/images/race_select_mandatory.png diff --git a/app/src/main/assets/images/race_select_mandatory_goal.png b/android/app/src/main/assets/images/race_select_mandatory_goal.png similarity index 100% rename from app/src/main/assets/images/race_select_mandatory_goal.png rename to android/app/src/main/assets/images/race_select_mandatory_goal.png diff --git a/app/src/main/assets/images/race_selection_fans.png b/android/app/src/main/assets/images/race_selection_fans.png similarity index 100% rename from app/src/main/assets/images/race_selection_fans.png rename to android/app/src/main/assets/images/race_selection_fans.png diff --git a/app/src/main/assets/images/race_skip.png b/android/app/src/main/assets/images/race_skip.png similarity index 100% rename from app/src/main/assets/images/race_skip.png rename to android/app/src/main/assets/images/race_skip.png diff --git a/app/src/main/assets/images/race_skip_locked.png b/android/app/src/main/assets/images/race_skip_locked.png similarity index 100% rename from app/src/main/assets/images/race_skip_locked.png rename to android/app/src/main/assets/images/race_skip_locked.png diff --git a/app/src/main/assets/images/race_skip_manual.png b/android/app/src/main/assets/images/race_skip_manual.png similarity index 100% rename from app/src/main/assets/images/race_skip_manual.png rename to android/app/src/main/assets/images/race_skip_manual.png diff --git a/app/src/main/assets/images/race_status.png b/android/app/src/main/assets/images/race_status.png similarity index 100% rename from app/src/main/assets/images/race_status.png rename to android/app/src/main/assets/images/race_status.png diff --git a/android/app/src/main/assets/images/race_strategy_end.png b/android/app/src/main/assets/images/race_strategy_end.png new file mode 100644 index 00000000..cee284ab Binary files /dev/null and b/android/app/src/main/assets/images/race_strategy_end.png differ diff --git a/android/app/src/main/assets/images/race_strategy_front.png b/android/app/src/main/assets/images/race_strategy_front.png new file mode 100644 index 00000000..5a67eebb Binary files /dev/null and b/android/app/src/main/assets/images/race_strategy_front.png differ diff --git a/android/app/src/main/assets/images/race_strategy_late.png b/android/app/src/main/assets/images/race_strategy_late.png new file mode 100644 index 00000000..9675805e Binary files /dev/null and b/android/app/src/main/assets/images/race_strategy_late.png differ diff --git a/android/app/src/main/assets/images/race_strategy_pace.png b/android/app/src/main/assets/images/race_strategy_pace.png new file mode 100644 index 00000000..b046469b Binary files /dev/null and b/android/app/src/main/assets/images/race_strategy_pace.png differ diff --git a/app/src/main/assets/images/recover_energy.png b/android/app/src/main/assets/images/recover_energy.png similarity index 100% rename from app/src/main/assets/images/recover_energy.png rename to android/app/src/main/assets/images/recover_energy.png diff --git a/app/src/main/assets/images/recover_energy_summer.png b/android/app/src/main/assets/images/recover_energy_summer.png similarity index 100% rename from app/src/main/assets/images/recover_energy_summer.png rename to android/app/src/main/assets/images/recover_energy_summer.png diff --git a/app/src/main/assets/images/recover_injury.png b/android/app/src/main/assets/images/recover_injury.png similarity index 100% rename from app/src/main/assets/images/recover_injury.png rename to android/app/src/main/assets/images/recover_injury.png diff --git a/app/src/main/assets/images/recover_injury_header.png b/android/app/src/main/assets/images/recover_injury_header.png similarity index 100% rename from app/src/main/assets/images/recover_injury_header.png rename to android/app/src/main/assets/images/recover_injury_header.png diff --git a/app/src/main/assets/images/recover_mood.png b/android/app/src/main/assets/images/recover_mood.png similarity index 100% rename from app/src/main/assets/images/recover_mood.png rename to android/app/src/main/assets/images/recover_mood.png diff --git a/app/src/main/assets/images/recover_mood_date.png b/android/app/src/main/assets/images/recover_mood_date.png similarity index 100% rename from app/src/main/assets/images/recover_mood_date.png rename to android/app/src/main/assets/images/recover_mood_date.png diff --git a/app/src/main/assets/images/shift.png b/android/app/src/main/assets/images/shift.png similarity index 100% rename from app/src/main/assets/images/shift.png rename to android/app/src/main/assets/images/shift.png diff --git a/app/src/main/assets/images/skill_points.png b/android/app/src/main/assets/images/skill_points.png similarity index 100% rename from app/src/main/assets/images/skill_points.png rename to android/app/src/main/assets/images/skill_points.png diff --git a/app/src/main/assets/images/speed_training_header.png b/android/app/src/main/assets/images/speed_training_header.png similarity index 100% rename from app/src/main/assets/images/speed_training_header.png rename to android/app/src/main/assets/images/speed_training_header.png diff --git a/android/app/src/main/assets/images/stamina_training_header.png b/android/app/src/main/assets/images/stamina_training_header.png new file mode 100644 index 00000000..e6daf48f Binary files /dev/null and b/android/app/src/main/assets/images/stamina_training_header.png differ diff --git a/app/src/main/assets/images/stat_aptitude_A.png b/android/app/src/main/assets/images/stat_aptitude_A.png similarity index 100% rename from app/src/main/assets/images/stat_aptitude_A.png rename to android/app/src/main/assets/images/stat_aptitude_A.png diff --git a/app/src/main/assets/images/stat_aptitude_B.png b/android/app/src/main/assets/images/stat_aptitude_B.png similarity index 100% rename from app/src/main/assets/images/stat_aptitude_B.png rename to android/app/src/main/assets/images/stat_aptitude_B.png diff --git a/app/src/main/assets/images/stat_aptitude_S.png b/android/app/src/main/assets/images/stat_aptitude_S.png similarity index 100% rename from app/src/main/assets/images/stat_aptitude_S.png rename to android/app/src/main/assets/images/stat_aptitude_S.png diff --git a/app/src/main/assets/images/stat_distance.png b/android/app/src/main/assets/images/stat_distance.png similarity index 100% rename from app/src/main/assets/images/stat_distance.png rename to android/app/src/main/assets/images/stat_distance.png diff --git a/app/src/main/assets/images/stat_friendship_block.png b/android/app/src/main/assets/images/stat_friendship_block.png similarity index 100% rename from app/src/main/assets/images/stat_friendship_block.png rename to android/app/src/main/assets/images/stat_friendship_block.png diff --git a/app/src/main/assets/images/stat_guts_block.png b/android/app/src/main/assets/images/stat_guts_block.png similarity index 100% rename from app/src/main/assets/images/stat_guts_block.png rename to android/app/src/main/assets/images/stat_guts_block.png diff --git a/app/src/main/assets/images/stat_maxed.png b/android/app/src/main/assets/images/stat_maxed.png similarity index 100% rename from app/src/main/assets/images/stat_maxed.png rename to android/app/src/main/assets/images/stat_maxed.png diff --git a/app/src/main/assets/images/stat_power_block.png b/android/app/src/main/assets/images/stat_power_block.png similarity index 100% rename from app/src/main/assets/images/stat_power_block.png rename to android/app/src/main/assets/images/stat_power_block.png diff --git a/app/src/main/assets/images/stat_skill_hint.png b/android/app/src/main/assets/images/stat_skill_hint.png similarity index 100% rename from app/src/main/assets/images/stat_skill_hint.png rename to android/app/src/main/assets/images/stat_skill_hint.png diff --git a/app/src/main/assets/images/stat_speed.png b/android/app/src/main/assets/images/stat_speed.png similarity index 100% rename from app/src/main/assets/images/stat_speed.png rename to android/app/src/main/assets/images/stat_speed.png diff --git a/app/src/main/assets/images/stat_speed_block.png b/android/app/src/main/assets/images/stat_speed_block.png similarity index 100% rename from app/src/main/assets/images/stat_speed_block.png rename to android/app/src/main/assets/images/stat_speed_block.png diff --git a/app/src/main/assets/images/stat_stamina_block.png b/android/app/src/main/assets/images/stat_stamina_block.png similarity index 100% rename from app/src/main/assets/images/stat_stamina_block.png rename to android/app/src/main/assets/images/stat_stamina_block.png diff --git a/android/app/src/main/assets/images/stat_style.png b/android/app/src/main/assets/images/stat_style.png new file mode 100644 index 00000000..3af34f0a Binary files /dev/null and b/android/app/src/main/assets/images/stat_style.png differ diff --git a/android/app/src/main/assets/images/stat_track.png b/android/app/src/main/assets/images/stat_track.png new file mode 100644 index 00000000..a6274281 Binary files /dev/null and b/android/app/src/main/assets/images/stat_track.png differ diff --git a/app/src/main/assets/images/stat_wit_block.png b/android/app/src/main/assets/images/stat_wit_block.png similarity index 100% rename from app/src/main/assets/images/stat_wit_block.png rename to android/app/src/main/assets/images/stat_wit_block.png diff --git a/app/src/main/assets/images/tazuna.png b/android/app/src/main/assets/images/tazuna.png similarity index 100% rename from app/src/main/assets/images/tazuna.png rename to android/app/src/main/assets/images/tazuna.png diff --git a/app/src/main/assets/images/training_event_active.png b/android/app/src/main/assets/images/training_event_active.png similarity index 100% rename from app/src/main/assets/images/training_event_active.png rename to android/app/src/main/assets/images/training_event_active.png diff --git a/app/src/main/assets/images/training_failure_chance.png b/android/app/src/main/assets/images/training_failure_chance.png similarity index 100% rename from app/src/main/assets/images/training_failure_chance.png rename to android/app/src/main/assets/images/training_failure_chance.png diff --git a/app/src/main/assets/images/training_guts.png b/android/app/src/main/assets/images/training_guts.png similarity index 100% rename from app/src/main/assets/images/training_guts.png rename to android/app/src/main/assets/images/training_guts.png diff --git a/app/src/main/assets/images/training_option.png b/android/app/src/main/assets/images/training_option.png similarity index 100% rename from app/src/main/assets/images/training_option.png rename to android/app/src/main/assets/images/training_option.png diff --git a/app/src/main/assets/images/training_option_circular.png b/android/app/src/main/assets/images/training_option_circular.png similarity index 100% rename from app/src/main/assets/images/training_option_circular.png rename to android/app/src/main/assets/images/training_option_circular.png diff --git a/app/src/main/assets/images/training_power.png b/android/app/src/main/assets/images/training_power.png similarity index 100% rename from app/src/main/assets/images/training_power.png rename to android/app/src/main/assets/images/training_power.png diff --git a/android/app/src/main/assets/images/training_rainbow.png b/android/app/src/main/assets/images/training_rainbow.png new file mode 100644 index 00000000..e4892358 Binary files /dev/null and b/android/app/src/main/assets/images/training_rainbow.png differ diff --git a/app/src/main/assets/images/training_speed.png b/android/app/src/main/assets/images/training_speed.png similarity index 100% rename from app/src/main/assets/images/training_speed.png rename to android/app/src/main/assets/images/training_speed.png diff --git a/app/src/main/assets/images/training_stamina.png b/android/app/src/main/assets/images/training_stamina.png similarity index 100% rename from app/src/main/assets/images/training_stamina.png rename to android/app/src/main/assets/images/training_stamina.png diff --git a/app/src/main/assets/images/training_wit.png b/android/app/src/main/assets/images/training_wit.png similarity index 100% rename from app/src/main/assets/images/training_wit.png rename to android/app/src/main/assets/images/training_wit.png diff --git a/android/app/src/main/assets/images/wit_training_header.png b/android/app/src/main/assets/images/wit_training_header.png new file mode 100644 index 00000000..3a63c857 Binary files /dev/null and b/android/app/src/main/assets/images/wit_training_header.png differ diff --git a/app/src/main/assets/readme/example1.png b/android/app/src/main/assets/readme/example1.png similarity index 100% rename from app/src/main/assets/readme/example1.png rename to android/app/src/main/assets/readme/example1.png diff --git a/app/src/main/assets/readme/example1_formatted.png b/android/app/src/main/assets/readme/example1_formatted.png similarity index 100% rename from app/src/main/assets/readme/example1_formatted.png rename to android/app/src/main/assets/readme/example1_formatted.png diff --git a/app/src/main/assets/readme/example2.png b/android/app/src/main/assets/readme/example2.png similarity index 100% rename from app/src/main/assets/readme/example2.png rename to android/app/src/main/assets/readme/example2.png diff --git a/app/src/main/assets/readme/example2_formatted.png b/android/app/src/main/assets/readme/example2_formatted.png similarity index 100% rename from app/src/main/assets/readme/example2_formatted.png rename to android/app/src/main/assets/readme/example2_formatted.png diff --git a/app/src/main/assets/readme/example3.png b/android/app/src/main/assets/readme/example3.png similarity index 100% rename from app/src/main/assets/readme/example3.png rename to android/app/src/main/assets/readme/example3.png diff --git a/android/app/src/main/assets/splash.json b/android/app/src/main/assets/splash.json new file mode 100644 index 00000000..4ec78feb --- /dev/null +++ b/android/app/src/main/assets/splash.json @@ -0,0 +1 @@ +{"assets":[{"h":192,"id":"0","p":"data:image/png;base64,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","u":"","w":192,"e":1},{"id":"43","layers":[{"ind":42,"ty":4,"parent":41,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"rc","p":{"a":0,"k":[97.378,14.4]},"r":{"a":0,"k":0},"s":{"a":0,"k":[194.756,28.8]}},{"ty":"fl","c":{"a":0,"k":[0,0,0]},"o":{"a":0,"k":100}}]},{"ind":41,"ty":3,"parent":40,"ks":{"p":{"a":0,"k":[-28.8,0]}},"ip":0,"op":61,"st":0},{"ind":40,"ty":3,"ks":{"p":{"a":0,"k":[29,0]}},"ip":0,"op":61,"st":0}]},{"id":"47","layers":[{"ind":45,"ty":0,"parent":39,"ks":{"a":{"a":0,"k":[29,0]}},"w":224,"h":29,"ip":0,"op":61,"st":0,"refId":"43"},{"ind":46,"ty":4,"parent":39,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"h":1},{"t":60,"s":[0],"h":1},{"t":60,"s":[100],"h":1}]}},"ip":0,"op":61,"st":0,"shapes":[{"ty":"rc","p":{"a":0,"k":[67.945,26.854]},"r":{"a":0,"k":0},"s":{"a":0,"k":[135.422,52.291]}},{"ty":"fl","c":{"a":0,"k":[0,0,0]},"o":{"a":0,"k":100}}]},{"ind":39,"ty":3,"ks":{"p":{"a":0,"k":[29,0]}},"ip":0,"op":61,"st":0}]},{"id":"36","layers":[{"ind":17,"ty":4,"parent":16,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[-0.7,0.6],[-1.1,0],[0,0],[-0.7,-0.4],[-0.4,-0.7],[0,-1],[0,0],[0,0],[0,0],[0,0],[0.4,0.5],[0.7,0],[0,0],[0.4,-0.2],[0.2,-0.4],[0,-0.6]],"v":[[5,15.5],[5,15.5],[5,23.1],[1.5,23.1],[1.5,10],[4.8,10],[4.9,13.3],[4.7,13.3],[6.2,10.8],[8.9,9.9],[8.9,9.9],[11.3,10.5],[12.9,12.2],[13.4,14.8],[13.4,14.8],[13.4,23.1],[9.9,23.1],[9.9,15.4],[9.3,13.5],[7.6,12.8],[7.6,12.8],[6.2,13.1],[5.3,14.1],[5,15.5]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0.3,-1.1],[0.7,-0.6],[0,0],[0.9,0],[0.7,0.4],[0.4,0.7],[0,0],[0,0],[0,0],[0,0],[0,-0.8],[-0.4,-0.5],[0,0],[-0.5,0],[-0.4,0.2],[-0.2,0.4],[0,0]]}}},{"ty":"fl","c":{"a":0,"k":[1,1,1]},"o":{"a":0,"k":100}}]},{"ind":16,"ty":3,"parent":15,"ks":{"p":{"a":0,"k":[122.215,0]}},"ip":0,"op":61,"st":0},{"ind":19,"ty":4,"parent":18,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[1,0.6],[0.5,1],[0,1.3],[0,0],[-0.5,1],[-1,0.6],[-1.3,0],[0,0],[-1,-0.6],[-0.5,-1],[0,-1.4],[0,0],[0.5,-1],[1,-0.6],[1.3,0]],"v":[[7.4,23.4],[7.4,23.4],[3.9,22.5],[1.7,20.2],[0.9,16.6],[0.9,16.6],[1.7,13.1],[3.9,10.7],[7.4,9.9],[7.4,9.9],[10.8,10.7],[13,13.1],[13.8,16.6],[13.8,16.6],[13,20.2],[10.8,22.5],[7.4,23.4]],"o":[[0,0],[-1.3,0],[-1,-0.6],[-0.5,-1],[0,0],[0,-1.4],[0.5,-1],[1,-0.6],[0,0],[1.3,0],[1,0.6],[0.5,1],[0,0],[0,1.3],[-0.5,1],[-1,0.6],[0,0]]}}},{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[-0.4,0.3],[-0.2,0.6],[0,0.8],[0,0],[0.2,0.6],[0.4,0.3],[0.6,0],[0,0],[0.4,-0.3],[0.2,-0.6],[0,-0.8],[0,0],[-0.2,-0.6],[-0.4,-0.3],[-0.6,0]],"v":[[7.4,20.6],[7.4,20.6],[8.9,20.1],[9.9,18.7],[10.2,16.6],[10.2,16.6],[9.9,14.6],[8.9,13.1],[7.4,12.6],[7.4,12.6],[5.8,13.1],[4.9,14.6],[4.5,16.6],[4.5,16.6],[4.9,18.7],[5.8,20.1],[7.4,20.6]],"o":[[0,0],[0.6,0],[0.4,-0.3],[0.2,-0.6],[0,0],[0,-0.8],[-0.2,-0.6],[-0.4,-0.3],[0,0],[-0.6,0],[-0.4,0.3],[-0.2,0.6],[0,0],[0,0.8],[0.2,0.6],[0.4,0.3],[0,0]]}}},{"ty":"fl","c":{"a":0,"k":[1,1,1]},"o":{"a":0,"k":100}}]},{"ind":18,"ty":3,"parent":15,"ks":{"p":{"a":0,"k":[107.496,0]}},"ip":0,"op":61,"st":0},{"ind":21,"ty":4,"parent":20,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[5,23.1],[1.5,23.1],[1.5,10],[5,10],[5,23.1]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]]}}},{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0.4,0.4],[0,0.5],[0,0],[-0.4,0.4],[-0.5,0],[0,0],[-0.4,-0.3],[0,-0.5],[0,0],[0.4,-0.4],[0.5,0]],"v":[[3.3,8.3],[3.3,8.3],[1.9,7.8],[1.3,6.5],[1.3,6.5],[1.9,5.2],[3.3,4.7],[3.3,4.7],[4.6,5.2],[5.2,6.5],[5.2,6.5],[4.6,7.8],[3.3,8.3]],"o":[[0,0],[-0.5,0],[-0.4,-0.4],[0,0],[0,-0.5],[0.4,-0.4],[0,0],[0.5,0],[0.4,0.3],[0,0],[0,0.5],[-0.4,0.4],[0,0]]}}},{"ty":"fl","c":{"a":0,"k":[1,1,1]},"o":{"a":0,"k":100}}]},{"ind":20,"ty":3,"parent":15,"ks":{"p":{"a":0,"k":[100.992,0]}},"ip":0,"op":61,"st":0},{"ind":23,"ty":4,"parent":22,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[0.2,10],[8,10],[8,12.7],[0.2,12.7],[0.2,10]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]]}}},{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0,0],[0,0],[-0.2,-0.2],[-0.4,0],[0,0],[-0.2,0],[-0.1,0],[0,0],[0,0],[0.4,0],[0.4,0],[0,0],[0.7,0.6],[0,1.2],[0,0]],"v":[[2,19.6],[2,6.9],[5.6,6.9],[5.6,19.3],[5.8,20.2],[6.8,20.5],[6.8,20.5],[7.3,20.5],[7.9,20.4],[7.9,20.4],[8.4,23],[7.2,23.2],[6.1,23.3],[6.1,23.3],[3.1,22.4],[2,19.6],[2,19.6]],"o":[[0,0],[0,0],[0,0],[0,0.4],[0.2,0.2],[0,0],[0.1,0],[0.2,0],[0,0],[0,0],[-0.4,0.1],[-0.4,0],[0,0],[-1.3,0],[-0.7,-0.6],[0,0],[0,0]]}}},{"ty":"fl","c":{"a":0,"k":[1,1,1]},"o":{"a":0,"k":100}}]},{"ind":22,"ty":3,"parent":15,"ks":{"p":{"a":0,"k":[92.203,0]}},"ip":0,"op":61,"st":0},{"ind":25,"ty":4,"parent":24,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0.7,0.3],[0.4,0.6],[0,0.9],[0,0],[-0.3,0.5],[-0.5,0.3],[-0.6,0.2],[-0.6,0.1],[0,0],[-0.5,0.1],[-0.2,0.1],[0,0.3],[0,0],[0,0],[0.2,0.3],[0.3,0.1],[0.4,0],[0,0],[0.3,-0.1],[0.2,-0.2],[0.1,-0.3],[0,0],[0,0],[-0.5,0.5],[-0.8,0.3],[-1,0],[0,0],[-0.7,-0.2],[-0.5,-0.4],[-0.3,-0.6],[0,-0.8],[0,0],[0,0],[0,0],[0,0],[0,0],[0.4,-0.3],[0.5,-0.2],[0.6,0]],"v":[[5.2,23.4],[5.2,23.4],[2.9,22.9],[1.4,21.6],[0.8,19.5],[0.8,19.5],[1.2,17.6],[2.3,16.5],[3.9,15.8],[5.7,15.4],[5.7,15.4],[7.6,15.2],[8.6,14.9],[8.9,14.3],[8.9,14.3],[8.9,14.2],[8.7,13.3],[8,12.7],[6.9,12.5],[6.9,12.5],[5.7,12.7],[4.9,13.2],[4.4,14],[4.4,14],[1.2,13.4],[2.3,11.5],[4.2,10.3],[6.9,9.9],[6.9,9.9],[8.9,10.1],[10.7,10.9],[12,12.3],[12.4,14.3],[12.4,14.3],[12.4,23.1],[9.1,23.1],[9.1,21.3],[9,21.3],[8.1,22.4],[6.9,23.1],[5.2,23.4]],"o":[[0,0],[-0.8,0],[-0.7,-0.3],[-0.4,-0.6],[0,0],[0,-0.7],[0.3,-0.5],[0.5,-0.3],[0.6,-0.2],[0,0],[0.8,-0.1],[0.5,-0.1],[0.2,-0.1],[0,0],[0,0],[0,-0.4],[-0.2,-0.3],[-0.3,-0.1],[0,0],[-0.5,0],[-0.3,0.1],[-0.2,0.2],[0,0],[0,0],[0.2,-0.8],[0.5,-0.5],[0.8,-0.3],[0,0],[0.7,0],[0.7,0.2],[0.5,0.4],[0.3,0.6],[0,0],[0,0],[0,0],[0,0],[0,0],[-0.2,0.4],[-0.4,0.3],[-0.5,0.2],[0,0]]}}},{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[-0.4,0.2],[-0.2,0.4],[0,0.5],[0,0],[0,0],[0.2,-0.1],[0.3,0],[0.3,0],[0.2,0],[0,0],[0.3,-0.1],[0.2,-0.2],[0,-0.4],[0,0],[-0.2,-0.2],[-0.3,-0.1],[-0.4,0]],"v":[[6.2,20.9],[6.2,20.9],[7.6,20.6],[8.6,19.7],[8.9,18.4],[8.9,18.4],[8.9,17],[8.5,17.2],[7.8,17.4],[7,17.5],[6.3,17.6],[6.3,17.6],[5.2,17.9],[4.4,18.5],[4.2,19.4],[4.2,19.4],[4.4,20.2],[5.1,20.7],[6.2,20.9]],"o":[[0,0],[0.6,0],[0.4,-0.2],[0.2,-0.4],[0,0],[0,0],[-0.1,0.1],[-0.2,0.1],[-0.3,0],[-0.2,0],[0,0],[-0.4,0.1],[-0.3,0.1],[-0.2,0.2],[0,0],[0,0.3],[0.2,0.2],[0.3,0.1],[0,0]]}}},{"ty":"fl","c":{"a":0,"k":[1,1,1]},"o":{"a":0,"k":100}}]},{"ind":24,"ty":3,"parent":15,"ks":{"p":{"a":0,"k":[78.27,0]}},"ip":0,"op":61,"st":0},{"ind":27,"ty":4,"parent":26,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[-0.4,0.5],[-0.5,0.2],[-0.6,0],[0,0],[-0.6,-0.6],[-0.3,-1.2],[0,0],[0,0],[-0.4,0.5],[-0.6,0.3],[-0.7,0],[0,0],[-0.6,-0.4],[-0.4,-0.7],[0,-1],[0,0],[0,0],[0,0],[0,0],[0.4,0.4],[0.6,0],[0,0],[0.3,-0.2],[0.2,-0.3],[0,-0.5],[0,0],[0,0],[0,0],[0,0],[0.4,0.4],[0.6,0],[0,0],[0.3,-0.2],[0.2,-0.4],[0,-0.5],[0,0],[0,0]],"v":[[5,23.1],[1.5,23.1],[1.5,10],[4.8,10],[4.9,13.3],[4.7,13.3],[5.6,11.3],[7,10.2],[8.6,9.8],[8.6,9.8],[10.9,10.7],[12.2,13.5],[12.2,13.5],[11.8,13.5],[12.7,11.4],[14.3,10.2],[16.2,9.8],[16.2,9.8],[18.4,10.4],[19.9,11.9],[20.4,14.3],[20.4,14.3],[20.4,23.1],[16.9,23.1],[16.9,15],[16.3,13.3],[14.8,12.8],[14.8,12.8],[13.7,13.1],[12.9,13.9],[12.6,15.1],[12.6,15.1],[12.6,23.1],[9.2,23.1],[9.2,14.9],[8.7,13.3],[7.2,12.8],[7.2,12.8],[6.1,13.1],[5.3,13.9],[5,15.2],[5,15.2],[5,23.1]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0.2,-0.8],[0.4,-0.5],[0.5,-0.2],[0,0],[0.9,0],[0.6,0.6],[0,0],[0,0],[0.2,-0.8],[0.4,-0.5],[0.6,-0.3],[0,0],[0.8,0],[0.6,0.4],[0.4,0.7],[0,0],[0,0],[0,0],[0,0],[0,-0.7],[-0.4,-0.4],[0,0],[-0.4,0],[-0.3,0.2],[-0.2,0.3],[0,0],[0,0],[0,0],[0,0],[0,-0.6],[-0.4,-0.4],[0,0],[-0.4,0],[-0.3,0.2],[-0.2,0.4],[0,0],[0,0],[0,0]]}}},{"ty":"fl","c":{"a":0,"k":[1,1,1]},"o":{"a":0,"k":100}}]},{"ind":26,"ty":3,"parent":15,"ks":{"p":{"a":0,"k":[56.367,0]}},"ip":0,"op":61,"st":0},{"ind":29,"ty":4,"parent":28,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[1,0.6],[0.5,1],[0,1.3],[0,0],[-0.5,1],[-1,0.6],[-1.3,0],[0,0],[-1,-0.6],[-0.5,-1],[0,-1.4],[0,0],[0.5,-1],[1,-0.6],[1.3,0]],"v":[[7.4,23.4],[7.4,23.4],[3.9,22.5],[1.7,20.2],[0.9,16.6],[0.9,16.6],[1.7,13.1],[3.9,10.7],[7.4,9.9],[7.4,9.9],[10.8,10.7],[13,13.1],[13.8,16.6],[13.8,16.6],[13,20.2],[10.8,22.5],[7.4,23.4]],"o":[[0,0],[-1.3,0],[-1,-0.6],[-0.5,-1],[0,0],[0,-1.4],[0.5,-1],[1,-0.6],[0,0],[1.3,0],[1,0.6],[0.5,1],[0,0],[0,1.3],[-0.5,1],[-1,0.6],[0,0]]}}},{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[-0.4,0.3],[-0.2,0.6],[0,0.8],[0,0],[0.2,0.6],[0.4,0.3],[0.6,0],[0,0],[0.4,-0.3],[0.2,-0.6],[0,-0.8],[0,0],[-0.2,-0.6],[-0.4,-0.3],[-0.6,0]],"v":[[7.4,20.6],[7.4,20.6],[8.9,20.1],[9.9,18.7],[10.2,16.6],[10.2,16.6],[9.9,14.6],[8.9,13.1],[7.4,12.6],[7.4,12.6],[5.8,13.1],[4.9,14.6],[4.5,16.6],[4.5,16.6],[4.9,18.7],[5.8,20.1],[7.4,20.6]],"o":[[0,0],[0.6,0],[0.4,-0.3],[0.2,-0.6],[0,0],[0,-0.8],[-0.2,-0.6],[-0.4,-0.3],[0,0],[-0.6,0],[-0.4,0.3],[-0.2,0.6],[0,0],[0,0.8],[0.2,0.6],[0.4,0.3],[0,0]]}}},{"ty":"fl","c":{"a":0,"k":[1,1,1]},"o":{"a":0,"k":100}}]},{"ind":28,"ty":3,"parent":15,"ks":{"p":{"a":0,"k":[41.648,0]}},"ip":0,"op":61,"st":0},{"ind":31,"ty":4,"parent":30,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[0.2,10],[8,10],[8,12.7],[0.2,12.7],[0.2,10]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]]}}},{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0,0],[0,0],[-0.2,-0.2],[-0.4,0],[0,0],[-0.2,0],[-0.1,0],[0,0],[0,0],[0.4,0],[0.4,0],[0,0],[0.7,0.6],[0,1.2],[0,0]],"v":[[2,19.6],[2,6.9],[5.6,6.9],[5.6,19.3],[5.8,20.2],[6.8,20.5],[6.8,20.5],[7.3,20.5],[7.9,20.4],[7.9,20.4],[8.4,23],[7.2,23.2],[6.1,23.3],[6.1,23.3],[3.1,22.4],[2,19.6],[2,19.6]],"o":[[0,0],[0,0],[0,0],[0,0.4],[0.2,0.2],[0,0],[0.1,0],[0.2,0],[0,0],[0,0],[-0.4,0.1],[-0.4,0],[0,0],[-1.3,0],[-0.7,-0.6],[0,0],[0,0]]}}},{"ty":"fl","c":{"a":0,"k":[1,1,1]},"o":{"a":0,"k":100}}]},{"ind":30,"ty":3,"parent":15,"ks":{"p":{"a":0,"k":[32.859,0]}},"ip":0,"op":61,"st":0},{"ind":33,"ty":4,"parent":32,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0.7,0.4],[0.4,0.7],[0,1],[0,0],[0,0],[0,0],[0,0],[-0.4,-0.5],[-0.7,0],[0,0],[-0.4,0.2],[-0.2,0.4],[0,0.6],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0.7,-0.6],[1.1,0]],"v":[[6,23.3],[6,23.3],[3.6,22.7],[2.1,21],[1.5,18.4],[1.5,18.4],[1.5,10],[5,10],[5,17.8],[5.6,19.7],[7.4,20.4],[7.4,20.4],[8.7,20],[9.6,19.1],[9.9,17.6],[9.9,17.6],[9.9,10],[13.4,10],[13.4,23.1],[10.1,23.1],[10.1,19.9],[10.3,19.9],[8.7,22.4],[6,23.3]],"o":[[0,0],[-0.9,0],[-0.7,-0.4],[-0.4,-0.7],[0,0],[0,0],[0,0],[0,0],[0,0.8],[0.4,0.5],[0,0],[0.5,0],[0.4,-0.2],[0.2,-0.4],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[-0.3,1.1],[-0.7,0.6],[0,0]]}}},{"ty":"fl","c":{"a":0,"k":[1,1,1]},"o":{"a":0,"k":100}}]},{"ind":32,"ty":3,"parent":15,"ks":{"p":{"a":0,"k":[17.918,0]}},"ip":0,"op":61,"st":0},{"ind":35,"ty":4,"parent":34,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0.3,1.3],[0.4,1.5],[0,0],[0,0],[0.3,-1.3],[0.3,-1.1],[0,0],[0,0]],"v":[[4.5,23.1],[0.6,23.1],[6.5,5.7],[11.2,5.7],[17.3,23.1],[13.4,23.1],[10.6,14.7],[9.5,11.1],[8.4,6.9],[8.4,6.9],[9.2,6.9],[8.2,11.2],[7.2,14.7],[7.2,14.7],[4.5,23.1]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[-0.3,-1.1],[-0.3,-1.3],[0,0],[0,0],[-0.4,1.5],[-0.3,1.3],[0,0],[0,0],[0,0]]}}},{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[13.6,19.1],[4.3,19.1],[4.3,16.3],[13.6,16.3],[13.6,19.1]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]]}}},{"ty":"fl","c":{"a":0,"k":[1,1,1]},"o":{"a":0,"k":100}}]},{"ind":34,"ty":3,"parent":15,"ks":{},"ip":0,"op":61,"st":0},{"ind":15,"ty":3,"parent":14,"ks":{"p":{"a":1,"k":[{"t":0,"s":[0,28.8],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":4.338,"s":[0,28.8],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[0,0],"h":1}]}},"ip":0,"op":61,"st":0},{"ind":14,"ty":3,"ks":{"p":{"a":0,"k":[0,-4]}},"ip":0,"op":61,"st":0}]},{"id":"50","layers":[{"ind":49,"ty":0,"td":1,"ks":{"a":{"a":0,"k":[29,0]}},"w":253,"h":53,"ip":0,"op":61,"st":0,"refId":"47"},{"ind":38,"ty":0,"tt":1,"ks":{"a":{"a":0,"k":[0,-4]}},"w":136,"h":49,"ip":0,"op":61,"st":0,"refId":"36"}]},{"id":"77","layers":[{"ind":76,"ty":4,"parent":75,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"rc","p":{"a":0,"k":[75.857,14.4]},"r":{"a":0,"k":0},"s":{"a":0,"k":[151.713,28.8]}},{"ty":"fl","c":{"a":0,"k":[0,0,0]},"o":{"a":0,"k":100}}]},{"ind":75,"ty":3,"parent":74,"ks":{"p":{"a":0,"k":[-28.8,0]}},"ip":0,"op":61,"st":0},{"ind":74,"ty":3,"ks":{"p":{"a":0,"k":[29,0]}},"ip":0,"op":61,"st":0}]},{"id":"81","layers":[{"ind":79,"ty":0,"parent":73,"ks":{"a":{"a":0,"k":[29,0]}},"w":181,"h":29,"ip":0,"op":61,"st":0,"refId":"77"},{"ind":80,"ty":4,"parent":73,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"h":1},{"t":22.650876,"s":[0],"h":1},{"t":22.650876,"s":[100],"h":1},{"t":60,"s":[100],"h":1}]}},"ip":0,"op":61,"st":0,"shapes":[{"ty":"rc","p":{"a":0,"k":[46.594,26.854]},"r":{"a":0,"k":0},"s":{"a":0,"k":[92.039,52.291]}},{"ty":"fl","c":{"a":0,"k":[0,0,0]},"o":{"a":0,"k":100}}]},{"ind":73,"ty":3,"ks":{"p":{"a":0,"k":[29,0]}},"ip":0,"op":61,"st":0}]},{"id":"70","layers":[{"ind":57,"ty":4,"parent":56,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0.8,0.5],[0.5,1],[0,1.5],[0,0],[-0.5,1],[-0.8,0.5],[-1,0],[0,0],[-0.5,-0.2],[-0.3,-0.4],[-0.1,-0.3],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0.3,-0.4],[0.5,-0.2],[0.7,0]],"v":[[6.3,23.4],[6.3,23.4],[3.6,22.6],[1.7,20.3],[0.9,16.6],[0.9,16.6],[1.7,12.9],[3.6,10.6],[6.3,9.9],[6.3,9.9],[8.1,10.2],[9.3,11.2],[10,12.2],[10,12.2],[10.1,12.2],[10.1,5.7],[13.6,5.7],[13.6,23.1],[10.2,23.1],[10.2,21],[10,21],[9.3,22.1],[8.1,23],[6.3,23.4]],"o":[[0,0],[-1,0],[-0.8,-0.5],[-0.5,-1],[0,0],[0,-1.5],[0.5,-1],[0.8,-0.5],[0,0],[0.7,0],[0.5,0.3],[0.3,0.4],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[-0.2,0.4],[-0.3,0.4],[-0.5,0.2],[0,0]]}}},{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[-0.4,0.3],[-0.2,0.6],[0,0.8],[0,0],[0.2,0.6],[0.4,0.3],[0.6,0],[0,0],[0.4,-0.3],[0.2,-0.6],[0,-0.8],[0,0],[-0.2,-0.6],[-0.4,-0.3],[-0.6,0]],"v":[[7.4,20.5],[7.4,20.5],[8.9,20],[9.9,18.7],[10.2,16.6],[10.2,16.6],[9.9,14.5],[8.9,13.2],[7.4,12.7],[7.4,12.7],[5.8,13.2],[4.9,14.6],[4.6,16.6],[4.6,16.6],[4.9,18.6],[5.8,20],[7.4,20.5]],"o":[[0,0],[0.6,0],[0.4,-0.3],[0.2,-0.6],[0,0],[0,-0.8],[-0.2,-0.6],[-0.4,-0.3],[0,0],[-0.6,0],[-0.4,0.3],[-0.2,0.6],[0,0],[0,0.8],[0.2,0.6],[0.4,0.3],[0,0]]}}},{"ty":"fl","c":{"a":0,"k":[1,1,1]},"o":{"a":0,"k":100}}]},{"ind":56,"ty":3,"parent":55,"ks":{"p":{"a":0,"k":[78.984,0]}},"ip":0,"op":61,"st":0},{"ind":59,"ty":4,"parent":58,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[5,23.1],[1.5,23.1],[1.5,10],[5,10],[5,23.1]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]]}}},{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0.4,0.4],[0,0.5],[0,0],[-0.4,0.4],[-0.5,0],[0,0],[-0.4,-0.3],[0,-0.5],[0,0],[0.4,-0.4],[0.5,0]],"v":[[3.3,8.3],[3.3,8.3],[1.9,7.8],[1.3,6.5],[1.3,6.5],[1.9,5.2],[3.3,4.7],[3.3,4.7],[4.6,5.2],[5.2,6.5],[5.2,6.5],[4.6,7.8],[3.3,8.3]],"o":[[0,0],[-0.5,0],[-0.4,-0.4],[0,0],[0,-0.5],[0.4,-0.4],[0,0],[0.5,0],[0.4,0.3],[0,0],[0,0.5],[-0.4,0.4],[0,0]]}}},{"ty":"fl","c":{"a":0,"k":[1,1,1]},"o":{"a":0,"k":100}}]},{"ind":58,"ty":3,"parent":55,"ks":{"p":{"a":0,"k":[72.48,0]}},"ip":0,"op":61,"st":0},{"ind":61,"ty":4,"parent":60,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[1,0.6],[0.5,1],[0,1.3],[0,0],[-0.5,1],[-1,0.6],[-1.3,0],[0,0],[-1,-0.6],[-0.5,-1],[0,-1.4],[0,0],[0.5,-1],[1,-0.6],[1.3,0]],"v":[[7.4,23.4],[7.4,23.4],[3.9,22.5],[1.7,20.2],[0.9,16.6],[0.9,16.6],[1.7,13.1],[3.9,10.7],[7.4,9.9],[7.4,9.9],[10.8,10.7],[13,13.1],[13.8,16.6],[13.8,16.6],[13,20.2],[10.8,22.5],[7.4,23.4]],"o":[[0,0],[-1.3,0],[-1,-0.6],[-0.5,-1],[0,0],[0,-1.4],[0.5,-1],[1,-0.6],[0,0],[1.3,0],[1,0.6],[0.5,1],[0,0],[0,1.3],[-0.5,1],[-1,0.6],[0,0]]}}},{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[-0.4,0.3],[-0.2,0.6],[0,0.8],[0,0],[0.2,0.6],[0.4,0.3],[0.6,0],[0,0],[0.4,-0.3],[0.2,-0.6],[0,-0.8],[0,0],[-0.2,-0.6],[-0.4,-0.3],[-0.6,0]],"v":[[7.4,20.6],[7.4,20.6],[8.9,20.1],[9.9,18.7],[10.2,16.6],[10.2,16.6],[9.9,14.6],[8.9,13.1],[7.4,12.6],[7.4,12.6],[5.8,13.1],[4.9,14.6],[4.5,16.6],[4.5,16.6],[4.9,18.7],[5.8,20.1],[7.4,20.6]],"o":[[0,0],[0.6,0],[0.4,-0.3],[0.2,-0.6],[0,0],[0,-0.8],[-0.2,-0.6],[-0.4,-0.3],[0,0],[-0.6,0],[-0.4,0.3],[-0.2,0.6],[0,0],[0,0.8],[0.2,0.6],[0.4,0.3],[0,0]]}}},{"ty":"fl","c":{"a":0,"k":[1,1,1]},"o":{"a":0,"k":100}}]},{"ind":60,"ty":3,"parent":55,"ks":{"p":{"a":0,"k":[57.762,0]}},"ip":0,"op":61,"st":0},{"ind":63,"ty":4,"parent":62,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[-0.6,0.4],[-0.7,0],[0,0],[-0.2,0],[-0.2,0],[0,0],[0,0],[0.3,0],[0.3,0],[0,0],[0.4,-0.2],[0.2,-0.4],[0,-0.5],[0,0],[0,0]],"v":[[5,23.1],[1.5,23.1],[1.5,10],[4.9,10],[4.9,12.3],[5,12.3],[6.3,10.5],[8.2,9.9],[8.2,9.9],[8.8,9.9],[9.3,10],[9.3,10],[9.3,13.1],[8.7,13],[7.8,12.9],[7.8,12.9],[6.4,13.3],[5.4,14.2],[5,15.7],[5,15.7],[5,23.1]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0.2,-0.8],[0.6,-0.4],[0,0],[0.2,0],[0.2,0],[0,0],[0,0],[-0.2,-0.1],[-0.3,0],[0,0],[-0.5,0],[-0.4,0.2],[-0.2,0.4],[0,0],[0,0],[0,0]]}}},{"ty":"fl","c":{"a":0,"k":[1,1,1]},"o":{"a":0,"k":100}}]},{"ind":62,"ty":3,"parent":55,"ks":{"p":{"a":0,"k":[47.988,0]}},"ip":0,"op":61,"st":0},{"ind":65,"ty":4,"parent":64,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0.8,0.5],[0.5,1],[0,1.5],[0,0],[-0.5,1],[-0.8,0.5],[-1,0],[0,0],[-0.5,-0.2],[-0.3,-0.4],[-0.1,-0.3],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0.3,-0.4],[0.5,-0.2],[0.7,0]],"v":[[6.3,23.4],[6.3,23.4],[3.6,22.6],[1.7,20.3],[0.9,16.6],[0.9,16.6],[1.7,12.9],[3.6,10.6],[6.3,9.9],[6.3,9.9],[8.1,10.2],[9.3,11.2],[10,12.2],[10,12.2],[10.1,12.2],[10.1,5.7],[13.6,5.7],[13.6,23.1],[10.2,23.1],[10.2,21],[10,21],[9.3,22.1],[8.1,23],[6.3,23.4]],"o":[[0,0],[-1,0],[-0.8,-0.5],[-0.5,-1],[0,0],[0,-1.5],[0.5,-1],[0.8,-0.5],[0,0],[0.7,0],[0.5,0.3],[0.3,0.4],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[-0.2,0.4],[-0.3,0.4],[-0.5,0.2],[0,0]]}}},{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[-0.4,0.3],[-0.2,0.6],[0,0.8],[0,0],[0.2,0.6],[0.4,0.3],[0.6,0],[0,0],[0.4,-0.3],[0.2,-0.6],[0,-0.8],[0,0],[-0.2,-0.6],[-0.4,-0.3],[-0.6,0]],"v":[[7.4,20.5],[7.4,20.5],[8.9,20],[9.9,18.7],[10.2,16.6],[10.2,16.6],[9.9,14.5],[8.9,13.2],[7.4,12.7],[7.4,12.7],[5.8,13.2],[4.9,14.6],[4.6,16.6],[4.6,16.6],[4.9,18.6],[5.8,20],[7.4,20.5]],"o":[[0,0],[0.6,0],[0.4,-0.3],[0.2,-0.6],[0,0],[0,-0.8],[-0.2,-0.6],[-0.4,-0.3],[0,0],[-0.6,0],[-0.4,0.3],[-0.2,0.6],[0,0],[0,0.8],[0.2,0.6],[0.4,0.3],[0,0]]}}},{"ty":"fl","c":{"a":0,"k":[1,1,1]},"o":{"a":0,"k":100}}]},{"ind":64,"ty":3,"parent":55,"ks":{"p":{"a":0,"k":[32.859,0]}},"ip":0,"op":61,"st":0},{"ind":67,"ty":4,"parent":66,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[-0.7,0.6],[-1.1,0],[0,0],[-0.7,-0.4],[-0.4,-0.7],[0,-1],[0,0],[0,0],[0,0],[0,0],[0.4,0.5],[0.7,0],[0,0],[0.4,-0.2],[0.2,-0.4],[0,-0.6]],"v":[[5,15.5],[5,15.5],[5,23.1],[1.5,23.1],[1.5,10],[4.8,10],[4.9,13.3],[4.7,13.3],[6.2,10.8],[8.9,9.9],[8.9,9.9],[11.3,10.5],[12.9,12.2],[13.4,14.8],[13.4,14.8],[13.4,23.1],[9.9,23.1],[9.9,15.4],[9.3,13.5],[7.6,12.8],[7.6,12.8],[6.2,13.1],[5.3,14.1],[5,15.5]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0.3,-1.1],[0.7,-0.6],[0,0],[0.9,0],[0.7,0.4],[0.4,0.7],[0,0],[0,0],[0,0],[0,0],[0,-0.8],[-0.4,-0.5],[0,0],[-0.5,0],[-0.4,0.2],[-0.2,0.4],[0,0]]}}},{"ty":"fl","c":{"a":0,"k":[1,1,1]},"o":{"a":0,"k":100}}]},{"ind":66,"ty":3,"parent":55,"ks":{"p":{"a":0,"k":[17.918,0]}},"ip":0,"op":61,"st":0},{"ind":69,"ty":4,"parent":68,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0.3,1.3],[0.4,1.5],[0,0],[0,0],[0.3,-1.3],[0.3,-1.1],[0,0],[0,0]],"v":[[4.5,23.1],[0.6,23.1],[6.5,5.7],[11.2,5.7],[17.3,23.1],[13.4,23.1],[10.6,14.7],[9.5,11.1],[8.4,6.9],[8.4,6.9],[9.2,6.9],[8.2,11.2],[7.2,14.7],[7.2,14.7],[4.5,23.1]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[-0.3,-1.1],[-0.3,-1.3],[0,0],[0,0],[-0.4,1.5],[-0.3,1.3],[0,0],[0,0],[0,0]]}}},{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[13.6,19.1],[4.3,19.1],[4.3,16.3],[13.6,16.3],[13.6,19.1]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]]}}},{"ty":"fl","c":{"a":0,"k":[1,1,1]},"o":{"a":0,"k":100}}]},{"ind":68,"ty":3,"parent":55,"ks":{},"ip":0,"op":61,"st":0},{"ind":55,"ty":3,"parent":54,"ks":{"p":{"a":1,"k":[{"t":0,"s":[0,28.8],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":1.278,"s":[0,28.8],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":22.65,"s":[0,0],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":60,"s":[0,0],"h":1}]}},"ip":0,"op":61,"st":0},{"ind":54,"ty":3,"ks":{"p":{"a":0,"k":[0,-4]}},"ip":0,"op":61,"st":0}]},{"id":"84","layers":[{"ind":83,"ty":0,"td":1,"ks":{"a":{"a":0,"k":[29,0]}},"w":210,"h":53,"ip":0,"op":61,"st":0,"refId":"81"},{"ind":72,"ty":0,"tt":1,"ks":{"a":{"a":0,"k":[0,-4]}},"w":93,"h":49,"ip":0,"op":61,"st":0,"refId":"70"}]},{"id":"103","layers":[{"ind":102,"ty":4,"parent":101,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"rc","p":{"a":0,"k":[55.501,14.4]},"r":{"a":0,"k":0},"s":{"a":0,"k":[111.002,28.8]}},{"ty":"fl","c":{"a":0,"k":[0,0,0]},"o":{"a":0,"k":100}}]},{"ind":101,"ty":3,"parent":100,"ks":{"p":{"a":0,"k":[-28.8,0]}},"ip":0,"op":61,"st":0},{"ind":100,"ty":3,"ks":{"p":{"a":0,"k":[29,0]}},"ip":0,"op":61,"st":0}]},{"id":"107","layers":[{"ind":105,"ty":0,"parent":99,"ks":{"a":{"a":0,"k":[29,0]}},"w":141,"h":29,"ip":0,"op":61,"st":0,"refId":"103"},{"ind":106,"ty":4,"parent":99,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"h":1},{"t":11.916335,"s":[0],"h":1},{"t":11.916335,"s":[100],"h":1},{"t":60,"s":[100],"h":1}]}},"ip":0,"op":61,"st":0,"shapes":[{"ty":"rc","p":{"a":0,"k":[26.35,26.835]},"r":{"a":0,"k":0},"s":{"a":0,"k":[51.105,52.33]}},{"ty":"fl","c":{"a":0,"k":[0,0,0]},"o":{"a":0,"k":100}}]},{"ind":99,"ty":3,"ks":{"p":{"a":0,"k":[29,0]}},"ip":0,"op":61,"st":0}]},{"id":"96","layers":[{"ind":91,"ty":4,"parent":90,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0.7,0.3],[0.4,0.6],[0,0.9],[0,0],[-0.3,0.5],[-0.5,0.3],[-0.6,0.2],[-0.6,0.1],[0,0],[-0.5,0.1],[-0.2,0.1],[0,0.3],[0,0],[0,0],[0.2,0.3],[0.3,0.1],[0.4,0],[0,0],[0.3,-0.1],[0.2,-0.2],[0.1,-0.3],[0,0],[0,0],[-0.5,0.5],[-0.8,0.3],[-1,0],[0,0],[-0.7,-0.2],[-0.5,-0.4],[-0.3,-0.6],[0,-0.8],[0,0],[0,0],[0,0],[0,0],[0,0],[0.4,-0.3],[0.5,-0.2],[0.6,0]],"v":[[5.2,23.4],[5.2,23.4],[2.9,22.9],[1.4,21.6],[0.8,19.5],[0.8,19.5],[1.2,17.6],[2.3,16.5],[3.9,15.8],[5.7,15.4],[5.7,15.4],[7.6,15.2],[8.6,14.9],[8.9,14.3],[8.9,14.3],[8.9,14.2],[8.7,13.3],[8,12.7],[6.9,12.5],[6.9,12.5],[5.7,12.7],[4.9,13.2],[4.4,14],[4.4,14],[1.2,13.4],[2.3,11.5],[4.2,10.3],[6.9,9.9],[6.9,9.9],[8.9,10.1],[10.7,10.9],[12,12.3],[12.4,14.3],[12.4,14.3],[12.4,23.1],[9.1,23.1],[9.1,21.3],[9,21.3],[8.1,22.4],[6.9,23.1],[5.2,23.4]],"o":[[0,0],[-0.8,0],[-0.7,-0.3],[-0.4,-0.6],[0,0],[0,-0.7],[0.3,-0.5],[0.5,-0.3],[0.6,-0.2],[0,0],[0.8,-0.1],[0.5,-0.1],[0.2,-0.1],[0,0],[0,0],[0,-0.4],[-0.2,-0.3],[-0.3,-0.1],[0,0],[-0.5,0],[-0.3,0.1],[-0.2,0.2],[0,0],[0,0],[0.2,-0.8],[0.5,-0.5],[0.8,-0.3],[0,0],[0.7,0],[0.7,0.2],[0.5,0.4],[0.3,0.6],[0,0],[0,0],[0,0],[0,0],[0,0],[-0.2,0.4],[-0.4,0.3],[-0.5,0.2],[0,0]]}}},{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[-0.4,0.2],[-0.2,0.4],[0,0.5],[0,0],[0,0],[0.2,-0.1],[0.3,0],[0.3,0],[0.2,0],[0,0],[0.3,-0.1],[0.2,-0.2],[0,-0.4],[0,0],[-0.2,-0.2],[-0.3,-0.1],[-0.4,0]],"v":[[6.2,20.9],[6.2,20.9],[7.6,20.6],[8.6,19.7],[8.9,18.4],[8.9,18.4],[8.9,17],[8.5,17.2],[7.8,17.4],[7,17.5],[6.3,17.6],[6.3,17.6],[5.2,17.9],[4.4,18.5],[4.2,19.4],[4.2,19.4],[4.4,20.2],[5.1,20.7],[6.2,20.9]],"o":[[0,0],[0.6,0],[0.4,-0.2],[0.2,-0.4],[0,0],[0,0],[-0.1,0.1],[-0.2,0.1],[-0.3,0],[-0.2,0],[0,0],[-0.4,0.1],[-0.3,0.1],[-0.2,0.2],[0,0],[0,0.3],[0.2,0.2],[0.3,0.1],[0,0]]}}},{"ty":"fl","c":{"a":0,"k":[1,1,1]},"o":{"a":0,"k":100}}]},{"ind":90,"ty":3,"parent":89,"ks":{"p":{"a":0,"k":[39.469,0]}},"ip":0,"op":61,"st":0},{"ind":93,"ty":4,"parent":92,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[-0.4,0.5],[-0.5,0.2],[-0.6,0],[0,0],[-0.6,-0.6],[-0.3,-1.2],[0,0],[0,0],[-0.4,0.5],[-0.6,0.3],[-0.7,0],[0,0],[-0.6,-0.4],[-0.4,-0.7],[0,-1],[0,0],[0,0],[0,0],[0,0],[0.4,0.4],[0.6,0],[0,0],[0.3,-0.2],[0.2,-0.3],[0,-0.5],[0,0],[0,0],[0,0],[0,0],[0.4,0.4],[0.6,0],[0,0],[0.3,-0.2],[0.2,-0.4],[0,-0.5],[0,0],[0,0]],"v":[[5,23.1],[1.5,23.1],[1.5,10],[4.8,10],[4.9,13.3],[4.7,13.3],[5.6,11.3],[7,10.2],[8.6,9.8],[8.6,9.8],[10.9,10.7],[12.2,13.5],[12.2,13.5],[11.8,13.5],[12.7,11.4],[14.3,10.2],[16.2,9.8],[16.2,9.8],[18.4,10.4],[19.9,11.9],[20.4,14.3],[20.4,14.3],[20.4,23.1],[16.9,23.1],[16.9,15],[16.3,13.3],[14.8,12.8],[14.8,12.8],[13.7,13.1],[12.9,13.9],[12.6,15.1],[12.6,15.1],[12.6,23.1],[9.2,23.1],[9.2,14.9],[8.7,13.3],[7.2,12.8],[7.2,12.8],[6.1,13.1],[5.3,13.9],[5,15.2],[5,15.2],[5,23.1]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0.2,-0.8],[0.4,-0.5],[0.5,-0.2],[0,0],[0.9,0],[0.6,0.6],[0,0],[0,0],[0.2,-0.8],[0.4,-0.5],[0.6,-0.3],[0,0],[0.8,0],[0.6,0.4],[0.4,0.7],[0,0],[0,0],[0,0],[0,0],[0,-0.7],[-0.4,-0.4],[0,0],[-0.4,0],[-0.3,0.2],[-0.2,0.3],[0,0],[0,0],[0,0],[0,0],[0,-0.6],[-0.4,-0.4],[0,0],[-0.4,0],[-0.3,0.2],[-0.2,0.4],[0,0],[0,0],[0,0]]}}},{"ty":"fl","c":{"a":0,"k":[1,1,1]},"o":{"a":0,"k":100}}]},{"ind":92,"ty":3,"parent":89,"ks":{"p":{"a":0,"k":[17.566,0]}},"ip":0,"op":61,"st":0},{"ind":95,"ty":4,"parent":94,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[1.1,0.5],[0.6,1],[0,1.3],[0,0],[0,0],[0,0],[0,0],[-0.3,-0.5],[-0.5,-0.3],[-0.7,0],[0,0],[-0.5,0.3],[-0.3,0.5],[0,0.7],[0,0],[0,0],[0,0],[0,0],[0.6,-1],[1.1,-0.5],[1.4,0]],"v":[[8.8,23.4],[8.8,23.4],[5,22.6],[2.5,20.3],[1.6,17],[1.6,17],[1.6,5.7],[5.2,5.7],[5.2,16.7],[5.6,18.5],[6.9,19.8],[8.8,20.2],[8.8,20.2],[10.7,19.8],[12,18.5],[12.4,16.7],[12.4,16.7],[12.4,5.7],[16,5.7],[16,17],[15.1,20.3],[12.6,22.6],[8.8,23.4]],"o":[[0,0],[-1.4,0],[-1.1,-0.5],[-0.6,-1],[0,0],[0,0],[0,0],[0,0],[0,0.7],[0.3,0.5],[0.5,0.3],[0,0],[0.7,0],[0.5,-0.3],[0.3,-0.5],[0,0],[0,0],[0,0],[0,0],[0,1.3],[-0.6,1],[-1.1,0.5],[0,0]]}}},{"ty":"fl","c":{"a":0,"k":[1,1,1]},"o":{"a":0,"k":100}}]},{"ind":94,"ty":3,"parent":89,"ks":{},"ip":0,"op":61,"st":0},{"ind":89,"ty":3,"parent":88,"ks":{"p":{"a":1,"k":[{"t":0,"s":[0,28.8],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":11.916,"s":[0,0],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":60,"s":[0,0],"h":1}]}},"ip":0,"op":61,"st":0},{"ind":88,"ty":3,"ks":{"p":{"a":0,"k":[0,-5]}},"ip":0,"op":61,"st":0}]},{"id":"136","layers":[{"ind":135,"ty":4,"parent":134,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"rc","p":{"a":0,"k":[96,96]},"r":{"a":0,"k":0},"s":{"a":0,"k":[192,192]}},{"ty":"fl","c":{"a":0,"k":[0,0,0]},"o":{"a":0,"k":100}}]},{"ind":134,"ty":3,"ks":{},"ip":0,"op":61,"st":0}]},{"id":"140","layers":[{"ind":138,"ty":0,"parent":133,"ks":{},"w":192,"h":192,"ip":0,"op":61,"st":0,"refId":"136"},{"ind":139,"ty":4,"parent":133,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"h":1},{"t":48.06,"s":[0],"h":1},{"t":48.06,"s":[100],"h":1},{"t":60,"s":[100],"h":1}]}},"ip":0,"op":61,"st":0,"shapes":[{"ty":"rc","p":{"a":0,"k":[115.6,115.6]},"r":{"a":0,"k":0},"s":{"a":0,"k":[271.2,271.2]}},{"ty":"fl","c":{"a":0,"k":[0,0,0]},"o":{"a":0,"k":100}}]},{"ind":133,"ty":3,"ks":{"p":{"a":0,"k":[20,20]}},"ip":0,"op":61,"st":0}]},{"id":"123","layers":[{"ind":122,"ty":4,"parent":121,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"rc","p":{"a":1,"k":[{"t":0,"s":[96,0],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":48.06,"s":[96,96],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":60,"s":[96,96],"h":1}]},"r":{"a":0,"k":0},"s":{"a":1,"k":[{"t":0,"s":[192,0],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":48.06,"s":[192,192],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":60,"s":[192,192],"h":1}]}},{"ty":"fl","c":{"a":0,"k":[0,0,0]},"o":{"a":0,"k":100}}]},{"ind":121,"ty":3,"parent":120,"ks":{"p":{"a":1,"k":[{"t":0,"s":[0,96],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":48.06,"s":[0,0],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":60,"s":[0,0],"h":1}]}},"ip":0,"op":61,"st":0},{"ind":120,"ty":3,"ks":{"p":{"a":0,"k":[0,96]}},"ip":0,"op":61,"st":0}]},{"id":"127","layers":[{"ind":125,"ty":0,"parent":119,"ks":{"a":{"a":0,"k":[0,96]}},"w":192,"h":384,"ip":0,"op":61,"st":0,"refId":"123"},{"ind":126,"ty":4,"parent":119,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"h":1},{"t":48.06,"s":[0],"h":1},{"t":48.06,"s":[100],"h":1},{"t":60,"s":[100],"h":1}]}},"ip":0,"op":61,"st":0,"shapes":[{"ty":"rc","p":{"a":0,"k":[105.6,105.6]},"r":{"a":0,"k":0},"s":{"a":0,"k":[251.2,251.2]}},{"ty":"fl","c":{"a":0,"k":[0,0,0]},"o":{"a":0,"k":100}}]},{"ind":119,"ty":3,"ks":{"p":{"a":0,"k":[20,96]}},"ip":0,"op":61,"st":0}]},{"id":"116","layers":[{"ind":115,"ty":2,"parent":114,"ks":{},"ip":0,"op":61,"st":0,"refId":"0"},{"ind":114,"ty":3,"parent":113,"ks":{"a":{"a":0,"k":[96,96]},"p":{"a":0,"k":[96,96]},"s":{"a":1,"k":[{"t":0,"s":[120,120],"i":{"x":[0,0],"y":[1,1]},"o":{"x":[0.5,0.5],"y":[0,0]}},{"t":48.06,"s":[100,100],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":60,"s":[100,100],"h":1}]}},"ip":0,"op":61,"st":0},{"ind":113,"ty":3,"ks":{"p":{"a":0,"k":[20,20]}},"ip":0,"op":61,"st":0}]},{"id":"130","layers":[{"ind":129,"ty":0,"td":1,"parent":112,"ks":{"a":{"a":0,"k":[20,96]}},"w":252,"h":480,"ip":0,"op":61,"st":0,"refId":"127"},{"ind":118,"ty":0,"tt":1,"parent":112,"ks":{"a":{"a":0,"k":[20,20]}},"w":232,"h":232,"ip":0,"op":61,"st":0,"refId":"116"},{"ind":112,"ty":3,"parent":111,"ks":{},"ip":0,"op":61,"st":0},{"ind":111,"ty":3,"ks":{"p":{"a":0,"k":[20,20]}},"ip":0,"op":61,"st":0}]}],"fr":60,"h":800,"ip":0,"layers":[{"ind":4,"ty":4,"parent":3,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"rc","p":{"a":0,"k":[0.5,125]},"r":{"a":0,"k":0},"s":{"a":0,"k":[1,250]}},{"ty":"fl","c":{"a":0,"k":[1,1,1]},"o":{"a":0,"k":100}}]},{"ind":3,"ty":3,"parent":2,"ks":{"p":{"a":1,"k":[{"t":0,"s":[300,-250],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":42.06,"s":[300,850],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":60,"s":[300,850],"h":1}]}},"ip":0,"op":61,"st":0},{"ind":6,"ty":4,"parent":5,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"rc","p":{"a":0,"k":[0.5,125]},"r":{"a":0,"k":0},"s":{"a":0,"k":[1,250]}},{"ty":"fl","c":{"a":0,"k":[1,1,1]},"o":{"a":0,"k":100}}]},{"ind":5,"ty":3,"parent":2,"ks":{"p":{"a":1,"k":[{"t":0,"s":[70,-250],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":42.06,"s":[70,850],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":60,"s":[70,850],"h":1}]}},"ip":0,"op":61,"st":0},{"ind":8,"ty":4,"parent":7,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"rc","p":{"a":0,"k":[125,0.5]},"r":{"a":0,"k":0},"s":{"a":0,"k":[250,1]}},{"ty":"fl","c":{"a":0,"k":[1,1,1]},"o":{"a":0,"k":100}}]},{"ind":7,"ty":3,"parent":2,"ks":{"p":{"a":1,"k":[{"t":0,"s":[-250,525],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":42.06,"s":[360,525],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":60,"s":[360,525],"h":1}]}},"ip":0,"op":61,"st":0},{"ind":10,"ty":4,"parent":9,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"rc","p":{"a":0,"k":[125,0.5]},"r":{"a":0,"k":0},"s":{"a":0,"k":[250,1]}},{"ty":"fl","c":{"a":0,"k":[1,1,1]},"o":{"a":0,"k":100}}]},{"ind":9,"ty":3,"parent":2,"ks":{"p":{"a":1,"k":[{"t":0,"s":[-250,200],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":42.06,"s":[360,200],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":60,"s":[360,200],"h":1}]}},"ip":0,"op":61,"st":0},{"ind":52,"ty":0,"parent":13,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"h":1},{"t":4.336906,"s":[0],"h":1},{"t":4.336906,"s":[100],"h":1},{"t":60,"s":[100],"h":1}]}},"w":136,"h":53,"ip":0,"op":61,"st":0,"refId":"50"},{"ind":13,"ty":3,"parent":12,"ks":{"p":{"a":0,"k":[48.922,34.5]}},"ip":0,"op":61,"st":0},{"ind":86,"ty":0,"parent":53,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"h":1},{"t":1.278926,"s":[0],"h":1},{"t":1.278926,"s":[100],"h":1},{"t":60,"s":[100],"h":1}]},"p":{"a":0,"k":[59.086,0]}},"w":93,"h":53,"ip":0,"op":61,"st":0,"refId":"84"},{"ind":109,"ty":0,"td":1,"parent":87,"ks":{"a":{"a":0,"k":[29,0]}},"w":170,"h":53,"ip":0,"op":61,"st":0,"refId":"107"},{"ind":98,"ty":0,"tt":1,"parent":87,"ks":{"a":{"a":0,"k":[0,-5]}},"w":52,"h":48,"ip":0,"op":61,"st":0,"refId":"96"},{"ind":87,"ty":3,"parent":53,"ks":{},"ip":0,"op":61,"st":0},{"ind":53,"ty":3,"parent":12,"ks":{"p":{"a":0,"k":[40.9,5.7]}},"ip":0,"op":61,"st":0},{"ind":12,"ty":3,"parent":11,"ks":{"p":{"a":0,"k":[25,96]}},"ip":0,"op":61,"st":0},{"ind":142,"ty":0,"td":1,"parent":110,"ks":{"a":{"a":0,"k":[20,20]}},"w":272,"h":272,"ip":0,"op":61,"st":0,"refId":"140"},{"ind":132,"ty":0,"tt":1,"parent":110,"ks":{"a":{"a":0,"k":[20,20]}},"w":252,"h":252,"ip":0,"op":61,"st":0,"refId":"130"},{"ind":110,"ty":3,"parent":11,"ks":{"p":{"a":0,"k":[47,-96]}},"ip":0,"op":61,"st":0},{"ind":11,"ty":3,"parent":2,"ks":{"p":{"a":0,"k":[38,338]}},"ip":0,"op":61,"st":0},{"ind":2,"ty":3,"parent":1,"ks":{},"ip":0,"op":61,"st":0},{"ind":143,"ty":4,"parent":1,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"rc","p":{"a":0,"k":[180,400]},"r":{"a":0,"k":0},"s":{"a":0,"k":[360,800]}},{"ty":"gf","e":{"a":0,"k":[360,400]},"g":{"p":2,"k":{"a":1,"k":[{"t":0,"s":[0,0.09,0.592,0.592,1,0.216,0.776,0.678],"h":1},{"t":12,"s":[0,0.09,0.592,0.592,1,0.216,0.776,0.678],"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"t":60,"s":[0,0,0,0,1,0,0,0],"h":1}]}},"t":1,"o":{"a":0,"k":100},"s":{"a":0,"k":[0,400]}}]},{"ind":1,"ty":3,"ks":{},"ip":0,"op":61,"st":0}],"meta":{"g":"https://jitter.video"},"op":60,"v":"5.7.4","w":360} \ No newline at end of file diff --git a/android/app/src/main/ic_launcher-playstore.png b/android/app/src/main/ic_launcher-playstore.png new file mode 100644 index 00000000..8b8741f9 Binary files /dev/null and b/android/app/src/main/ic_launcher-playstore.png differ diff --git a/android/app/src/main/java/com/steve1316/uma_android_automation/MainActivity.kt b/android/app/src/main/java/com/steve1316/uma_android_automation/MainActivity.kt new file mode 100644 index 00000000..ed07b08e --- /dev/null +++ b/android/app/src/main/java/com/steve1316/uma_android_automation/MainActivity.kt @@ -0,0 +1,126 @@ +package com.steve1316.uma_android_automation + +import expo.modules.ReactActivityDelegateWrapper +import android.content.res.Configuration +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.ViewGroup +import android.widget.FrameLayout +import com.airbnb.lottie.LottieAnimationView +import com.facebook.react.ReactActivity +import com.facebook.react.ReactActivityDelegate +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled +import com.facebook.react.defaults.DefaultReactActivityDelegate +import com.github.javiersantos.appupdater.AppUpdater +import com.github.javiersantos.appupdater.enums.UpdateFrom +import com.steve1316.automation_library.utils.ScreenStateReceiver +import org.opencv.android.OpenCVLoader +import java.util.Locale + + +class MainActivity : ReactActivity() { + companion object { + const val loggerTag: String = "UAA" + } + + // This ViewGroup holds the Lottie animated splash screen that is displayed during app startup and allows for cleanup. + private var splashViewGroup: ViewGroup? = null + private val splashDuration = 2000L + + override fun onCreate(savedInstanceState: Bundle?) { + // State restoration needs to be null to avoid crash with react-native-screens. + // https://github.com/software-mansion/react-native-screens/issues/17#issuecomment-424704067 + super.onCreate(null) + ScreenStateReceiver.register(applicationContext) + + // Set application locale to combat cases where user's language uses commas instead of decimal points for floating numbers. + val config: Configuration? = this.getResources().configuration + val locale = Locale("en") + Locale.setDefault(locale) + this.getResources().updateConfiguration(config, this.getResources().displayMetrics) + + // Set up the app updater to check for the latest update from GitHub. + AppUpdater(this) + .setUpdateFrom(UpdateFrom.XML) + .setUpdateXML("https://raw.githubusercontent.com/steve1316/uma-android-automation/main/android/app/update.xml") + .start(); + + // Load OpenCV native library. This will throw a "E/OpenCV/StaticHelper: OpenCV error: Cannot load info library for OpenCV". It is safe to + // ignore this error. OpenCV functionality is not impacted by this error. + OpenCVLoader.initDebug() + + // Only show splash screen on first launch, not when resuming from background. + if (savedInstanceState == null) { + showSplashScreen() + } + } + + override fun onDestroy() { + ScreenStateReceiver.unregister(applicationContext) + super.onDestroy() + } + + /** + * Returns the name of the main component registered from JavaScript. This is used to schedule + * rendering of the component. + * + * Note: This needs to match with the name declared in app.json! + */ + override fun getMainComponentName(): String = "Uma Android Automation" + + /** + * Returns the instance of the [com.facebook.react.ReactActivityDelegate]. We use [DefaultReactActivityDelegate] + * which allows you to enable New Architecture with a single boolean flags [com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled] + */ + override fun createReactActivityDelegate(): ReactActivityDelegate = ReactActivityDelegateWrapper(this, BuildConfig.IS_NEW_ARCHITECTURE_ENABLED, DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)) + + /** + * Displays an animated splash screen while the application loads the Javascript bundle. + */ + private fun showSplashScreen() { + // Create a FrameLayout container that will hold the Lottie animation and allows the animation to be positioned and scaled. + val frameLayout = FrameLayout(this) + frameLayout.layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + + // Initialize the Lottie animation view with proper scaling configuration and add it to the FrameLayout. + // Animation file located in assets/splash.json + val lottieView = LottieAnimationView(this) + lottieView.setAnimation("splash.json") + lottieView.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP + lottieView.layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + frameLayout.addView(lottieView) + + // Display the splash screen visible to the user. + addContentView(frameLayout, ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )) + splashViewGroup = frameLayout + + // Play the Lottie animation. + lottieView.playAnimation() + + // Automatically transition to the main UI after the animation is finished. + Handler(Looper.getMainLooper()).postDelayed({ + hideSplashScreen() + }, splashDuration) + } + + /** + * Removes the splash screen and performs cleanup. + */ + private fun hideSplashScreen() { + splashViewGroup?.let { view -> + val parent = view.parent as? ViewGroup + parent?.removeView(view) + splashViewGroup = null + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/steve1316/uma_android_automation/MainApplication.kt b/android/app/src/main/java/com/steve1316/uma_android_automation/MainApplication.kt new file mode 100644 index 00000000..8920d1e4 --- /dev/null +++ b/android/app/src/main/java/com/steve1316/uma_android_automation/MainApplication.kt @@ -0,0 +1,51 @@ +package com.steve1316.uma_android_automation + +import android.content.res.Configuration +import expo.modules.ApplicationLifecycleDispatcher +import expo.modules.ReactNativeHostWrapper +import android.app.Application +import com.facebook.react.PackageList +import com.facebook.react.ReactApplication +import com.facebook.react.ReactHost +import com.facebook.react.ReactNativeHost +import com.facebook.react.ReactPackage +import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative +import com.facebook.react.defaults.DefaultReactNativeHost +import com.facebook.react.common.ReleaseLevel +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint + + +class MainApplication : Application(), ReactApplication { + override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(this, object : DefaultReactNativeHost(this) { + override fun getPackages(): List = + PackageList(this).packages.apply { + // Packages that cannot be autolinked yet can be added manually here, for example: + // add(MyReactNativePackage()) + add(StartPackage()) + } + + override fun getJSMainModuleName(): String = "index" + + override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG + + override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED + }) + + override val reactHost: ReactHost get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost) + + override fun onCreate() { + super.onCreate() + try { + DefaultNewArchitectureEntryPoint.releaseLevel = ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase()) + } catch (e: IllegalArgumentException) { + DefaultNewArchitectureEntryPoint.releaseLevel = ReleaseLevel.STABLE + } + loadReactNative(this) + ApplicationLifecycleDispatcher.onApplicationCreate(this) + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/steve1316/uma_android_automation/StartModule.kt b/android/app/src/main/java/com/steve1316/uma_android_automation/StartModule.kt new file mode 100644 index 00000000..685cc8b1 --- /dev/null +++ b/android/app/src/main/java/com/steve1316/uma_android_automation/StartModule.kt @@ -0,0 +1,319 @@ +package com.steve1316.uma_android_automation + +import android.app.Activity +import android.app.AlertDialog +import android.content.Context +import android.content.Intent +import android.media.projection.MediaProjectionManager +import android.provider.Settings +import android.util.Log +import com.facebook.react.bridge.ActivityEventListener +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.modules.core.DeviceEventManagerModule +import com.steve1316.automation_library.events.ExceptionEvent +import com.steve1316.automation_library.events.JSEvent +import com.steve1316.automation_library.events.StartEvent +import com.steve1316.automation_library.utils.MediaProjectionService +import com.steve1316.automation_library.utils.MessageLog +import com.steve1316.automation_library.utils.MyAccessibilityService +import com.steve1316.uma_android_automation.bot.Game +import com.steve1316.uma_android_automation.utils.SettingsHelper +import com.steve1316.uma_android_automation.utils.SQLiteSettingsManager +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.SubscriberExceptionEvent +import androidx.core.net.toUri + +/** + * Takes care of setting up internal processes such as the Accessibility and MediaProjection services, receiving and sending messages over to the + * Javascript frontend, and handle tests involving Discord and Twitter API integrations if needed. + *

+ * Loaded into the React PackageList via MainApplication's instantiation of the StartPackage. + */ +class StartModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext), ActivityEventListener { + private val tag = "[${MainActivity.loggerTag}]StartModule" + + companion object { + private var reactContext: ReactApplicationContext? = null + private var emitter: DeviceEventManagerModule.RCTDeviceEventEmitter? = null + } + + private val context: Context = reactContext.applicationContext + private var messageId = 1 + + init { + StartModule.reactContext = reactContext + StartModule.reactContext?.addActivityEventListener(this) + Log.d(tag, "StartModule is now initialized.") + } + + override fun getName(): String { + return "StartModule" + } + + override fun onNewIntent(intent: Intent) { + // Empty implementation + } + + override fun onActivityResult(activity: Activity, requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == 100 && resultCode == Activity.RESULT_OK) { + // Start up the MediaProjection service after the user accepts the onscreen prompt. + reactContext?.startService( + MediaProjectionService.getStartIntent(reactContext!!, resultCode, data!!) + ) + sendEvent("MediaProjectionService", "Running") + Log.d(tag, "MediaProjectionService is now running.") + } + } + + //////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////// + // Interaction with the Start / Stop button. + + /** + * This is called when the Start button is pressed back at the Javascript frontend and starts up the MediaProjection service along with the + * BotService attached to it. + */ + @ReactMethod + fun start() { + if (readyCheck()) { + startProjection() + } + } + + /** + * Register this module with EventBus in order to allow listening to certain events and then begin starting up the MediaProjection service. + */ + private fun startProjection() { + // This extra call to unregister is to account for the user stopping the service from the notification which bypasses the call to + // unregister in stopProjection(). + EventBus.getDefault().unregister(this) + EventBus.getDefault().register(this) + Log.d(tag, "Event Bus registered for StartModule") + val mediaProjectionManager = reactContext?.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager + reactContext?.startActivityForResult(mediaProjectionManager.createScreenCaptureIntent(), 100, null) + } + + /** + * Unregister this module with EventBus and then stops the MediaProjection service. + */ + private fun stopProjection() { + EventBus.getDefault().unregister(this) + Log.d(tag, "Event Bus unregistered for StartModule") + reactContext?.startService(MediaProjectionService.getStopIntent(reactContext!!)) + sendEvent("MediaProjectionService", "Not Running") + } + + /** + * This is called when the Stop button is pressed and will begin stopping the MediaProjection service. + */ + @ReactMethod + fun stop() { + stopProjection() + } + + //////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////// + // Permissions + + /** + * Checks the permissions for both overlay and accessibility for this app. + * + * @return True if both permissions were already granted and false otherwise. + */ + private fun readyCheck(): Boolean { + return checkForOverlayPermission() && checkForAccessibilityPermission() + } + + /** + * Checks for overlay permission and guides the user to enable it if it has not been granted yet. + * + * @return True if the overlay permission has already been granted. + */ + private fun checkForOverlayPermission(): Boolean { + if (!Settings.canDrawOverlays(this.reactApplicationContext.currentActivity)) { + Log.d(tag, "Application is missing overlay permission.") + + val builder = AlertDialog.Builder(this.reactApplicationContext.currentActivity) + builder.setTitle(R.string.overlay_disabled) + builder.setMessage(R.string.overlay_disabled_message) + + builder.setPositiveButton(R.string.go_to_settings) { _, _ -> + // Send the user to the Overlay Settings. + val uri = "package:${reactContext?.packageName}" + val intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, uri.toUri()) + this.reactApplicationContext.currentActivity?.startActivity(intent) + } + + builder.setNegativeButton(android.R.string.cancel, null) + + builder.show() + return false + } + + Log.d(tag, "Application has permission to draw overlay.") + return true + } + + /** + * Checks for accessibility permission and guides the user to enable it if it has not been granted yet. + * + * @return True if the accessibility permission has already been granted. + */ + private fun checkForAccessibilityPermission(): Boolean { + val prefString = Settings.Secure.getString(reactContext?.contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES) + + if (prefString != null && prefString.isNotEmpty()) { + // Check the string of enabled accessibility services to see if this application's accessibility service is there. + val enabled = prefString.contains(reactContext?.packageName.toString() + "/" + MyAccessibilityService::class.java.name) + + if (enabled) { + Log.d(tag, "This application's Accessibility Service is currently turned on.") + return true + } + } + + // Shows a dialog explaining how to enable Accessibility Service when restricted settings are detected. + // The dialog provides options to navigate to App Info or Accessibility Settings to complete the setup. + AlertDialog.Builder(this.reactApplicationContext.currentActivity).apply { + setTitle(R.string.accessibility_disabled) + setMessage( + """ + To enable Accessibility Service: + + 1. Tap "Go to App Info". + 2. Tap the 3-dot menu in the top right. If not available, you can skip to step 4. + 3. Tap "Allow restricted settings". + 4. Return to Accessibility Settings and enable the service. + """.trimIndent() + ) + setPositiveButton("Go to App Info") { _, _ -> + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = "package:${reactContext?.packageName}".toUri() + } + this@StartModule.reactApplicationContext.currentActivity?.startActivity(intent) + } + setNeutralButton("Accessibility Settings") { _, _ -> + val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) + this@StartModule.reactApplicationContext.currentActivity?.startActivity(intent) + } + setNegativeButton(android.R.string.cancel, null) + }.show() + + return false + } + + //////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////// + // Event interaction + + /** + * Listener function to start this module's entry point. + * + * @param event The StartEvent object to parse its message. + */ + @Subscribe + fun onStartEvent(event: StartEvent) { + if (event.message == "Entry Point ON") { + // Initialize SQLite settings with detailed debugging. + Log.d(tag, "Starting SQLite settings initialization...") + + // Check if the database file exists before attempting to initialize. + val dbFile = java.io.File(context.filesDir, "SQLite/settings.db") + Log.d(tag, "Database file path: ${dbFile.absolutePath}") + Log.d(tag, "Database file exists: ${dbFile.exists()}") + Log.d(tag, "Database file can read: ${dbFile.canRead()}") + Log.d(tag, "Database file size: ${if (dbFile.exists()) dbFile.length() else "N/A"} bytes") + + // List the contents of the files directory to see what's actually there. + val filesDir = context.filesDir + Log.d(tag, "Files directory: ${filesDir.absolutePath}") + val files = filesDir.listFiles() + if (files != null) { + Log.d(tag, "Files in files directory:") + for (file in files) { + Log.d(tag, " - ${file.name} (${if (file.isDirectory) "dir" else "file"})") + } + } + + // Check if SQLite subdirectory exists. + val sqliteDir = java.io.File(context.filesDir, "SQLite") + Log.d(tag, "SQLite directory exists: ${sqliteDir.exists()}") + if (sqliteDir.exists()) { + val sqliteFiles = sqliteDir.listFiles() + if (sqliteFiles != null) { + Log.d(tag, "Files in SQLite directory:") + for (file in sqliteFiles) { + Log.d(tag, " - ${file.name} (${file.length()} bytes)") + } + } + } + + // Check if database is available before attempting to initialize. + val settingsManager = SQLiteSettingsManager(context) + Log.d(tag, "Database is available: ${settingsManager.isDatabaseAvailable()}") + + SettingsHelper.initialize(context) + if (SettingsHelper.isAvailable()) { + Log.d(tag, "SQLite settings initialized successfully.") + } else { + Log.w(tag, "Failed to initialize SQLite settings, continuing with defaults.") + } + + val entryPoint = Game(context) + + try { + entryPoint.start() + } catch (e: Exception) { + EventBus.getDefault().postSticky(ExceptionEvent(e)) + } + } + } + + /** + * Sends the message back to the Javascript frontend along with its event name to be listened on. + * + * @param eventName The name of the event to be picked up on as defined in the developer's JS frontend. + * @param message The message string to pass on. + */ + fun sendEvent(eventName: String, message: String) { + val params = Arguments.createMap() + params.putString("message", message) + params.putInt("id", messageId++) + if (emitter == null) { + // Register the event emitter to send messages to JS. + Log.d(tag, "Event emitter not found to be able to send messages to the frontend. Registering now.") + emitter = reactContext?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + } + + emitter?.emit(eventName, params) + } + + /** + * Listener function to call the inner event sending function in order to send the message back to the Javascript frontend. + * + * @param event The JSEvent object to parse its event name and message. + */ + @Subscribe + fun onJSEvent(event: JSEvent) { + sendEvent(event.eventName, event.message) + } + + /** + * Listener function to send Exception messages back to the Javascript frontend. + * + * @param event The SubscriberExceptionEvent object to parse its event name and message. + */ + @Subscribe + fun onSubscriberExceptionEvent(event: SubscriberExceptionEvent) { + Log.e(tag, "Received exception event to send: ${event.throwable}") + MessageLog.printToLog(event.throwable.toString(), MainActivity.loggerTag, isWarning = false, isError = true, skipPrintTime = false) + for (line in event.throwable.stackTrace) { + MessageLog.printToLog("\t${line}", MainActivity.loggerTag, isWarning = false, isError = true, skipPrintTime = true) + } + MessageLog.printToLog("", MainActivity.loggerTag, isWarning = false, isError = false, skipPrintTime = true) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/steve1316/uma_android_automation/StartPackage.java b/android/app/src/main/java/com/steve1316/uma_android_automation/StartPackage.java new file mode 100644 index 00000000..9e61317b --- /dev/null +++ b/android/app/src/main/java/com/steve1316/uma_android_automation/StartPackage.java @@ -0,0 +1,28 @@ +package com.steve1316.uma_android_automation; + +import androidx.annotation.NonNull; +import com.facebook.react.ReactPackage; +import com.facebook.react.bridge.NativeModule; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.uimanager.ViewManager; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class StartPackage implements ReactPackage { + @NonNull + @Override + public List createViewManagers(@NonNull ReactApplicationContext reactContext) { + return Collections.emptyList(); + } + + @NonNull + @Override + public List createNativeModules(@NonNull ReactApplicationContext reactContext) { + List modules = new ArrayList<>(); + + modules.add(new StartModule(reactContext)); + + return modules; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/steve1316/uma_android_automation/bot/Campaign.kt b/android/app/src/main/java/com/steve1316/uma_android_automation/bot/Campaign.kt similarity index 61% rename from app/src/main/java/com/steve1316/uma_android_automation/bot/Campaign.kt rename to android/app/src/main/java/com/steve1316/uma_android_automation/bot/Campaign.kt index 92834fd7..5b0edd6d 100644 --- a/app/src/main/java/com/steve1316/uma_android_automation/bot/Campaign.kt +++ b/android/app/src/main/java/com/steve1316/uma_android_automation/bot/Campaign.kt @@ -1,6 +1,7 @@ package com.steve1316.uma_android_automation.bot import com.steve1316.uma_android_automation.MainActivity +import com.steve1316.uma_android_automation.utils.SettingsHelper /** * Base campaign class that contains all shared logic for campaign automation. @@ -10,18 +11,20 @@ import com.steve1316.uma_android_automation.MainActivity open class Campaign(val game: Game) { protected val tag: String = "[${MainActivity.Companion.loggerTag}]Normal" + val mustRestBeforeSummer: Boolean = SettingsHelper.getBooleanSetting("training", "mustRestBeforeSummer") + /** * Campaign-specific training event handling. */ open fun handleTrainingEvent() { - game.handleTrainingEvent() + game.trainingEvent.handleTrainingEvent() } /** * Campaign-specific race event handling. */ open fun handleRaceEvents(): Boolean { - return game.handleRaceEvents() + return game.racing.handleRaceEvents() } /** @@ -40,10 +43,17 @@ open class Campaign(val game: Game) { // Most bot operations start at the Main screen. if (game.checkMainScreen()) { var needToRace = false - if (!game.encounteredRacingPopup) { + if (!game.racing.encounteredRacingPopup) { // Refresh the stat values in memory. game.updateStatValueMapping() + // Check for fan requirement on the main screen. + val needsFanRequirement = game.imageUtils.findImage("race_fans_criteria", tries = 1, region = game.imageUtils.regionTopHalf).first != null + if (needsFanRequirement) { + game.racing.hasFanRequirement = true + game.printToLog("[RACE] Fan requirement criteria detected on main screen. Forcing racing to fulfill requirement.", tag = tag) + } + // If the required skill points has been reached, stop the bot. if (game.enableSkillPointCheck && game.imageUtils.determineSkillPoints() >= game.skillPointsRequired) { game.printToLog("\n[END] Bot has acquired the set amount of skill points. Exiting now...", tag = tag) @@ -52,64 +62,64 @@ open class Campaign(val game: Game) { } // If force racing is enabled, skip all other activities and go straight to racing - if (game.enableForceRacing) { - game.printToLog("\n[INFO] Force racing enabled - skipping all other activities and going straight to racing.", tag = tag) + if (game.racing.enableForceRacing) { + game.printToLog("[INFO] Force racing enabled - skipping all other activities and going straight to racing.", tag = tag) needToRace = true } else { - // If the bot detected a injury, then rest. - if (game.checkInjury()) { - game.printToLog("[INFO] A infirmary visit was attempted in order to heal an injury.", tag = tag) + // Check if we need to rest before Summer Training (June Early/Late in Classic/Senior Year). + if (mustRestBeforeSummer && (game.currentDate.year == 2 || game.currentDate.year == 3) && game.currentDate.month == 6 && game.currentDate.phase == "Late") { + game.printToLog("[INFO] Forcing rest during June ${game.currentDate.phase} in Year ${game.currentDate.year} in preparation for Summer Training.", tag = tag) + game.recoverEnergy() + game.racing.skipRacing = false + } else if (game.checkInjury() && !game.checkFinals()) { game.findAndTapImage("ok", region = game.imageUtils.regionMiddle) game.wait(3.0) - game.skipRacing = false - } else if (game.recoverMood()) { - game.printToLog("[INFO] Mood has recovered.", tag = tag) - game.skipRacing = false - } else if (!game.checkExtraRaceAvailability()) { + game.racing.skipRacing = false + } else if (game.recoverMood() && !game.checkFinals()) { + game.racing.skipRacing = false + } else if (!game.racing.isExtraRaceEligible()) { game.printToLog("[INFO] Training due to it not being an extra race day.", tag = tag) - game.handleTraining() - game.skipRacing = false + game.training.handleTraining() + game.racing.skipRacing = false } else { + game.printToLog("[INFO] Bot has no injuries, mood is sufficient and extra races can be run today. Setting needToRace to true.", tag = tag) needToRace = true } } } - if (game.encounteredRacingPopup || needToRace) { + if (game.racing.encounteredRacingPopup || needToRace) { game.printToLog("[INFO] Racing by default.", tag = tag) - if (!game.skipRacing && !handleRaceEvents()) { - if (game.detectedMandatoryRaceCheck) { + // The !game.racing.skipRacing was removed due to possibility of getting stuck in a loop. + if (!handleRaceEvents()) { + if (game.racing.detectedMandatoryRaceCheck) { game.printToLog("\n[END] Stopping bot due to detection of Mandatory Race.", tag = tag) game.notificationMessage = "Stopping bot due to detection of Mandatory Race." break } game.findAndTapImage("back", tries = 1, region = game.imageUtils.regionBottomHalf) - game.skipRacing = !game.enableForceRacing - game.handleTraining() + game.racing.skipRacing = !game.racing.enableForceRacing + game.training.handleTraining() } } } else if (game.checkTrainingEventScreen()) { // If the bot is at the Training Event screen, that means there are selectable options for rewards. - game.printToLog("[INFO] Detected a Training Event on screen.", tag = tag) handleTrainingEvent() - game.skipRacing = false + game.racing.skipRacing = false } else if (game.handleInheritanceEvent()) { // If the bot is at the Inheritance screen, then accept the inheritance. - game.printToLog("[INFO] Accepted the Inheritance.", tag = tag) - game.skipRacing = false + game.racing.skipRacing = false } else if (game.checkMandatoryRacePrepScreen()) { - game.printToLog("[INFO] There is a Mandatory race to be run.", tag = tag) // If the bot is at the Main screen with the button to select a race visible, that means the bot needs to handle a mandatory race. - if (!handleRaceEvents() && game.detectedMandatoryRaceCheck) { + if (!handleRaceEvents() && game.racing.detectedMandatoryRaceCheck) { game.printToLog("\n[END] Stopping bot due to detection of Mandatory Race.", tag = tag) game.notificationMessage = "Stopping bot due to detection of Mandatory Race." break } } else if (game.checkRacingScreen()) { // If the bot is already at the Racing screen, then complete this standalone race. - game.printToLog("[INFO] There is a standalone race ready to be run.", tag = tag) - game.handleStandaloneRace() - game.skipRacing = false + game.racing.handleStandaloneRace() + game.racing.skipRacing = false } else if (game.checkEndScreen()) { // Stop when the bot has reached the screen where it details the overall result of the run. game.printToLog("\n[END] Bot has reached the end of the run. Exiting now...", tag = tag) @@ -117,7 +127,7 @@ open class Campaign(val game: Game) { break } else if (checkCampaignSpecificConditions()) { game.printToLog("[INFO] Campaign-specific checks complete.", tag = tag) - game.skipRacing = false + game.racing.skipRacing = false continue } else { game.printToLog("[INFO] Did not detect the bot being at the following screens: Main, Training Event, Inheritance, Mandatory Race Preparation, Racing and Career End.", tag = tag) diff --git a/android/app/src/main/java/com/steve1316/uma_android_automation/bot/Game.kt b/android/app/src/main/java/com/steve1316/uma_android_automation/bot/Game.kt new file mode 100644 index 00000000..1a9510fb --- /dev/null +++ b/android/app/src/main/java/com/steve1316/uma_android_automation/bot/Game.kt @@ -0,0 +1,752 @@ +package com.steve1316.uma_android_automation.bot + +import android.content.Context +import android.util.Log +import com.steve1316.uma_android_automation.MainActivity +import com.steve1316.uma_android_automation.bot.campaigns.AoHaru +import com.steve1316.uma_android_automation.utils.CustomImageUtils +import com.steve1316.automation_library.utils.ImageUtils.ScaleConfidenceResult +import com.steve1316.automation_library.utils.BotService +import com.steve1316.automation_library.data.SharedData +import com.steve1316.automation_library.utils.MessageLog +import com.steve1316.automation_library.utils.MyAccessibilityService +import com.steve1316.uma_android_automation.utils.SettingsHelper +import com.steve1316.uma_android_automation.utils.GameDateParser +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.opencv.core.Point +import java.text.DecimalFormat +import kotlin.intArrayOf + +/** + * Main driver for bot activity and navigation. + */ +class Game(val myContext: Context) { + private val tag: String = "[${MainActivity.loggerTag}]Game" + var notificationMessage: String = "" + + val imageUtils: CustomImageUtils = CustomImageUtils(myContext, this) + val gestureUtils: MyAccessibilityService = MyAccessibilityService.getInstance() + val gameDateParser: GameDateParser = GameDateParser() + + val decimalFormat = DecimalFormat("#.##") + + //////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////// + // SQLite Settings + val campaign: String = SettingsHelper.getStringSetting("general", "scenario") + val debugMode: Boolean = SettingsHelper.getBooleanSetting("debug", "enableDebugMode") + + //////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////// + val training: Training = Training(this) + val racing: Racing = Racing(this) + val trainingEvent: TrainingEvent = TrainingEvent(this) + + //////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////// + // Stops + val enableSkillPointCheck: Boolean = SettingsHelper.getBooleanSetting("general", "enableSkillPointCheck") + val skillPointsRequired: Int = SettingsHelper.getIntSetting("general", "skillPointCheck") + private val enablePopupCheck: Boolean = SettingsHelper.getBooleanSetting("general", "enablePopupCheck") + + //////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////// + // Misc + var currentDate: Date = Date(1, "Early", 1, 1) + var aptitudes: Aptitudes = Aptitudes( + track = Track("B", "B"), + distance = Distance("B", "B", "B", "B"), + style = Style("B", "B", "B", "B") + ) + private var inheritancesDone = 0 + + data class Date( + val year: Int, + val phase: String, + val month: Int, + val turnNumber: Int + ) + + data class Track( + val turf: String, + val dirt: String + ) + + data class Distance( + val sprint: String, + val mile: String, + val medium: String, + val long: String + ) + + data class Style( + val front: String, + val pace: String, + val late: String, + val end: String + ) + + data class Aptitudes( + val track: Track, + val distance: Distance, + val style: Style + ) + + //////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////// + // Helper functions for bot logging and interaction. + + /** + * Print the specified message to debug console and then saves the message to the log. + * + * @param message Message to be saved. + * @param tag Distinguishes between messages for where they came from. Defaults to Game's TAG. + * @param isError Flag to determine whether to display log message in console as debug or error. + * @param isOption Flag to determine whether to append a newline right after the time in the string. + */ + fun printToLog(message: String, tag: String = this.tag, isError: Boolean = false, isOption: Boolean = false) { + // Remove the newline prefix if needed and place it where it should be. + val formattedMessage = if (message.startsWith("\n")) { + val newMessage = message.removePrefix("\n") + if (isOption) { + "\n\n$newMessage" + } else { + "\n$newMessage" + } + } else { + if (isOption) { + "\n$message" + } else { + message + } + } + + // Add message to the external library's MessageLog (which already posts to EventBus). + MessageLog.printToLog(formattedMessage, tag, false, isError, false) + } + + /** + * Wait the specified seconds to account for ping or loading. + * It also checks for interruption every 100ms to allow faster interruption and checks if the game is still in the middle of loading. + * + * @param seconds Number of seconds to pause execution. + * @param skipWaitingForLoading If true, then it will skip the loading check. Defaults to false. + */ + fun wait(seconds: Double, skipWaitingForLoading: Boolean = false) { + val totalMillis = (seconds * 1000).toLong() + // Check for interruption every 100ms. + val checkInterval = 100L + + var remainingMillis = totalMillis + while (remainingMillis > 0) { + if (!BotService.isRunning) { + throw InterruptedException() + } + + val sleepTime = minOf(checkInterval, remainingMillis) + runBlocking { + delay(sleepTime) + } + remainingMillis -= sleepTime + } + + if (!skipWaitingForLoading) { + // Check if the game is still loading as well. + waitForLoading() + } + } + + /** + * Wait for the game to finish loading. + */ + fun waitForLoading() { + while (checkLoading()) { + // Avoid an infinite loop by setting the flag to true. + wait(0.5, skipWaitingForLoading = true) + } + } + + /** + * Find and tap the specified image. + * + * @param imageName Name of the button image file in the /assets/images/ folder. + * @param tries Number of tries to find the specified button. Defaults to 3. + * @param region Specify the region consisting of (x, y, width, height) of the source screenshot to template match. Defaults to (0, 0, 0, 0) which is equivalent to searching the full image. + * @param taps Specify the number of taps on the specified image. Defaults to 1. + * @param suppressError Whether or not to suppress saving error messages to the log in failing to find the button. Defaults to false. + * @return True if the button was found and clicked. False otherwise. + */ + fun findAndTapImage(imageName: String, tries: Int = 3, region: IntArray = intArrayOf(0, 0, 0, 0), taps: Int = 1, suppressError: Boolean = false): Boolean { + if (debugMode) { + printToLog("[DEBUG] Now attempting to find and click the \"$imageName\" button.") + } + + val tempLocation: Point? = imageUtils.findImage(imageName, tries = tries, region = region, suppressError = suppressError).first + + return if (tempLocation != null) { + Log.d(tag, "Found and going to tap: $imageName") + tap(tempLocation.x, tempLocation.y, imageName, taps = taps) + true + } else { + false + } + } + + /** + * Performs a tap on the screen at the coordinates and then will wait until the game processes the server request and gets a response back. + * + * @param x The x-coordinate. + * @param y The y-coordinate. + * @param imageName The template image name to use for tap location randomization. + * @param taps The number of taps. + * @param ignoreWaiting Flag to ignore checking if the game is busy loading. + */ + fun tap(x: Double, y: Double, imageName: String, taps: Int = 1, ignoreWaiting: Boolean = false) { + // Perform the tap. + gestureUtils.tap(x, y, imageName, taps = taps) + + if (!ignoreWaiting) { + // Now check if the game is waiting for a server response from the tap and wait if necessary. + wait(0.20) + waitForLoading() + } + } + + /** + * Prints the current date as a formatted string. + * + * @return Formatted date string. + */ + fun printFormattedDate(): String { + val formattedYear = when (currentDate.year) { + 1 -> "Junior Year" + 2 -> "Classic Year" + 3 -> "Senior Year" + else -> "Null Year" + } + val formattedMonth = when (currentDate.month) { + 1 -> "Jan" + 2 -> "Feb" + 3 -> "Mar" + 4 -> "Apr" + 5 -> "May" + 6 -> "Jun" + 7 -> "Jul" + 8 -> "Aug" + 9 -> "Sep" + 10 -> "Oct" + 11 -> "Nov" + 12 -> "Dec" + else -> "Null Month" + } + return "$formattedYear ${currentDate.phase} $formattedMonth / Turn Number ${currentDate.turnNumber}" + } + + //////////////////////////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////////////////////////// + // Helper functions to test behavior and results of various workflows. + + /** + * Handles the test to perform template matching to determine what the best scale will be for the device. + */ + fun startTemplateMatchingTest() { + printToLog("\n[TEST] Now beginning basic template match test on the Home screen.") + printToLog("[TEST] Template match confidence setting will be overridden for the test.\n") + var results = mutableMapOf>( + "energy" to mutableListOf(), + "tazuna" to mutableListOf(), + "skill_points" to mutableListOf() + ) + results = imageUtils.startTemplateMatchingTest(results) + printToLog("\n[TEST] Basic template match test complete.") + + // Print all scale/confidence combinations that worked for each template. + for ((templateName, scaleConfidenceResults) in results) { + if (scaleConfidenceResults.isNotEmpty()) { + printToLog("[TEST] All working scale/confidence combinations for $templateName:") + for (result in scaleConfidenceResults) { + printToLog("[TEST] Scale: ${result.scale}, Confidence: ${result.confidence}") + } + } else { + printToLog("[WARNING] No working scale/confidence combinations found for $templateName") + } + } + + // Then print the median scales and confidences. + val medianScales = mutableListOf() + val medianConfidences = mutableListOf() + for ((templateName, scaleConfidenceResults) in results) { + if (scaleConfidenceResults.isNotEmpty()) { + val sortedScales = scaleConfidenceResults.map { it.scale }.sorted() + val sortedConfidences = scaleConfidenceResults.map { it.confidence }.sorted() + val medianScale = sortedScales[sortedScales.size / 2] + val medianConfidence = sortedConfidences[sortedConfidences.size / 2] + medianScales.add(medianScale) + medianConfidences.add(medianConfidence) + printToLog("[TEST] Median scale for $templateName: $medianScale") + printToLog("[TEST] Median confidence for $templateName: $medianConfidence") + } + } + + if (medianScales.isNotEmpty()) { + printToLog("\n[TEST] The following are the recommended scales to set: $medianScales.") + printToLog("[TEST] The following are the recommended confidences to set: $medianConfidences.") + } else { + printToLog("\n[ERROR] No median scale/confidence can be found.", isError = true) + } + } + + /** + * Handles the test to perform OCR on the current date and elapsed turn number. + */ + fun startDateOCRTest() { + printToLog("\n[TEST] Now beginning the Date OCR test on the Main screen.") + printToLog("[TEST] Note that this test is dependent on having the correct scale.") + updateDate() + } + + fun startAptitudesDetectionTest() { + printToLog("\n[TEST] Now beginning the Aptitudes Detection test on the Main screen.") + printToLog("[TEST] Note that this test is dependent on having the correct scale.") + updateAptitudes() + } + + //////////////////////////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////////////////////////// + // Helper functions to check what screen the bot is at. + + /** + * Checks if the bot is at the Main screen or the screen with available options to undertake. + * This will also make sure that the Main screen does not contain the option to select a race. + * + * @return True if the bot is at the Main screen. Otherwise false. + */ + fun checkMainScreen(): Boolean { + // Current date should be printed here to section off the tasks undertaken for this date. + printToLog("\n[INFO] Checking if the bot is sitting at the Main screen.") + return if (imageUtils.findImage("tazuna", tries = 1, region = imageUtils.regionTopHalf).first != null && + imageUtils.findImage("race_select_mandatory", tries = 1, region = imageUtils.regionBottomHalf, suppressError = true).first == null) { + printToLog("[INFO] Bot is at the Main screen.") + + // Perform updates here if necessary. + updateDate() + if (currentDate.turnNumber % 10 == 0) updateAptitudes() + true + } else if (!enablePopupCheck && imageUtils.findImage("cancel", tries = 1, region = imageUtils.regionBottomHalf).first != null && + imageUtils.findImage("race_confirm", tries = 1, region = imageUtils.regionBottomHalf).first != null) { + // This popup is most likely the insufficient fans popup. Force an extra race to catch up on the required fans. + printToLog("[INFO] There is a possible insufficient fans or maiden race popup.") + racing.encounteredRacingPopup = true + racing.skipRacing = false + true + } else { + printToLog("[INFO] Bot is not at the Main screen.") + false + } + } + + /** + * Checks if the bot is at the Training Event screen with an active event with options to select on screen. + * + * @return True if the bot is at the Training Event screen. Otherwise false. + */ + fun checkTrainingEventScreen(): Boolean { + printToLog("\n[INFO] Checking if the bot is sitting on the Training Event screen.") + return if (imageUtils.findImage("training_event_active", tries = 1, region = imageUtils.regionMiddle).first != null) { + printToLog("[INFO] Bot is at the Training Event screen.") + true + } else { + printToLog("[INFO] Bot is not at the Training Event screen.") + false + } + } + + /** + * Checks if the bot is at the preparation screen with a mandatory race needing to be completed. + * + * @return True if the bot is at the Main screen with a mandatory race. Otherwise false. + */ + fun checkMandatoryRacePrepScreen(): Boolean { + printToLog("\n[INFO] Checking if the bot is sitting on the Race Preparation screen for a mandatory race.") + return if (imageUtils.findImage("race_select_mandatory", tries = 1, region = imageUtils.regionBottomHalf).first != null) { + printToLog("[INFO] Bot is at the preparation screen with a mandatory race ready to be completed.") + true + } else if (imageUtils.findImage("race_select_mandatory_goal", tries = 1, region = imageUtils.regionMiddle).first != null) { + // Most likely the user started the bot here so a delay will need to be placed to allow the start banner of the Service to disappear. + wait(2.0) + printToLog("[INFO] Bot is at the Race Selection screen with a mandatory race needing to be selected.") + // Walk back to the preparation screen. + findAndTapImage("back", tries = 1, region = imageUtils.regionBottomHalf) + wait(1.0) + true + } else { + printToLog("[INFO] Bot is not at the Race Preparation screen for a mandatory race.") + false + } + } + + /** + * Checks if the bot is at the Racing screen waiting to be skipped or done manually. + * + * @return True if the bot is at the Racing screen. Otherwise, false. + */ + fun checkRacingScreen(): Boolean { + printToLog("\n[INFO] Checking if the bot is sitting on the Racing screen.") + return if (imageUtils.findImage("race_change_strategy", tries = 1, region = imageUtils.regionBottomHalf).first != null) { + printToLog("[INFO] Bot is at the Racing screen waiting to be skipped or done manually.") + true + } else { + printToLog("[INFO] Bot is not at the Racing screen.") + false + } + } + + /** + * Checks if the bot is at the Ending screen detailing the overall results of the run. + * + * @return True if the bot is at the Ending screen. Otherwise false. + */ + fun checkEndScreen(): Boolean { + printToLog("\n[INFO] Checking if the bot is sitting on the End screen.") + return if (imageUtils.findImage("complete_career", tries = 1, region = imageUtils.regionBottomHalf).first != null) { + true + } else { + printToLog("[INFO] Bot is not at the End screen and can keep going.") + false + } + } + + /** + * Checks if the bot is currently at Finals. + * + * @return True if the bot is at Finals. Otherwise false. + */ + fun checkFinals(): Boolean { + printToLog("\n[INFO] Checking if the bot is at the Finals.") + val finalsLocation = imageUtils.findImage("race_select_extra_locked_uma_finals", tries = 1, suppressError = true, region = imageUtils.regionBottomHalf).first + return if (finalsLocation != null) { + printToLog("[INFO] It is currently the Finals.") + true + } else { + printToLog("[INFO] It is not the Finals yet.") + false + } + } + + /** + * Checks if the bot has a injury. + * + * @return True if the bot has a injury. Otherwise false. + */ + fun checkInjury(): Boolean { + printToLog("\n[INJURY] Checking if there is an injury that needs healing on ${printFormattedDate()}.") + val recoverInjuryLocation = imageUtils.findImage("recover_injury", tries = 1, region = imageUtils.regionBottomHalf).first + return if (recoverInjuryLocation != null && imageUtils.checkColorAtCoordinates( + recoverInjuryLocation.x.toInt(), + recoverInjuryLocation.y.toInt() + 15, + intArrayOf(151, 105, 243), + 10 + )) { + if (findAndTapImage("recover_injury", tries = 1, region = imageUtils.regionBottomHalf)) { + wait(0.3) + if (imageUtils.findImage("recover_injury_header", tries = 1, region = imageUtils.regionMiddle).first != null) { + printToLog("[INJURY] Injury detected and attempted to heal.") + true + } else { + false + } + } else { + printToLog("[WARNING] Injury detected but attempt to rest failed.") + false + } + } else { + printToLog("[INJURY] No injury detected.") + false + } + } + + /** + * Checks if the bot is at a "Now Loading..." screen or if the game is awaiting for a server response. This may cause significant delays in normal bot processes. + * + * @return True if the game is still loading or is awaiting for a server response. Otherwise, false. + */ + fun checkLoading(): Boolean { + printToLog("[LOADING] Now checking if the game is still loading...") + return if (imageUtils.findImage("connecting", tries = 1, region = imageUtils.regionTopHalf, suppressError = true).first != null) { + printToLog("[LOADING] Detected that the game is awaiting a response from the server from the \"Connecting\" text at the top of the screen. Waiting...") + true + } else if (imageUtils.findImage("now_loading", tries = 1, region = imageUtils.regionBottomHalf, suppressError = true).first != null) { + printToLog("[LOADING] Detected that the game is still loading from the \"Now Loading\" text at the bottom of the screen. Waiting...") + true + } else { + false + } + } + + //////////////////////////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////////////////////////// + // Helper functions to update game states. + + fun updateAptitudes() { + printToLog("\n[STATS] Updating aptitudes for the current character.") + if (findAndTapImage("main_status", tries = 1, region = imageUtils.regionMiddle)) { + aptitudes = imageUtils.determineAptitudes(aptitudes) + findAndTapImage("race_accept_trophy", tries = 1, region = imageUtils.regionBottomHalf) + MessageLog.printToLog(""" + [Aptitudes] + Track: Turf=${aptitudes.track.turf}, Dirt=${aptitudes.track.dirt} + Distance: Sprint=${aptitudes.distance.sprint}, Mile=${aptitudes.distance.mile}, Medium=${aptitudes.distance.medium}, Long=${aptitudes.distance.long} + Style: Front=${aptitudes.style.front}, Pace=${aptitudes.style.pace}, Late=${aptitudes.style.late}, End=${aptitudes.style.end} + """.trimIndent(), + tag = tag + ) + + // Update preferred distance based on new aptitudes. + training.updatePreferredDistance() + } + } + + /** + * Updates the current stat value mapping by reading the character's current stats from the Main screen. + */ + fun updateStatValueMapping() { + printToLog("\n[STATS] Updating stat value mapping.") + training.currentStatsMap = imageUtils.determineStatValues(training.currentStatsMap) + // Print the updated stat value mapping here. + training.currentStatsMap.forEach { it -> + printToLog("[STATS] ${it.key}: ${it.value}") + } + } + + /** + * Updates the stored date in memory by keeping track of the current year, phase, month and current turn number. + */ + fun updateDate() { + printToLog("\n[DATE] Updating the current date.") + val dateString = imageUtils.determineDayString() + currentDate = gameDateParser.parseDateString(dateString, imageUtils, this) + printToLog("[DATE] It is currently ${printFormattedDate()}.") + } + + /** + * Handles the Inheritance event if detected on the screen. + * + * @return True if the Inheritance event happened and was accepted. Otherwise false. + */ + fun handleInheritanceEvent(): Boolean { + return if (inheritancesDone < 2) { + if (findAndTapImage("inheritance", tries = 1, region = imageUtils.regionBottomHalf)) { + printToLog("\n[INFO] Claimed an inheritance on ${printFormattedDate()}.") + inheritancesDone++ + true + } else { + false + } + } else { + false + } + } + + /** + * Attempt to recover energy. + * + * @return True if the bot successfully recovered energy. Otherwise false. + */ + fun recoverEnergy(): Boolean { + printToLog("\n[ENERGY] Now starting attempt to recover energy on ${printFormattedDate()}.") + return when { + findAndTapImage("recover_energy", tries = 1, imageUtils.regionBottomHalf) -> { + findAndTapImage("ok") + printToLog("[ENERGY] Successfully recovered energy.") + racing.raceRepeatWarningCheck = false + true + } + findAndTapImage("recover_energy_summer", tries = 1, imageUtils.regionBottomHalf) -> { + findAndTapImage("ok") + printToLog("[ENERGY] Successfully recovered energy for the Summer.") + racing.raceRepeatWarningCheck = false + true + } + else -> { + printToLog("[ENERGY] Failed to recover energy. Moving on...") + false + } + } + } + + /** + * Attempt to recover mood to always maintain at least Above Normal mood. + * + * @return True if the bot successfully recovered mood. Otherwise false. + */ + fun recoverMood(): Boolean { + printToLog("\n[MOOD] Detecting current mood on ${printFormattedDate()}.") + + // Detect what Mood the bot is at. + val currentMood: String = when { + imageUtils.findImage("mood_normal", tries = 1, region = imageUtils.regionTopHalf, suppressError = true).first != null -> { + "Normal" + } + imageUtils.findImage("mood_good", tries = 1, region = imageUtils.regionTopHalf, suppressError = true).first != null -> { + "Good" + } + imageUtils.findImage("mood_great", tries = 1, region = imageUtils.regionTopHalf, suppressError = true).first != null -> { + "Great" + } + else -> { + "Bad/Awful" + } + } + + printToLog("[MOOD] Detected mood to be $currentMood.") + + // Only recover mood if its below Good mood and its not Summer. + return if (training.firstTrainingCheck && currentMood == "Normal" && imageUtils.findImage("recover_energy_summer", tries = 1, region = imageUtils.regionBottomHalf, suppressError = true).first == null) { + printToLog("[MOOD] Current mood is Normal. Not recovering mood due to firstTrainingCheck flag being active. Will need to complete a training first before being allowed to recover mood.") + false + } else if ((currentMood == "Bad/Awful" || currentMood == "Normal") && imageUtils.findImage("recover_energy_summer", tries = 1, region = imageUtils.regionBottomHalf, suppressError = true).first == null) { + printToLog("[MOOD] Current mood is not good. Recovering mood now.") + if (!findAndTapImage("recover_mood", tries = 1, region = imageUtils.regionBottomHalf, suppressError = true)) { + findAndTapImage("recover_energy_summer", tries = 1, region = imageUtils.regionBottomHalf, suppressError = true) + } + + // Do the date if it is unlocked. + if (findAndTapImage("recover_mood_date", tries = 1, region = imageUtils.regionMiddle, suppressError = true)) { + wait(1.0) + } + + findAndTapImage("ok", region = imageUtils.regionMiddle, suppressError = true) + racing.raceRepeatWarningCheck = false + true + } else { + printToLog("[MOOD] Current mood is good enough or its the Summer event. Moving on...") + false + } + } + + + /** + * Perform misc checks to potentially fix instances where the bot is stuck. + * + * @return True if the checks passed. Otherwise false if the bot encountered a warning popup and needs to exit. + */ + fun performMiscChecks(): Boolean { + printToLog("\n[MISC] Beginning check for misc cases...") + + if (enablePopupCheck && imageUtils.findImage("cancel", tries = 1, region = imageUtils.regionBottomHalf).first != null && + imageUtils.findImage("recover_mood_date", tries = 1, region = imageUtils.regionMiddle).first == null) { + printToLog("\n[END] Bot may have encountered a warning popup. Exiting now...") + notificationMessage = "Bot may have encountered a warning popup" + return false + } else if (findAndTapImage("next", tries = 1, region = imageUtils.regionBottomHalf)) { + // Now confirm the completion of a Training Goal popup. + printToLog("[MISC] Popup detected that needs to be dismissed with the \"Next\" button.") + wait(2.0) + findAndTapImage("next", tries = 1, region = imageUtils.regionBottomHalf) + wait(1.0) + } else if (imageUtils.findImage("crane_game", tries = 1, region = imageUtils.regionBottomHalf).first != null) { + // Stop when the bot has reached the Crane Game Event. + printToLog("\n[END] Bot will stop due to the detection of the Crane Game Event. Please complete it and restart the bot.") + notificationMessage = "Bot will stop due to the detection of the Crane Game Event. Please complete it and restart the bot." + return false + } else if (findAndTapImage("race_retry", tries = 1, region = imageUtils.regionBottomHalf, suppressError = true)) { + printToLog("[MISC] There is a race retry popup.") + wait(5.0) + } else if (findAndTapImage("race_accept_trophy", tries = 1, region = imageUtils.regionBottomHalf, suppressError = true)) { + printToLog("[MISC] There is a possible popup to accept a trophy.") + racing.finishRace(true, isExtra = true) + } else if (findAndTapImage("race_end", tries = 1, region = imageUtils.regionBottomHalf, suppressError = true)) { + printToLog("[MISC] Ended a leftover race.") + } else if (imageUtils.findImage("connection_error", tries = 1, region = imageUtils.regionMiddle, suppressError = true).first != null) { + printToLog("\n[END] Bot will stop due to detecting a connection error.") + notificationMessage = "Bot will stop due to detecting a connection error." + return false + } else if (imageUtils.findImage("race_not_enough_fans", tries = 1, region = imageUtils.regionMiddle, suppressError = true).first != null) { + printToLog("[MISC] There was a popup about insufficient fans.") + racing.encounteredRacingPopup = true + findAndTapImage("cancel", region = imageUtils.regionBottomHalf) + } else if (findAndTapImage("back", tries = 1, region = imageUtils.regionBottomHalf, suppressError = true)) { + printToLog("[MISC] Navigating back a screen since all the other misc checks have been completed.") + wait(1.0) + } else if (!BotService.isRunning) { + printToLog("\n[END] BotService is not running. Exiting now...") + throw InterruptedException() + } else { + printToLog("[MISC] Did not detect any popups or the Crane Game on the screen. Moving on...") + } + + return true + } + + /** + * Bot will begin automation here. + * + * @return True if all automation goals have been met. False otherwise. + */ + fun start(): Boolean { + // Print current app settings at the start of the run. + try { + val formattedSettingsString = SettingsHelper.getStringSetting("misc", "formattedSettingsString") + printToLog("\n[SETTINGS] Current Bot Configuration:") + printToLog("=====================================") + formattedSettingsString.split("\n").forEach { line -> + if (line.isNotEmpty()) { + printToLog(line) + } + } + printToLog("=====================================\n") + } catch (e: Exception) { + printToLog("[WARNING] Failed to load formatted settings from SQLite: ${e.message}") + printToLog("[INFO] Using fallback settings display...") + // Fallback to basic settings display if formatted string is not available. + printToLog("[INFO] Campaign: $campaign") + printToLog("[INFO] Debug Mode: $debugMode") + } + + // Print device and version information. + printToLog("[INFO] Device Information: ${SharedData.displayWidth}x${SharedData.displayHeight}, DPI ${SharedData.displayDPI}") + if (SharedData.displayWidth != 1080) printToLog("[WARNING] ⚠️ Bot performance will be severely degraded since display width is not 1080p unless an appropriate scale is set for your device.") + if (debugMode) printToLog("[WARNING] ⚠️ Debug Mode is enabled. All bot operations will be significantly slower as a result.") + if (SettingsHelper.getStringSetting("debug", "templateMatchCustomScale").toDouble() != 1.0) printToLog("[INFO] Manual scale has been set to ${SettingsHelper.getStringSetting("debug", "templateMatchCustomScale").toDouble()}") + printToLog("[WARNING] ⚠️ Note that certain Android notification styles (like banners) are big enough that they cover the area that contains the Mood which will interfere with mood recovery logic in the beginning.") + val packageInfo = myContext.packageManager.getPackageInfo(myContext.packageName, 0) + printToLog("[INFO] Bot version: ${packageInfo.versionName} (${packageInfo.versionCode})\n\n") + + val startTime: Long = System.currentTimeMillis() + + // Start debug tests here if enabled. Otherwise, proceed with regular bot operations. + if (SettingsHelper.getBooleanSetting("debug", "debugMode_startTemplateMatchingTest")) { + startTemplateMatchingTest() + } else if (SettingsHelper.getBooleanSetting("debug", "debugMode_startSingleTrainingOCRTest")) { + training.startSingleTrainingOCRTest() + } else if (SettingsHelper.getBooleanSetting("debug", "debugMode_startComprehensiveTrainingOCRTest")) { + training.startComprehensiveTrainingOCRTest() + } else if (SettingsHelper.getBooleanSetting("debug", "debugMode_startDateOCRTest")) { + startDateOCRTest() + } else if (SettingsHelper.getBooleanSetting("debug", "debugMode_startRaceListDetectionTest")) { + racing.startRaceListDetectionTest() + } else if (SettingsHelper.getBooleanSetting("debug", "debugMode_startAptitudesDetectionTest")) { + startAptitudesDetectionTest() + } else { + // Update the stat targets by distances and the preferred distance for training. + training.setStatTargetsByDistances() + training.updatePreferredDistance() + + wait(5.0) + + if (campaign == "Ao Haru") { + val aoHaruCampaign = AoHaru(this) + aoHaruCampaign.start() + } else { + val uraFinaleCampaign = Campaign(this) + uraFinaleCampaign.start() + } + } + + val endTime: Long = System.currentTimeMillis() + Log.d(tag, "Total Runtime: ${endTime - startTime}ms") + + return true + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/steve1316/uma_android_automation/bot/Racing.kt b/android/app/src/main/java/com/steve1316/uma_android_automation/bot/Racing.kt new file mode 100644 index 00000000..1ede924e --- /dev/null +++ b/android/app/src/main/java/com/steve1316/uma_android_automation/bot/Racing.kt @@ -0,0 +1,1682 @@ +package com.steve1316.uma_android_automation.bot + +import com.steve1316.uma_android_automation.MainActivity +import com.steve1316.uma_android_automation.utils.SettingsHelper +import com.steve1316.uma_android_automation.utils.CustomImageUtils.RaceDetails +import com.steve1316.uma_android_automation.utils.SQLiteSettingsManager +import net.ricecode.similarity.JaroWinklerStrategy +import net.ricecode.similarity.StringSimilarityServiceImpl +import org.json.JSONArray +import org.json.JSONObject +import org.opencv.core.Point +import android.util.Log + +class Racing (private val game: Game) { + private val tag: String = "[${MainActivity.loggerTag}]Racing" + + private val enableFarmingFans = SettingsHelper.getBooleanSetting("racing", "enableFarmingFans") + private val daysToRunExtraRaces: Int = SettingsHelper.getIntSetting("racing", "daysToRunExtraRaces") + private val disableRaceRetries: Boolean = SettingsHelper.getBooleanSetting("racing", "disableRaceRetries") + val enableForceRacing = SettingsHelper.getBooleanSetting("racing", "enableForceRacing") + + private val enableRacingPlan = SettingsHelper.getBooleanSetting("racing", "enableRacingPlan") + private val lookAheadDays = SettingsHelper.getIntSetting("racing", "lookAheadDays") + private val smartRacingCheckInterval = SettingsHelper.getIntSetting("racing", "smartRacingCheckInterval") + private val minFansThreshold = SettingsHelper.getIntSetting("racing", "minFansThreshold") + private val preferredTerrain = SettingsHelper.getStringSetting("racing", "preferredTerrain") + private val preferredGradesString = SettingsHelper.getStringSetting("racing", "preferredGrades") + private val racingPlanJson = SettingsHelper.getStringSetting("racing", "racingPlan") + + private var raceRetries = 3 + var raceRepeatWarningCheck = false + var encounteredRacingPopup = false + var skipRacing = false + var firstTimeRacing = true + var hasFanRequirement = false // Indicates that a fan requirement has been detected on the main screen. + private var nextSmartRaceDay: Int? = null // Tracks the specific day to race based on opportunity cost analysis. + + private val enableStopOnMandatoryRace: Boolean = SettingsHelper.getBooleanSetting("racing", "enableStopOnMandatoryRaces") + var detectedMandatoryRaceCheck = false + + // Race strategy override settings. + private val enableRaceStrategyOverride = SettingsHelper.getBooleanSetting("racing", "enableRaceStrategyOverride") + private val juniorYearRaceStrategy = SettingsHelper.getStringSetting("racing", "juniorYearRaceStrategy") + private val userSelectedOriginalStrategy = SettingsHelper.getStringSetting("racing", "originalRaceStrategy") + private var detectedOriginalStrategy: String? = null + private var hasAppliedStrategyOverride = false + + companion object { + private const val TABLE_RACES = "races" + private const val RACES_COLUMN_NAME = "name" + private const val RACES_COLUMN_GRADE = "grade" + private const val RACES_COLUMN_FANS = "fans" + private const val RACES_COLUMN_TURN_NUMBER = "turnNumber" + private const val RACES_COLUMN_NAME_FORMATTED = "nameFormatted" + private const val RACES_COLUMN_TERRAIN = "terrain" + private const val RACES_COLUMN_DISTANCE_TYPE = "distanceType" + private const val SIMILARITY_THRESHOLD = 0.7 + } + + /** + * Retrieves the user's planned races from saved settings. + * + * @return A list of [PlannedRace] entries defined by the user, or an empty list if none exist. + */ + private fun getUserPlannedRaces(): List { + if (!enableRacingPlan) { + game.printToLog("[RACE] Racing plan is disabled, returning empty planned races list.", tag = tag) + return emptyList() + } + + return try { + if (game.debugMode) game.printToLog("[RACE] Raw user-selected racing plan JSON: \"$racingPlanJson\".", tag = tag) + + if (racingPlanJson.isEmpty() || racingPlanJson == "[]") { + game.printToLog("[RACE] User-selected racing plan is empty, returning empty list.", tag = tag) + return emptyList() + } + + val jsonArray = JSONArray(racingPlanJson) + val plannedRaces = mutableListOf() + + for (i in 0 until jsonArray.length()) { + val raceObj = jsonArray.getJSONObject(i) + val plannedRace = PlannedRace( + raceName = raceObj.getString("raceName"), + date = raceObj.getString("date"), + priority = raceObj.optInt("priority", 0) + ) + plannedRaces.add(plannedRace) + } + + game.printToLog("[RACE] Successfully loaded ${plannedRaces.size} user-selected planned races from settings.", tag = tag) + plannedRaces + } catch (e: Exception) { + game.printToLog("[ERROR] Failed to parse user-selected racing plan JSON: ${e.message}. Returning empty list.", tag = tag, isError = true) + emptyList() + } + } + + /** + * Loads the complete race database from saved settings, including all race metadata such as + * names, grades, distances, and turn numbers. + * + * @return A map of race names to their [RaceData] or an empty map if racing plan data is missing or invalid. + */ + private fun getRacePlanData(): Map { + return try { + val racingPlanDataJson = SettingsHelper.getStringSetting("racing", "racingPlanData") + if (game.debugMode) game.printToLog("[RACE] Raw racing plan data JSON length: ${racingPlanDataJson.length}.", tag = tag) + + if (racingPlanDataJson.isEmpty()) { + game.printToLog("[RACE] Racing plan data is empty, returning empty map.", tag = tag) + return emptyMap() + } + + val jsonObject = JSONObject(racingPlanDataJson) + val raceDataMap = mutableMapOf() + + val keys = jsonObject.keys() + while (keys.hasNext()) { + val key = keys.next() + val raceObj = jsonObject.getJSONObject(key) + + val raceData = RaceData( + name = raceObj.getString("name"), + grade = raceObj.getString("grade"), + terrain = raceObj.getString("terrain"), + distanceType = raceObj.getString("distanceType"), + fans = raceObj.getInt("fans"), + turnNumber = raceObj.getInt("turnNumber"), + nameFormatted = raceObj.getString("nameFormatted") + ) + + raceDataMap[raceData.name] = raceData + } + + game.printToLog("[RACE] Successfully loaded ${raceDataMap.size} race entries from racing plan data.", tag = tag) + raceDataMap + } catch (e: Exception) { + game.printToLog("[ERROR] Failed to parse racing plan data JSON: ${e.message}. Returning empty map.", tag = tag, isError = true) + emptyMap() + } + } + + data class RaceData( + val name: String, + val grade: String, + val fans: Int, + val nameFormatted: String, + val terrain: String, + val distanceType: String, + val turnNumber: Int + ) + + data class ScoredRace( + val raceData: RaceData, + val score: Double, + val fansScore: Double, + val gradeScore: Double, + val aptitudeBonus: Double + ) + + data class PlannedRace( + val raceName: String, + val date: String, + val priority: Int + ) + + //////////////////////////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////////////////////////// + + /** + * Handles the test to detect the currently displayed races on the Race List screen. + */ + fun startRaceListDetectionTest() { + game.printToLog("\n[TEST] Now beginning detection test on the Race List screen for the currently displayed races.", tag = tag) + if (game.imageUtils.findImage("race_status").first == null) { + game.printToLog("[TEST] Bot is not on the Race List screen. Ending the test.") + return + } + + // Detect the current date first. + game.updateDate() + + // Check for all double star predictions. + val doublePredictionLocations = game.imageUtils.findAll("race_extra_double_prediction") + game.printToLog("[TEST] Found ${doublePredictionLocations.size} races with double predictions.", tag = tag) + + doublePredictionLocations.forEachIndexed { index, location -> + val raceName = game.imageUtils.extractRaceName(location) + game.printToLog("[TEST] Race #${index + 1} - Detected name: \"$raceName\".", tag = tag) + + // Query database for race details. + val raceData = getRaceByTurnAndName(game.currentDate.turnNumber, raceName) + + if (raceData != null) { + game.printToLog("[TEST] Race #${index + 1} - Match found:", tag = tag) + game.printToLog("[TEST] Name: ${raceData.name}", tag = tag) + game.printToLog("[TEST] Grade: ${raceData.grade}", tag = tag) + game.printToLog("[TEST] Fans: ${raceData.fans}", tag = tag) + game.printToLog("[TEST] Formatted: ${raceData.nameFormatted}", tag = tag) + } else { + game.printToLog("[TEST] Race #${index + 1} - No match found for turn ${game.currentDate.turnNumber}", tag = tag) + } + } + } + + //////////////////////////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////////////////////////// + + /** + * Get race data by turn number and detected name using exact and/or fuzzy matching. + * + * @param turnNumber The current turn number to match against. + * @param detectedName The race name detected by OCR. + * @return A [RaceData] object if a match is found, null otherwise. + */ + fun getRaceByTurnAndName(turnNumber: Int, detectedName: String): RaceData? { + val settingsManager = SQLiteSettingsManager(game.myContext) + if (!settingsManager.initialize()) { + game.printToLog("[ERROR] Database not available for race lookup.", tag = tag, isError = true) + return null + } + + return try { + game.printToLog("[RACE] Looking up race for turn $turnNumber with detected name: \"$detectedName\".", tag = tag) + + // Do exact matching based on the info gathered. + val exactMatch = findExactMatch(settingsManager, turnNumber, detectedName) + if (exactMatch != null) { + game.printToLog("[RACE] Found exact match: \"${exactMatch.name}\" AKA \"${exactMatch.nameFormatted}\".", tag = tag) + settingsManager.close() + return exactMatch + } + + // Otherwise, do fuzzy matching to find the most similar match using Jaro-Winkler. + val fuzzyMatch = findFuzzyMatch(settingsManager, turnNumber, detectedName) + if (fuzzyMatch != null) { + game.printToLog("[RACE] Found fuzzy match: \"${fuzzyMatch.name}\" AKA \"${fuzzyMatch.nameFormatted}\".", tag = tag) + settingsManager.close() + return fuzzyMatch + } + + game.printToLog("[RACE] No match found for turn $turnNumber with name \"$detectedName\".", tag = tag) + settingsManager.close() + null + } catch (e: Exception) { + game.printToLog("[ERROR] Error looking up race: ${e.message}.", tag = tag, isError = true) + settingsManager.close() + null + } + } + + /** + * Queries the race database for an entry matching the specified turn number and formatted name. + * + * @param settingsManager The settings manager providing access to the race database. + * @param turnNumber The turn number used to filter the race records. + * @param detectedName The exact formatted race name to match against. + * @return A [RaceData] object if an exact match is found, or null if no matching race exists. + */ + private fun findExactMatch(settingsManager: SQLiteSettingsManager, turnNumber: Int, detectedName: String): RaceData? { + val database = settingsManager.getDatabase() + if (database == null) return null + + val cursor = database.query( + TABLE_RACES, + arrayOf( + RACES_COLUMN_NAME, + RACES_COLUMN_GRADE, + RACES_COLUMN_FANS, + RACES_COLUMN_NAME_FORMATTED, + RACES_COLUMN_TERRAIN, + RACES_COLUMN_DISTANCE_TYPE, + RACES_COLUMN_TURN_NUMBER + ), + "$RACES_COLUMN_TURN_NUMBER = ? AND $RACES_COLUMN_NAME_FORMATTED = ?", + arrayOf(turnNumber.toString(), detectedName), + null, null, null + ) + + return if (cursor.moveToFirst()) { + val race = RaceData( + name = cursor.getString(0), + grade = cursor.getString(1), + fans = cursor.getInt(2), + nameFormatted = cursor.getString(3), + terrain = cursor.getString(4), + distanceType = cursor.getString(5), + turnNumber = cursor.getInt(6) + ) + cursor.close() + race + } else { + cursor.close() + null + } + } + + /** + * Attempts to find the best fuzzy match for a race entry based on the given formatted name. + * + * This function queries all races for the specified turn number, then compares each race’s + * `nameFormatted` value to the provided [detectedName] using Jaro–Winkler string similarity. + * The race with the highest similarity score above the defined [SIMILARITY_THRESHOLD] is returned. + * + * @param settingsManager The settings manager providing access to the race database. + * @param turnNumber The turn number used to filter the race records. + * @param detectedName The name to compare against existing formatted race names. + * @return A [RaceData] object representing the best fuzzy match, or null if no similar race is found. + */ + private fun findFuzzyMatch(settingsManager: SQLiteSettingsManager, turnNumber: Int, detectedName: String): RaceData? { + val database = settingsManager.getDatabase() + if (database == null) return null + + val cursor = database.query( + TABLE_RACES, + arrayOf( + RACES_COLUMN_NAME, + RACES_COLUMN_GRADE, + RACES_COLUMN_FANS, + RACES_COLUMN_NAME_FORMATTED, + RACES_COLUMN_TERRAIN, + RACES_COLUMN_DISTANCE_TYPE, + RACES_COLUMN_TURN_NUMBER + ), + "$RACES_COLUMN_TURN_NUMBER = ?", + arrayOf(turnNumber.toString()), + null, null, null + ) + + if (!cursor.moveToFirst()) { + cursor.close() + return null + } + + val similarityService = StringSimilarityServiceImpl(JaroWinklerStrategy()) + var bestMatch: RaceData? = null + var bestScore = 0.0 + + do { + val nameFormatted = cursor.getString(3) + val similarity = similarityService.score(detectedName, nameFormatted) + + if (similarity > bestScore && similarity >= SIMILARITY_THRESHOLD) { + bestScore = similarity + bestMatch = RaceData( + name = cursor.getString(0), + grade = cursor.getString(1), + fans = cursor.getInt(2), + nameFormatted = nameFormatted, + terrain = cursor.getString(4), + distanceType = cursor.getString(5), + turnNumber = cursor.getInt(6) + ) + if (game.debugMode) game.printToLog("[DEBUG] Fuzzy match candidate: \"${bestMatch.name}\" AKA \"$nameFormatted\" with similarity ${game.decimalFormat.format(similarity)}.", tag = tag) + else Log.d(tag, "[DEBUG] Fuzzy match candidate: \"${bestMatch.name}\" AKA \"$nameFormatted\" with similarity ${game.decimalFormat.format(similarity)}.") + } + } while (cursor.moveToNext()) + + cursor.close() + + if (bestMatch != null) { + game.printToLog("[RACE] Best fuzzy match: \"${bestMatch.name}\" AKA \"${bestMatch.nameFormatted}\" with similarity ${game.decimalFormat.format(bestScore)}.", tag = tag) + } + + return bestMatch + } + + //////////////////////////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////////////////////////// + // Smart Racing Plan Functionality + + /** + * Maps the distance/terrain type string to the corresponding aptitude field. + * + * @param aptitudeType Either the distance type from race data ("Sprint", "Mile", "Medium", "Long") or the terrain ("Turf", "Dirt"). + * @return The corresponding aptitude value from the character's aptitudes. + */ + private fun mapToAptitude(aptitudeType: String): String { + return when (aptitudeType) { + "Sprint" -> game.aptitudes.distance.sprint + "Mile" -> game.aptitudes.distance.mile + "Medium" -> game.aptitudes.distance.medium + "Long" -> game.aptitudes.distance.long + "Turf" -> game.aptitudes.track.turf + "Dirt" -> game.aptitudes.track.dirt + else -> "X" + } + } + + /** + * Calculates a bonus value based on the race’s aptitude ratings for terrain and distance. + * + * This function checks whether both the terrain and distance aptitudes of the given race + * are rated as "A" or "S". If both conditions are met, a bonus of 100.0 is returned; + * otherwise, the result is 0.0. + * + * @param race The [RaceData] instance whose aptitudes are evaluated. + * @return The bonus value based on whether the conditions are met. + */ + private fun getAptitudeMatchBonus(race: RaceData): Double { + val terrainAptitude = mapToAptitude(race.terrain) + val distanceAptitude = mapToAptitude(race.distanceType) + + val terrainMatch = terrainAptitude == "A" || terrainAptitude == "S" + val distanceMatch = distanceAptitude == "A" || distanceAptitude == "S" + + return if (terrainMatch && distanceMatch) 100.0 else 0.0 + } + + /** + * Calculates a composite race score based on fan count, race grade, and aptitude performance. + * + * The score is derived from three weighted factors: + * - **Fans:** Normalized to a 0–100 scale. + * - **Grade:** Weighted to a map of values based on grade. + * - **Aptitude:** Adds a bonus if both terrain and distance aptitudes are A or S. + * + * The final score is the average of these three components. + * + * @param race The [RaceData] instance to evaluate. + * @return A [ScoredRace] object containing the final score and individual factor breakdowns. + */ + fun calculateRaceScore(race: RaceData): ScoredRace { + // Normalize fans to 0-100 scale (assuming max fans is 30000). + val fansScore = (race.fans.toDouble() / 30000.0) * 100.0 + + // Grade scoring: G1 = 75, G2 = 50, G3 = 25. + val gradeScore = when (race.grade) { + "G1" -> 75.0 + "G2" -> 50.0 + "G3" -> 25.0 + else -> 0.0 + } + + // Aptitude bonus: 100 if both terrain and distance match A/S, else 0. + val aptitudeBonus = getAptitudeMatchBonus(race) + + // Calculate final score with equal weights. + val finalScore = (fansScore + gradeScore + aptitudeBonus) / 3.0 + + // Log detailed scoring breakdown for debugging. + val terrainAptitude = mapToAptitude(race.terrain) + val distanceAptitude = mapToAptitude(race.distanceType) + if (game.debugMode) game.printToLog( + """ + [DEBUG] Scoring ${race.name}: + Fans = ${race.fans} (${game.decimalFormat.format(fansScore)}) + Grade = ${race.grade} (${game.decimalFormat.format(gradeScore)}) + Terrain = ${race.terrain} ($terrainAptitude) + Distance = ${race.distanceType} ($distanceAptitude) + Aptitude = ${game.decimalFormat.format(aptitudeBonus)} + Final = ${game.decimalFormat.format(finalScore)} + """.trimIndent(), + tag = tag + ) + + return ScoredRace( + raceData = race, + score = finalScore, + fansScore = fansScore, + gradeScore = gradeScore, + aptitudeBonus = aptitudeBonus + ) + } + + /** + * Retrieves all races scheduled within a specified look-ahead window from the database. + * + * This function queries races whose turn numbers fall between [currentTurn] and + * [currentTurn] + [lookAheadDays], inclusive. It returns the corresponding [RaceData] + * entries sorted in ascending order by turn number. + * + * @param currentTurn The current turn number used as the starting point. + * @param lookAheadDays The number of days (turns) to look ahead for upcoming races. + * @return A list of [RaceData] objects representing all races within the look-ahead window. + */ + fun getLookAheadRaces(currentTurn: Int, lookAheadDays: Int): List { + val settingsManager = SQLiteSettingsManager(game.myContext) + if (!settingsManager.initialize()) { + game.printToLog("[ERROR] Database not available for look-ahead race lookup.", tag = tag, isError = true) + return emptyList() + } + + return try { + val database = settingsManager.getDatabase() + if (database == null) { + game.printToLog("[ERROR] Database is null for look-ahead race lookup.", tag = tag, isError = true) + return emptyList() + } + + val endTurn = currentTurn + lookAheadDays + val cursor = database.query( + TABLE_RACES, + arrayOf( + RACES_COLUMN_NAME, + RACES_COLUMN_GRADE, + RACES_COLUMN_FANS, + RACES_COLUMN_NAME_FORMATTED, + RACES_COLUMN_TERRAIN, + RACES_COLUMN_DISTANCE_TYPE, + RACES_COLUMN_TURN_NUMBER + ), + "$RACES_COLUMN_TURN_NUMBER >= ? AND $RACES_COLUMN_TURN_NUMBER <= ?", + arrayOf(currentTurn.toString(), endTurn.toString()), + null, null, "$RACES_COLUMN_TURN_NUMBER ASC" + ) + + val races = mutableListOf() + if (cursor.moveToFirst()) { + do { + val race = RaceData( + name = cursor.getString(0), + grade = cursor.getString(1), + fans = cursor.getInt(2), + nameFormatted = cursor.getString(3), + terrain = cursor.getString(4), + distanceType = cursor.getString(5), + turnNumber = cursor.getInt(6) + ) + races.add(race) + } while (cursor.moveToNext()) + } + cursor.close() + settingsManager.close() + + game.printToLog("[RACE] Found ${races.size} races in look-ahead window (turns $currentTurn to $endTurn).", tag = tag) + races + } catch (e: Exception) { + game.printToLog("[ERROR] Error getting look-ahead races: ${e.message}", tag = tag, isError = true) + settingsManager.close() + emptyList() + } + } + + /** + * Filters the given list of races according to the user’s Racing Plan settings. + * + * The filtering criteria are loaded from the Racing Plan configuration and include: + * - **Minimum fans threshold:** Races must have at least this number of fans. + * - **Preferred terrain:** Only races matching the specified terrain (or "Any") are included. + * - **Preferred grades:** Races must match one of the preferred grade values. + * + * @param races The list of [RaceData] entries to filter. + * @return A list of [RaceData] objects that satisfy all Racing Plan filter criteria. + */ + fun filterRacesBySettings(races: List): List { + // Parse preferred grades from JSON array string. + game.printToLog("[RACE] Raw preferred grades string: \"$preferredGradesString\".", tag = tag) + val preferredGrades = try { + // Parse as JSON array. + val jsonArray = JSONArray(preferredGradesString) + val parsed = (0 until jsonArray.length()).map { jsonArray.getString(it) } + game.printToLog("[RACE] Parsed as JSON array: $parsed.", tag = tag) + parsed + } catch (e: Exception) { + game.printToLog("[RACE] Error parsing preferred grades: ${e.message}, using fallback.", tag = tag) + val parsed = preferredGradesString.split(",").map { it.trim() } + game.printToLog("[RACE] Fallback parsing result: $parsed", tag = tag) + parsed + } + + if (game.debugMode) game.printToLog("[DEBUG] Filter criteria: Min fans: $minFansThreshold, terrain: $preferredTerrain, grades: $preferredGrades", tag = tag) + else Log.d(tag, "[DEBUG] Filter criteria: Min fans: $minFansThreshold, terrain: $preferredTerrain, grades: $preferredGrades") + + val filteredRaces = races.filter { race -> + val meetsFansThreshold = race.fans >= minFansThreshold + val meetsTerrainPreference = preferredTerrain == "Any" || race.terrain == preferredTerrain + val meetsGradePreference = preferredGrades.isEmpty() || preferredGrades.contains(race.grade) + + val passes = meetsFansThreshold && meetsTerrainPreference && meetsGradePreference + + // If the race did not pass any of the filters, print the reason why. + if (!passes) { + val reasons = mutableListOf() + if (!meetsFansThreshold) reasons.add("fans ${race.fans} < $minFansThreshold") + if (!meetsTerrainPreference) reasons.add("terrain ${race.terrain} != $preferredTerrain") + if (!meetsGradePreference) reasons.add("grade ${race.grade} not in $preferredGrades") + if (game.debugMode) game.printToLog("[DEBUG] ✗ Filtered out ${race.name}: ${reasons.joinToString(", ")}", tag = tag) + else Log.d(tag, "[DEBUG] ✗ Filtered out ${race.name}: ${reasons.joinToString(", ")}") + } else { + if (game.debugMode) game.printToLog("[DEBUG] ✓ Passed filter: ${race.name} (fans: ${race.fans}, terrain: ${race.terrain}, grade: ${race.grade})", tag = tag) + else Log.d(tag, "[DEBUG] ✓ Passed filter: ${race.name} (fans: ${race.fans}, terrain: ${race.terrain}, grade: ${race.grade})") + } + + passes + } + + return filteredRaces + } + + /** + * Determines if a planned race should be considered based on current turn and race availability. + * + * For Year 3 (Senior Year): Always check screen for availability (existing smart racing flow) + * For Years 1-2: Calculate turn distance and check eligibility: + * - Must be within lookAheadDays range + * - Must pass standard racing checks (not in summer, not locked, etc.) + * - Use daysToRunExtraRaces to determine if it's an eligible racing day + * + * @param plannedRace The user-selected race to evaluate. + * @param racePlanData Full race database containing turn numbers. + * @param dayNumber The current day number in the game. + * @param currentTurnNumber The current turn in the game. + * @return True if the race should be considered for racing. + */ + private fun isPlannedRaceEligible(plannedRace: PlannedRace, racePlanData: Map, dayNumber: Int, currentTurnNumber: Int): Boolean { + // Find the race in the plan data. + val raceData = racePlanData[plannedRace.raceName] + if (raceData == null) { + game.printToLog("[ERROR] Planned race \"${plannedRace.raceName}\" not found in race plan data.", tag = tag, isError = true) + return false + } + + val raceTurnNumber = raceData.turnNumber + val turnDistance = raceTurnNumber - currentTurnNumber + + // Check if race is within look-ahead window. + if (turnDistance < 0) { + return false + } else if (turnDistance > lookAheadDays) { + if (game.debugMode) { + game.printToLog("[DEBUG] Planned race \"${plannedRace.raceName}\" is too far ahead of the look-ahead window (distance $turnDistance > lookAheadDays $lookAheadDays).", tag = tag) + } else { + Log.d(tag, "[DEBUG] Planned race \"${plannedRace.raceName}\" is too far ahead of the look-ahead window (distance $turnDistance > lookAheadDays $lookAheadDays).") + } + return false + } + + // For Classic Year, check if it's an eligible racing day using the settings for the standard racing logic. + if (game.currentDate.year == 2) { + if (!isEligibleRacingDay(dayNumber)) { + game.printToLog("[RACE] Planned race \"${plannedRace.raceName}\" is not on an eligible racing day (day $dayNumber, interval $daysToRunExtraRaces).", tag = tag) + return false + } + } + + game.printToLog("[RACE] Planned race \"${plannedRace.raceName}\" is eligible for racing.", tag = tag) + return true + } + + /** + * Checks if a given day number is eligible for racing based on the configured interval. + * + * @param dayNumber The day number to check. + * @return True if the day falls on the racing interval (dayNumber % daysToRunExtraRaces == 0). + */ + private fun isEligibleRacingDay(dayNumber: Int): Boolean { + return dayNumber % daysToRunExtraRaces == 0 + } + + /** + * Determines the optimal race to participate in within the upcoming window by scoring all candidates. + * + * Each race in [filteredUpcomingRaces] is evaluated using [calculateRaceScore], which considers + * fans, grade, and aptitude performance. The race with the highest overall score is returned. + * + * @param filteredUpcomingRaces The list of [RaceData] entries that passed prior filters. + * @return The [ScoredRace] with the highest score, or null if the list is empty. + */ + fun findBestRaceInWindow(filteredUpcomingRaces: List): ScoredRace? { + game.printToLog("[RACE] Finding best race in window from ${filteredUpcomingRaces.size} races after filters...", tag = tag) + + if (filteredUpcomingRaces.isEmpty()) { + game.printToLog("[RACE] No races provided after filters, cannot find best race.", tag = tag) + return null + } + + // For each upcoming race, calculate their score. + val scoredRaces = filteredUpcomingRaces.map { calculateRaceScore(it) } + val sortedScoredRaces = scoredRaces.sortedByDescending { it.score } + game.printToLog("[RACE] Scored all races (sorted by score descending):", tag = tag) + sortedScoredRaces.forEach { scoredRace -> + if (game.debugMode) game.printToLog("[DEBUG] ${scoredRace.raceData.name}: score=${game.decimalFormat.format(scoredRace.score)}, " + + "fans=${scoredRace.raceData.fans}(${game.decimalFormat.format(scoredRace.fansScore)}), " + + "grade=${scoredRace.raceData.grade}(${game.decimalFormat.format(scoredRace.gradeScore)}), " + + "aptitude=${game.decimalFormat.format(scoredRace.aptitudeBonus)}", + tag = tag + ) + } + + val bestRace = sortedScoredRaces.maxByOrNull { it.score } + + if (bestRace != null) { + game.printToLog("[RACE] Best race in window: ${bestRace.raceData.name} (score: ${game.decimalFormat.format(bestRace.score)})", tag = tag) + game.printToLog("[RACE] Fans: ${bestRace.raceData.fans} (${game.decimalFormat.format(bestRace.fansScore)}), Grade: ${bestRace.raceData.grade} (${game.decimalFormat.format(bestRace.gradeScore)}), Aptitude: ${game.decimalFormat.format(bestRace.aptitudeBonus)}", tag = tag) + } else { + game.printToLog("[RACE] Failed to determine best race from scored races.", tag = tag) + } + + return bestRace + } + + /** + * Calculates opportunity cost to determine whether the bot should race immediately or wait for a better opportunity. + * + * The decision is based on comparing the best currently available races with upcoming races + * within the specified look-ahead window. Each race is scored using [calculateRaceScore], + * taking into account fans, grade, and aptitude. The function applies a time decay factor to + * upcoming races and evaluates whether the expected improvement from waiting exceeds a + * predefined threshold. + * + * Decision logic: + * 1. If no current races are available, the bot cannot race. + * 2. Scores current races and identifies the best option. + * 3. Looks ahead [lookAheadDays] turns to find and filter upcoming races, then scores them. + * 4. Applies time decay and calculates the potential improvement from waiting. + * 5. Compares improvement against thresholds to decide whether to race now or wait. + * + * @param currentRaces List of currently available [RaceData] races. + * @param lookAheadDays Number of turns/days to consider for upcoming races. + * @return True if the bot should race now, false if it is better to wait for a future race. + */ + fun calculateOpportunityCost(currentRaces: List, lookAheadDays: Int): Boolean { + game.printToLog("[RACE] Evaluating whether to race now using Opportunity Cost logic...", tag = tag) + if (currentRaces.isEmpty()) { + game.printToLog("[RACE] No current races available, cannot race now.", tag = tag) + return false + } + + // Score current races. + game.printToLog("[RACE] Scoring ${currentRaces.size} current races (sorted by score descending):", tag = tag) + val currentScoredRaces = currentRaces.map { calculateRaceScore(it) } + val sortedScoredRaces = currentScoredRaces.sortedByDescending { it.score } + sortedScoredRaces.forEach { scoredRace -> + game.printToLog("[RACE] Current race: ${scoredRace.raceData.name} (score: ${game.decimalFormat.format(scoredRace.score)})", tag = tag) + } + val bestCurrentRace = sortedScoredRaces.maxByOrNull { it.score } + + if (bestCurrentRace == null) { + game.printToLog("[RACE] Failed to score current races, cannot race now.", tag = tag) + return false + } + + game.printToLog("[RACE] Best current race: ${bestCurrentRace.raceData.name} (score: ${game.decimalFormat.format(bestCurrentRace.score)})", tag = tag) + + // Get and score upcoming races. + game.printToLog("[RACE] Looking ahead $lookAheadDays days for upcoming races...", tag = tag) + val upcomingRaces = getLookAheadRaces(game.currentDate.turnNumber + 1, lookAheadDays) + game.printToLog("[RACE] Found ${upcomingRaces.size} upcoming races in database.", tag = tag) + + val filteredUpcomingRaces = filterRacesBySettings(upcomingRaces) + game.printToLog("[RACE] After filtering: ${filteredUpcomingRaces.size} upcoming races remain.", tag = tag) + + val bestUpcomingRace = findBestRaceInWindow(filteredUpcomingRaces) + + if (bestUpcomingRace == null) { + game.printToLog("[RACE] No suitable upcoming races found, racing now with best current option.", tag = tag) + return true + } + + game.printToLog("[RACE] Best upcoming race: ${bestUpcomingRace.raceData.name} (score: ${game.decimalFormat.format(bestUpcomingRace.score)}).", tag = tag) + + // Opportunity Cost logic. + val minimumQualityThreshold = 70.0 // Don't race anything scoring below this. + val timeDecayFactor = 0.90 // Future races are worth this percentage of their score. + val improvementThreshold = 25.0 // Only wait if improvement is greater than this. + + // Apply time decay to upcoming race score. + val discountedUpcomingScore = bestUpcomingRace.score * timeDecayFactor + + // Calculate opportunity cost: How much better is waiting? + val improvementFromWaiting = discountedUpcomingScore - bestCurrentRace.score + + // Decision criteria. + val isGoodEnough = bestCurrentRace.score >= minimumQualityThreshold + val notWorthWaiting = improvementFromWaiting < improvementThreshold + val shouldRace = isGoodEnough && notWorthWaiting + + game.printToLog("[RACE] Opportunity Cost Analysis:", tag = tag) + game.printToLog("[RACE] Current score: ${game.decimalFormat.format(bestCurrentRace.score)}", tag = tag) + game.printToLog("[RACE] Upcoming score (raw): ${game.decimalFormat.format(bestUpcomingRace.score)}", tag = tag) + game.printToLog("[RACE] Upcoming score (discounted by ${game.decimalFormat.format((1 - timeDecayFactor) * 100)}%): ${game.decimalFormat.format(discountedUpcomingScore)}", tag = tag) + game.printToLog("[RACE] Improvement from waiting: ${game.decimalFormat.format(improvementFromWaiting)}", tag = tag) + game.printToLog("[RACE] Quality check (≥${minimumQualityThreshold}): ${if (isGoodEnough) "PASS" else "FAIL"}", tag = tag) + game.printToLog("[RACE] Worth waiting check (<${improvementThreshold}): ${if (notWorthWaiting) "PASS" else "FAIL"}", tag = tag) + game.printToLog("[RACE] Decision: ${if (shouldRace) "RACE NOW" else "WAIT FOR BETTER OPPORTUNITY"}", tag = tag) + + // Print the reasoning for the decision. + if (shouldRace) { + game.printToLog("[RACE] Reasoning: Current race is good enough (${game.decimalFormat.format(bestCurrentRace.score)} ≥ ${minimumQualityThreshold}) and waiting only gives ${game.decimalFormat.format(improvementFromWaiting)} more points (less than ${improvementThreshold}).", tag = tag) + // Race now - clear the next race day tracker. + nextSmartRaceDay = null + } else { + val reason = if (!isGoodEnough) { + "Current race quality too low (${game.decimalFormat.format(bestCurrentRace.score)} < ${minimumQualityThreshold})." + } else { + "Worth waiting for better opportunity (+${game.decimalFormat.format(improvementFromWaiting)} points > ${improvementThreshold})." + } + game.printToLog("[RACE] Reasoning: $reason", tag = tag) + // Wait for better opportunity - store the turn number to race on. + val bestUpcomingRaceData = upcomingRaces.find { it.name == bestUpcomingRace.raceData.name } + nextSmartRaceDay = bestUpcomingRaceData?.turnNumber + game.printToLog("[RACE] Setting next smart race day to turn ${nextSmartRaceDay}.", tag = tag) + } + + return shouldRace + } + + /** + * Determines if racing is worthwhile based on turn number and opportunity cost analysis for smart racing. + * + * This function queries the race database to check if races exist at the current turn + * and uses opportunity cost logic to determine if racing is better than waiting. + * + * @param currentTurnNumber The current turn number in the game. + * @param dayNumber The current day number for extra races. + * @return True if we should race based on turn analysis, false otherwise. + */ + private fun shouldRaceSmartCheck(currentTurnNumber: Int, dayNumber: Int): Boolean { + return try { + game.printToLog("[RACE] Checking eligibility for racing at turn $currentTurnNumber...", tag = tag) + + // First, check if there are any races available at the current turn. + val currentTurnRaces = getLookAheadRaces(currentTurnNumber, 0) + if (currentTurnRaces.isEmpty()) { + game.printToLog("[RACE] No races available at turn $currentTurnNumber.", tag = tag) + return false + } + + game.printToLog("[RACE] Found ${currentTurnRaces.size} race(s) at turn $currentTurnNumber.", tag = tag) + + // Query upcoming races in the look-ahead window for opportunity cost analysis. + val upcomingRaces = getLookAheadRaces(currentTurnNumber + 1, lookAheadDays) + game.printToLog("[RACE] Found ${upcomingRaces.size} upcoming races in look-ahead window.", tag = tag) + + // Apply filters to both current and upcoming races. + val filteredCurrentRaces = filterRacesBySettings(currentTurnRaces) + val filteredUpcomingRaces = filterRacesBySettings(upcomingRaces) + + game.printToLog("[RACE] After filtering: ${filteredCurrentRaces.size} current races, ${filteredUpcomingRaces.size} upcoming races.", tag = tag) + + // If no filtered current races exist, we shouldn't race. + if (filteredCurrentRaces.isEmpty()) { + game.printToLog("[RACE] No current races match the filter criteria. Skipping racing.", tag = tag) + return false + } + + // If there are no upcoming races to compare against, race now if we have acceptable races. + if (filteredUpcomingRaces.isEmpty()) { + game.printToLog("[RACE] No upcoming races to compare against. Racing now with available races.", tag = tag) + return true + } + + // Use opportunity cost logic to determine if we should race now or wait. + val shouldRace = calculateOpportunityCost(filteredCurrentRaces, lookAheadDays) + + shouldRace + } catch (e: Exception) { + game.printToLog("[ERROR] Error in turn-based racing check: ${e.message}. Falling back to screen-based checks.", tag = tag, isError = true) + true // Return true to fall back to screen checks. + } + } + + /** + * Handles extra races using Smart Racing logic for Senior Year (Year 3). + * + * Updates game data, identifies and evaluates available races, and prioritizes planned ones + * with a scoring bonus. If no valid race is found, the process is canceled. + * + * @return True if a race was successfully selected and ready to run; false if the process was canceled. + */ + private fun handleSmartRacing(): Boolean { + game.printToLog("[RACE] Using Smart Racing Plan logic...", tag = tag) + + // Updates the current date and aptitudes for accurate scoring. + game.updateDate() + game.updateAptitudes() + + // Load user planned races and race plan data. + val userPlannedRaces = getUserPlannedRaces() + val racePlanData = getRacePlanData() + game.printToLog("[RACE] Loaded ${userPlannedRaces.size} user-selected races and ${racePlanData.size} race entries.", tag = tag) + + // Detects all double-star race predictions on screen. + val doublePredictionLocations = game.imageUtils.findAll("race_extra_double_prediction") + game.printToLog("[RACE] Found ${doublePredictionLocations.size} double-star prediction locations.", tag = tag) + if (doublePredictionLocations.isEmpty()) { + game.printToLog("[RACE] No double-star predictions found. Canceling racing process.", tag = tag) + return false + } + + // Extracts race names from the screen and matches them with the in-game database. + game.printToLog("[RACE] Extracting race names and matching with database...", tag = tag) + val currentRaces = doublePredictionLocations.mapNotNull { location -> + val raceName = game.imageUtils.extractRaceName(location) + val raceData = getRaceByTurnAndName(game.currentDate.turnNumber, raceName) + if (raceData != null) { + game.printToLog("[RACE] ✓ Matched in database: ${raceData.name} (Grade: ${raceData.grade}, Fans: ${raceData.fans}, Terrain: ${raceData.terrain}).", tag = tag) + raceData + } else { + game.printToLog("[RACE] ✗ No match found in database for \"$raceName\".", tag = tag) + null + } + } + + if (currentRaces.isEmpty()) { + game.printToLog("[RACE] No races matched in database. Canceling racing process.", tag = tag) + return false + } + game.printToLog("[RACE] Successfully matched ${currentRaces.size} races in database.", tag = tag) + + // Separate matched races into planned vs unplanned. + val (plannedRaces, regularRaces) = currentRaces.partition { race -> + userPlannedRaces.any { it.raceName == race.name } + } + + // Log which races are user-selected vs regular. + game.printToLog("[RACE] Found ${plannedRaces.size} user-selected races on screen: ${plannedRaces.map { it.name }}.", tag = tag) + game.printToLog("[RACE] Found ${regularRaces.size} regular races on screen: ${regularRaces.map { it.name }}.", tag = tag) + + // Filter both lists by user Racing Plan settings. + val filteredPlannedRaces = filterRacesBySettings(plannedRaces) + val filteredRegularRaces = filterRacesBySettings(regularRaces) + game.printToLog("[RACE] After filtering: ${filteredPlannedRaces.size} planned races and ${filteredRegularRaces.size} regular races remain.", tag = tag) + + // Combine all filtered races for Opportunity Cost analysis. + val allFilteredRaces = filteredPlannedRaces + filteredRegularRaces + if (allFilteredRaces.isEmpty()) { + game.printToLog("[RACE] No races match current settings after filtering. Canceling racing process.", tag = tag) + return false + } + + // Evaluate whether the bot should race now using Opportunity Cost logic. + if (!calculateOpportunityCost(allFilteredRaces, lookAheadDays)) { + game.printToLog("[RACE] Smart racing suggests waiting for better opportunities. Canceling racing process.", tag = tag) + return false + } + + // Decide which races to score based on availability. + val racesToScore = if (filteredPlannedRaces.isNotEmpty()) { + // Prefer planned races, but include regular races for comparison. + game.printToLog("[RACE] Prioritizing ${filteredPlannedRaces.size} planned races with ${filteredRegularRaces.size} regular races for comparison.", tag = tag) + filteredPlannedRaces + filteredRegularRaces + } else { + // No planned races available, use regular races only. + game.printToLog("[RACE] No planned races available, using ${filteredRegularRaces.size} regular races only.", tag = tag) + filteredRegularRaces + } + + // Score all eligible races with bonus for planned races. + val scoredRaces = racesToScore.map { race -> + val baseScore = calculateRaceScore(race) + if (plannedRaces.contains(race)) { + // Add a bonus for planned races. + val bonusScore = baseScore.copy(score = baseScore.score + 50.0) + game.printToLog("[RACE] Planned race \"${race.name}\" gets a bonus: ${game.decimalFormat.format(baseScore.score)} -> ${game.decimalFormat.format(bonusScore.score)}.", tag = tag) + bonusScore + } else { + baseScore + } + } + + // Sort by score and find the best race. + val sortedScoredRaces = scoredRaces.sortedByDescending { it.score } + val bestRace = sortedScoredRaces.first() + + game.printToLog("[RACE] Best race selected: ${bestRace.raceData.name} (score: ${game.decimalFormat.format(bestRace.score)}).", tag = tag) + if (plannedRaces.contains(bestRace.raceData)) { + game.printToLog("[RACE] Selected race is from user's planned races list.", tag = tag) + } else { + game.printToLog("[RACE] Selected race is from regular available races.", tag = tag) + } + + // Locates the best race on screen and selects it. + game.printToLog("[RACE] Looking for target race \"${bestRace.raceData.name}\" on screen...", tag = tag) + val targetRaceLocation = doublePredictionLocations.find { location -> + val raceName = game.imageUtils.extractRaceName(location) + val raceData = getRaceByTurnAndName(game.currentDate.turnNumber, raceName) + val matches = raceData?.name == bestRace.raceData.name + if (matches) game.printToLog("[RACE] ✓ Found target race at location (${location.x}, ${location.y}).", tag = tag) + matches + } ?: run { + game.printToLog("[RACE] Could not find target race \"${bestRace.raceData.name}\" on screen. Canceling racing process.", tag = tag) + return false + } + + game.printToLog("[RACE] Selecting smart racing choice: ${bestRace.raceData.name} (score: ${game.decimalFormat.format(bestRace.score)}).", tag = tag) + game.tap(targetRaceLocation.x, targetRaceLocation.y, "race_extra_double_prediction", ignoreWaiting = true) + + return true + } + + //////////////////////////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////////////////////////// + + /** + * Determines if extra race is eligible to be run based on various eligibility criteria. + * + * This function consolidates all eligibility checking logic including: + * - Force racing checks + * - Planned race eligibility (for years 1-2) + * - Opportunity cost analysis (via smart racing check) + * - Screen restrictions (locked, summer, UMA finals) + * - Day eligibility checks (optimal day, interval day, standard) + * + * @return True if extra race is eligible, false otherwise. + */ + fun isExtraRaceEligible(): Boolean { + val dayNumber = game.imageUtils.determineDayForExtraRace() + game.printToLog("[RACE] Current remaining number of days before the next mandatory race: $dayNumber.", tag = tag) + + // If the setting to force racing extra races is enabled, always return true. + if (enableForceRacing) return true + + // For years 1-2, check if planned races are eligible before proceeding. + if (game.currentDate.year == 2 && enableRacingPlan) { + val userPlannedRaces = getUserPlannedRaces() + if (userPlannedRaces.isNotEmpty()) { + val racePlanData = getRacePlanData() + if (racePlanData.isNotEmpty()) { + val currentTurnNumber = game.currentDate.turnNumber + + // Check each planned race for eligibility. + val eligiblePlannedRaces = userPlannedRaces.filter { plannedRace -> + isPlannedRaceEligible(plannedRace, racePlanData, dayNumber, currentTurnNumber) + } + + if (eligiblePlannedRaces.isEmpty()) { + game.printToLog("[RACE] No user-selected races are eligible at turn $currentTurnNumber.", tag = tag) + return false + } + + game.printToLog("[RACE] Found ${eligiblePlannedRaces.size} eligible user-selected races: ${eligiblePlannedRaces.map { it.raceName }}.", tag = tag) + } else { + game.printToLog("[RACE] No race plan data available for eligibility checking.", tag = tag) + return false + } + } else { + game.printToLog("[RACE] No user-selected races configured.", tag = tag) + return false + } + } + + // If fan requirement is detected, bypass smart racing logic to force racing. + if (hasFanRequirement) { + game.printToLog("[RACE] Fan requirement detected. Bypassing smart racing logic to fulfill requirement.", tag = tag) + } else if (enableRacingPlan && enableFarmingFans) { + // Smart racing: Check turn-based eligibility before screen checks. + // Only run opportunity cost analysis with smartRacingCheckInterval. + val isCheckInterval = game.currentDate.turnNumber % smartRacingCheckInterval == 0 + + if (isCheckInterval) { + game.printToLog("[RACE] Running opportunity cost analysis at turn ${game.currentDate.turnNumber} (smartRacingCheckInterval: every $smartRacingCheckInterval turns)...", tag = tag) + + val shouldRaceFromTurnCheck = shouldRaceSmartCheck(game.currentDate.turnNumber, dayNumber) + if (!shouldRaceFromTurnCheck) { + game.printToLog("[RACE] No suitable races at turn ${game.currentDate.turnNumber} based on opportunity cost analysis.", tag = tag) + return false + } + + game.printToLog("[RACE] Opportunity cost analysis completed, proceeding with screen checks...", tag = tag) + } else { + game.printToLog("[RACE] Skipping opportunity cost analysis (turn ${game.currentDate.turnNumber} does not match smartRacingCheckInterval). Using cached optimal race day.", tag = tag) + } + } + + // Check for common restrictions that apply to both smart and standard racing. + val isUmaFinalsLocked = game.imageUtils.findImage("race_select_extra_locked_uma_finals", tries = 1, region = game.imageUtils.regionBottomHalf).first != null + val isLocked = game.imageUtils.findImage("race_select_extra_locked", tries = 1, region = game.imageUtils.regionBottomHalf).first != null + val isSummer = game.imageUtils.findImage("recover_energy_summer", tries = 1, region = game.imageUtils.regionBottomHalf).first != null + + if (isUmaFinalsLocked) { + game.printToLog("[RACE] It is UMA Finals right now so there will be no extra races. Stopping extra race check.", tag = tag) + return false + } else if (isLocked) { + game.printToLog("[RACE] Extra Races button is currently locked. Stopping extra race check.", tag = tag) + return false + } else if (isSummer) { + game.printToLog("[RACE] It is currently Summer right now. Stopping extra race check.", tag = tag) + return false + } + + // For smart racing, if we got here, the turn-based check passed, so we should race. + // For standard racing, use the interval check. + // If fan requirement exists, always allow racing. + if (hasFanRequirement) { + game.printToLog("[RACE] Fan requirement detected. Allowing racing on any eligible day.", tag = tag) + return !raceRepeatWarningCheck + } else if (enableRacingPlan && enableFarmingFans) { + // Check if current day matches the optimal race day or falls on the interval. + val isOptimalDay = nextSmartRaceDay == dayNumber + val isIntervalDay = isEligibleRacingDay(dayNumber) + + if (isOptimalDay) { + game.printToLog("[RACE] Current day ($dayNumber) matches optimal race day.", tag = tag) + return !raceRepeatWarningCheck + } else if (isIntervalDay) { + game.printToLog("[RACE] Current day ($dayNumber) falls on racing interval ($daysToRunExtraRaces).", tag = tag) + return !raceRepeatWarningCheck + } else { + game.printToLog("[RACE] Current day ($dayNumber) is not optimal (next: $nextSmartRaceDay, interval: $daysToRunExtraRaces).", tag = tag) + return false + } + } + + // Standard racing logic. + return enableFarmingFans && isEligibleRacingDay(dayNumber) && !raceRepeatWarningCheck + } + + /** + * Handles extra races using the standard or traditional racing logic. + * + * This function performs the following steps: + * 1. Detects double-star races on screen. + * 2. If only one race has double predictions, selects it immediately. + * 3. Otherwise, iterates through each extra race to determine fan gain and double prediction status. + * 4. Evaluates which race to select based on maximum fans and double prediction priority (if force racing is enabled). + * 5. Selects the determined race on screen. + * + * @return True if a race was successfully selected; false if the process was canceled. + */ + private fun handleStandardRacing(): Boolean { + game.printToLog("[RACE] Using traditional racing logic for extra races...", tag = tag) + + // 1. Detects double-star races on screen. + val doublePredictionLocations = game.imageUtils.findAll("race_extra_double_prediction") + val maxCount = doublePredictionLocations.size + if (maxCount == 0) { + game.printToLog("[WARNING] No extra races found on screen. Canceling racing process.", tag = tag) + return false + } + + // 2. If only one race has double predictions, selects it immediately. + if (doublePredictionLocations.size == 1) { + game.printToLog("[RACE] Only one race with double predictions. Selecting it.", tag = tag) + game.tap(doublePredictionLocations[0].x, doublePredictionLocations[0].y, "race_extra_double_prediction", ignoreWaiting = true) + return true + } + + // 3. Otherwise, iterates through each extra race to determine fan gain and double prediction status. + val (sourceBitmap, templateBitmap) = game.imageUtils.getBitmaps("race_extra_double_prediction") + val listOfRaces = ArrayList() + val extraRaceLocations = ArrayList() + + for (count in 0 until maxCount) { + val selectedExtraRace = game.imageUtils.findImage("race_extra_selection", region = game.imageUtils.regionBottomHalf).first ?: break + extraRaceLocations.add(selectedExtraRace) + + val raceDetails = game.imageUtils.determineExtraRaceFans(selectedExtraRace, sourceBitmap, templateBitmap!!, forceRacing = enableForceRacing) + listOfRaces.add(raceDetails) + + if (count + 1 < maxCount) { + val nextX = if (game.imageUtils.isTablet) { + game.imageUtils.relX(selectedExtraRace.x, (-100 * 1.36).toInt()) + } else { + game.imageUtils.relX(selectedExtraRace.x, -100) + } + + val nextY = if (game.imageUtils.isTablet) { + game.imageUtils.relY(selectedExtraRace.y, (150 * 1.50).toInt()) + } else { + game.imageUtils.relY(selectedExtraRace.y, 150) + } + + game.tap(nextX.toDouble(), nextY.toDouble(), "race_extra_selection", ignoreWaiting = true) + } + + game.wait(0.5) + } + + // Determine max fans and select the appropriate race. + val maxFans = listOfRaces.maxOfOrNull { it.fans } ?: -1 + if (maxFans == -1) return false + game.printToLog("[RACE] Number of fans detected for each extra race are: ${listOfRaces.joinToString(", ") { it.fans.toString() }}", tag = tag) + + // 4. Evaluates which race to select based on maximum fans and double prediction priority (if force racing is enabled). + val index = if (!enableForceRacing) { + listOfRaces.indexOfFirst { it.fans == maxFans } + } else { + listOfRaces.indexOfFirst { it.hasDoublePredictions }.takeIf { it != -1 } ?: listOfRaces.indexOfFirst { it.fans == maxFans } + } + + // 5. Selects the determined race on screen. + game.printToLog("[RACE] Selecting extra race at option #${index + 1}.", tag = tag) + val target = extraRaceLocations[index] + game.tap(target.x - game.imageUtils.relWidth((100 * 1.36).toInt()), target.y - game.imageUtils.relHeight(70), "race_extra_selection", ignoreWaiting = true) + + return true + } + + /** + * The entry point for handling mandatory or extra races. + * + * @return True if the mandatory/extra race was completed successfully. Otherwise false. + */ + fun handleRaceEvents(): Boolean { + game.printToLog("\n********************", tag = tag) + game.printToLog("[RACE] Starting Racing process on ${game.printFormattedDate()}.", tag = tag) + if (encounteredRacingPopup) { + // Dismiss the insufficient fans popup here and head to the Race Selection screen. + game.findAndTapImage("race_confirm", tries = 1, region = game.imageUtils.regionBottomHalf) + encounteredRacingPopup = false + game.wait(1.0) + } + + // If there are no races available, cancel the racing process. + if (game.imageUtils.findImage("race_none_available", tries = 1, region = game.imageUtils.regionMiddle, suppressError = true).first != null) { + game.printToLog("[RACE] There are no races to compete in. Canceling the racing process and doing something else.", tag = tag) + game.printToLog("********************", tag = tag) + return false + } + + skipRacing = false + + // First, check if there is a mandatory or a extra race available. If so, head into the Race Selection screen. + // Note: If there is a mandatory race, the bot would be on the Home screen. + // Otherwise, it would have found itself at the Race Selection screen already (by way of the insufficient fans popup). + if (game.findAndTapImage("race_select_mandatory", tries = 1, region = game.imageUtils.regionBottomHalf)) { + game.printToLog("[RACE] Starting process for handling a mandatory race.", tag = tag) + + if (enableStopOnMandatoryRace) { + game.printToLog("********************", tag = tag) + detectedMandatoryRaceCheck = true + return false + } + + // If there is a popup warning about racing too many times, confirm the popup to continue as this is a mandatory race. + game.findAndTapImage("ok", tries = 1, region = game.imageUtils.regionMiddle, suppressError = true) + game.wait(1.0) + + // There is a mandatory race. Now confirm the selection and the resultant popup and then wait for the game to load. + game.wait(2.0) + game.printToLog("[RACE] Confirming the mandatory race selection.", tag = tag) + game.findAndTapImage("race_confirm", tries = 3, region = game.imageUtils.regionBottomHalf) + game.wait(1.0) + game.printToLog("[RACE] Confirming any popup from the mandatory race selection.", tag = tag) + game.findAndTapImage("race_confirm", tries = 3, region = game.imageUtils.regionBottomHalf) + game.wait(2.0) + + game.waitForLoading() + + // Handle race strategy override if enabled. + handleRaceStrategyOverride() + + // Skip the race if possible, otherwise run it manually. + val resultCheck: Boolean = if (game.imageUtils.findImage("race_skip_locked", tries = 5, region = game.imageUtils.regionBottomHalf).first == null) { + skipRace() + } else { + manualRace() + } + + finishRace(resultCheck) + + game.printToLog("[RACE] Racing process for Mandatory Race is completed.", tag = tag) + game.printToLog("********************", tag = tag) + return true + } else if (game.currentDate.phase != "Pre-Debut" && game.findAndTapImage("race_select_extra", tries = 1, region = game.imageUtils.regionBottomHalf)) { + game.printToLog("[RACE] Starting process for handling a extra race.", tag = tag) + + // If there is a popup warning about repeating races 3+ times, stop the process and do something else other than racing. + if (game.imageUtils.findImage("race_repeat_warning").first != null) { + if (!enableForceRacing) { + raceRepeatWarningCheck = true + game.printToLog("[RACE] Closing popup warning of doing more than 3+ races and setting flag to prevent racing for now. Canceling the racing process and doing something else.", tag = tag) + game.findAndTapImage("cancel", region = game.imageUtils.regionBottomHalf) + game.printToLog("********************", tag = tag) + return false + } else { + game.findAndTapImage("ok", tries = 1, region = game.imageUtils.regionMiddle) + game.wait(1.0) + } + } + + // There is a extra race. + val statusLocation = game.imageUtils.findImage("race_status").first + if (statusLocation == null) { + game.printToLog("[ERROR] Unable to determine existence of list of extra races. Canceling the racing process and doing something else.", tag = tag, isError = true) + game.printToLog("********************", tag = tag) + return false + } + + val maxCount = game.imageUtils.findAll("race_selection_fans", region = game.imageUtils.regionBottomHalf).size + if (maxCount == 0) { + game.printToLog("[WARNING] Was unable to find any extra races to select. Canceling the racing process and doing something else.", tag = tag, isError = true) + game.printToLog("********************", tag = tag) + return false + } else { + game.printToLog("[RACE] There are $maxCount extra race options currently on screen.", tag = tag) + } + + if (hasFanRequirement) game.printToLog("[RACE] Fan requirement criteria detected. This race must be completed to meet the fan requirement.", tag = tag) + + // Determine whether to use smart racing with user-selected races or standard racing. + val useSmartRacing = if (hasFanRequirement) { + // If fan requirement is needed, force standard racing to ensure the race proceeds. + false + } else if (game.currentDate.year == 3) { + // Year 3 (Senior Year): Use smart racing if conditions are met. + enableFarmingFans && !enableForceRacing && enableRacingPlan + } else { + // Year 2 (Classic Year): Use smart racing if conditions are met. + // The planned race eligibility check is now handled inside isExtraRaceEligible(). + // Year 1 (Junior Year) will use the standard racing logic. + game.currentDate.year == 2 && enableRacingPlan + } + + val success = if (useSmartRacing) { + if (game.currentDate.year == 3) { + game.printToLog("[RACE] Using smart racing for Senior Year.", tag = tag) + } else { + game.printToLog("[RACE] Using smart racing with user-selected races for Year ${game.currentDate.year}.", tag = tag) + } + handleSmartRacing() + } else { + // Use the standard racing logic. + // If needed, print the reason(s) to why the smart racing logic was not started. + if (enableRacingPlan && !hasFanRequirement) { + game.printToLog("[RACE] Smart racing conditions not met due to current settings, using traditional racing logic...", tag = tag) + game.printToLog("[RACE] Reason: One or more conditions failed:", tag = tag) + if (game.currentDate.year == 3) { + if (!enableFarmingFans) game.printToLog("[RACE] - enableFarmingFans is false", tag = tag) + if (enableForceRacing) game.printToLog("[RACE] - enableForceRacing is true", tag = tag) + } else if (game.currentDate.year == 1) { + game.printToLog("[RACE] - It is currently the Junior Year.", tag = tag) + } else { + game.printToLog("[RACE] - No eligible user-selected races found for Year ${game.currentDate.year}", tag = tag) + } + } + + handleStandardRacing() + } + + if (!success) return false + + // Confirm the selection and the resultant popup and then wait for the game to load. + game.findAndTapImage("race_confirm", tries = 30, region = game.imageUtils.regionBottomHalf) + game.findAndTapImage("race_confirm", tries = 10, region = game.imageUtils.regionBottomHalf) + game.wait(2.0) + + // Handle race strategy override if enabled. + handleRaceStrategyOverride() + + // Skip the race if possible, otherwise run it manually. + val resultCheck: Boolean = if (game.imageUtils.findImage("race_skip_locked", tries = 5, region = game.imageUtils.regionBottomHalf).first == null) { + skipRace() + } else { + manualRace() + } + + finishRace(resultCheck, isExtra = true) + + // Clear the next smart race day tracker since we just completed a race. + nextSmartRaceDay = null + + game.printToLog("[RACE] Racing process for Extra Race is completed.", tag = tag) + game.printToLog("********************", tag = tag) + return true + } + + game.printToLog("********************", tag = tag) + return false + } + + /** + * The entry point for handling standalone races if the user started the bot on the Racing screen. + */ + fun handleStandaloneRace() { + game.printToLog("\n********************", tag = tag) + game.printToLog("[RACE] Starting Standalone Racing process...", tag = tag) + + // Skip the race if possible, otherwise run it manually. + val resultCheck: Boolean = if (game.imageUtils.findImage("race_skip_locked", tries = 5, region = game.imageUtils.regionBottomHalf).first == null) { + skipRace() + } else { + manualRace() + } + + finishRace(resultCheck) + + game.printToLog("[RACE] Racing process for Standalone Race is completed.", tag = tag) + game.printToLog("********************", tag = tag) + } + + /** + * Skips the current race to get to the results screen. + * + * @return True if the bot completed the race with retry attempts remaining. Otherwise false. + */ + private fun skipRace(): Boolean { + while (raceRetries >= 0) { + game.printToLog("[RACE] Skipping race...", tag = tag) + + // Press the skip button and then wait for your result of the race to show. + if (game.findAndTapImage("race_skip", tries = 30, region = game.imageUtils.regionBottomHalf)) { + game.printToLog("[RACE] Race was able to be skipped.", tag = tag) + } + game.wait(2.0) + + // Now tap on the screen to get past the Race Result screen. + game.tap(350.0, 450.0, "ok", taps = 3) + + // Check if the race needed to be retried. + if (game.imageUtils.findImage("race_retry", tries = 5, region = game.imageUtils.regionBottomHalf, suppressError = true).first != null) { + if (disableRaceRetries) { + game.printToLog("\n[END] Stopping the bot due to failing a mandatory race.", tag = tag) + game.printToLog("********************", tag = tag) + game.notificationMessage = "Stopping the bot due to failing a mandatory race." + throw IllegalStateException() + } + game.findAndTapImage("race_retry", tries = 1, region = game.imageUtils.regionBottomHalf, suppressError = true) + game.printToLog("[RACE] The skipped race failed and needs to be run again. Attempting to retry...", tag = tag) + game.wait(3.0) + raceRetries-- + } else { + return true + } + } + + return false + } + + /** + * Manually runs the current race to get to the results screen. + * + * @return True if the bot completed the race with retry attempts remaining. Otherwise false. + */ + private fun manualRace(): Boolean { + while (raceRetries >= 0) { + game.printToLog("[RACE] Skipping manual race...", tag = tag) + + // Press the manual button. + if (game.findAndTapImage("race_manual", tries = 30, region = game.imageUtils.regionBottomHalf)) { + game.printToLog("[RACE] Started the manual race.", tag = tag) + } + game.wait(2.0) + + // Confirm the Race Playback popup if it appears. + if (game.findAndTapImage("ok", tries = 1, region = game.imageUtils.regionMiddle, suppressError = true)) { + game.printToLog("[RACE] Confirmed the Race Playback popup.", tag = tag) + game.wait(5.0) + } + + game.waitForLoading() + + // Now press the confirm button to get past the list of participants. + if (game.findAndTapImage("race_confirm", tries = 30, region = game.imageUtils.regionBottomHalf)) { + game.printToLog("[RACE] Dismissed the list of participants.", tag = tag) + } + game.waitForLoading() + game.wait(1.0) + game.waitForLoading() + game.wait(1.0) + + // Skip the part where it reveals the name of the race. + if (game.findAndTapImage("race_skip_manual", tries = 30, region = game.imageUtils.regionBottomHalf)) { + game.printToLog("[RACE] Skipped the name reveal of the race.", tag = tag) + } + // Skip the walkthrough of the starting gate. + if (game.findAndTapImage("race_skip_manual", tries = 30, region = game.imageUtils.regionBottomHalf)) { + game.printToLog("[RACE] Skipped the walkthrough of the starting gate.", tag = tag) + } + game.wait(3.0) + // Skip the start of the race. + if (game.findAndTapImage("race_skip_manual", tries = 30, region = game.imageUtils.regionBottomHalf)) { + game.printToLog("[RACE] Skipped the start of the race.", tag = tag) + } + // Skip the lead up to the finish line. + if (game.findAndTapImage("race_skip_manual", tries = 30, region = game.imageUtils.regionBottomHalf)) { + game.printToLog("[RACE] Skipped the lead up to the finish line.", tag = tag) + } + game.wait(2.0) + // Skip the result screen. + if (game.findAndTapImage("race_skip_manual", tries = 30, region = game.imageUtils.regionBottomHalf)) { + game.printToLog("[RACE] Skipped the results screen.", tag = tag) + } + game.wait(2.0) + + game.waitForLoading() + game.wait(1.0) + + // Check if the race needed to be retried. + if (game.imageUtils.findImage("race_retry", tries = 5, region = game.imageUtils.regionBottomHalf, suppressError = true).first != null) { + if (disableRaceRetries) { + game.printToLog("\n[END] Stopping the bot due to failing a mandatory race.", tag = tag) + game.printToLog("********************", tag = tag) + game.notificationMessage = "Stopping the bot due to failing a mandatory race." + throw IllegalStateException() + } + game.findAndTapImage("race_retry", tries = 1, region = game.imageUtils.regionBottomHalf, suppressError = true) + game.printToLog("[RACE] Manual race failed and needs to be run again. Attempting to retry...", tag = tag) + game.wait(5.0) + raceRetries-- + } else { + // Check if a Trophy was acquired. + if (game.findAndTapImage("race_accept_trophy", tries = 5, region = game.imageUtils.regionBottomHalf)) { + game.printToLog("[RACE] Closing popup to claim trophy...", tag = tag) + } + + return true + } + } + + return false + } + + /** + * Finishes up and confirms the results of the race and its success. + * + * @param resultCheck Flag to see if the race was completed successfully. Throws an IllegalStateException if it did not. + * @param isExtra Flag to determine the following actions to finish up this mandatory or extra race. + */ + fun finishRace(resultCheck: Boolean, isExtra: Boolean = false) { + game.printToLog("\n[RACE] Now performing cleanup and finishing the race.", tag = tag) + if (!resultCheck) { + game.notificationMessage = "Bot has run out of retry attempts for racing. Stopping the bot now..." + throw IllegalStateException() + } + + // Bot will be at the screen where it shows the final positions of all participants. + // Press the confirm button and wait to see the triangle of fans. + game.printToLog("[RACE] Now attempting to confirm the final positions of all participants and number of gained fans", tag = tag) + if (game.findAndTapImage("next", tries = 30, region = game.imageUtils.regionBottomHalf)) { + game.wait(0.5) + + // Now tap on the screen to get to the next screen. + game.tap(350.0, 750.0, "ok", taps = 3) + + // Now press the end button to finish the race. + game.findAndTapImage("race_end", tries = 30, region = game.imageUtils.regionBottomHalf) + + if (!isExtra) { + game.printToLog("[RACE] Seeing if a Training Goal popup will appear.", tag = tag) + // Wait until the popup showing the completion of a Training Goal appears and confirm it. + // There will be dialog before it so the delay should be longer. + game.wait(5.0) + if (game.findAndTapImage("next", tries = 10, region = game.imageUtils.regionBottomHalf)) { + game.wait(2.0) + + // Now confirm the completion of a Training Goal popup. + game.printToLog("[RACE] There was a Training Goal popup. Confirming it now.", tag = tag) + game.findAndTapImage("next", tries = 10, region = game.imageUtils.regionBottomHalf) + } + } else if (game.findAndTapImage("next", tries = 10, region = game.imageUtils.regionBottomHalf)) { + // Same as above but without the longer delay. + game.wait(2.0) + game.findAndTapImage("race_end", tries = 10, region = game.imageUtils.regionBottomHalf) + } + + firstTimeRacing = false + hasFanRequirement = false // Reset fan requirement flag after race completion. + } else { + game.printToLog("[ERROR] Cannot start the cleanup process for finishing the race. Moving on...", tag = tag, isError = true) + } + } + + /** + * Handles race strategy override for Junior Year races. + * + * During Junior Year: Applies the user-selected strategy and stores the original. + * After Junior Year: Restores the original strategy and disables the feature. + */ + private fun handleRaceStrategyOverride() { + if (!enableRaceStrategyOverride) { + return + } else if (enableRaceStrategyOverride && !firstTimeRacing && !hasAppliedStrategyOverride && game.currentDate.year != 1) { + return + } + + val currentYear = game.currentDate.year + game.printToLog("[RACE] Handling race strategy override for Year $currentYear.", tag = tag) + + // Check if we're on the racing screen by looking for the Change Strategy button. + if (!game.findAndTapImage("race_change_strategy", tries = 1, region = game.imageUtils.regionBottomHalf)) { + game.printToLog("[RACE] Change Strategy button not found. Skipping strategy override.", tag = tag) + return + } + + // Wait for the strategy selection popup to appear. + game.wait(2.0) + + // Find the confirm button to use as reference point for strategy coordinates. + val confirmLocation = game.imageUtils.findImage("confirm", region = game.imageUtils.regionBottomHalf).first + if (confirmLocation == null) { + game.printToLog("[ERROR] Could not find confirm button for strategy selection. Skipping strategy override.", tag = tag, isError = true) + game.findAndTapImage("cancel", region = game.imageUtils.regionMiddle) + return + } + + val baseX = confirmLocation.x.toInt() + val baseY = confirmLocation.y.toInt() + + if (currentYear == 1) { + // Junior Year: Apply user's selected strategy and detect the original. + if (!hasAppliedStrategyOverride) { + // Detect and store the original strategy. + val originalStrategy = detectOriginalStrategy() + if (originalStrategy != null) { + detectedOriginalStrategy = originalStrategy + game.printToLog("[RACE] Detected original race strategy: $originalStrategy", tag = tag) + } + + // Apply the user's selected strategy. + game.printToLog("[RACE] Applying user-selected strategy: $juniorYearRaceStrategy", tag = tag) + + if (modifyRacingStrategy(baseX, baseY, juniorYearRaceStrategy)) { + hasAppliedStrategyOverride = true + game.printToLog("[RACE] Successfully applied strategy override for Junior Year.", tag = tag) + } else { + game.printToLog("[ERROR] Failed to apply strategy override.", tag = tag, isError = true) + } + } + } else { + // Year 2+: Apply the detected original strategy if available, otherwise use user-selected strategy. + val strategyToApply = if (detectedOriginalStrategy != null) { + detectedOriginalStrategy!! + } else { + userSelectedOriginalStrategy + } + + game.printToLog("[RACE] Applying original race strategy: $strategyToApply", tag = tag) + + if (modifyRacingStrategy(baseX, baseY, strategyToApply)) { + hasAppliedStrategyOverride = false + game.printToLog("[RACE] Successfully applied original strategy. Strategy override disabled for rest of run.", tag = tag) + } else { + game.printToLog("[ERROR] Failed to apply original strategy.", tag = tag, isError = true) + } + } + + // Click confirm to apply the strategy change. + if (game.findAndTapImage("confirm", tries = 3, region = game.imageUtils.regionBottomHalf)) { + game.wait(2.0) + game.printToLog("[RACE] Strategy change confirmed.", tag = tag) + } else { + game.printToLog("[ERROR] Failed to confirm strategy change.", tag = tag, isError = true) + } + } + + /** + * Detects the original race strategy by searching for strategy indicators. + * + * @return The detected strategy name or null if not found. + */ + private fun detectOriginalStrategy(): String? { + val strategyImages = listOf( + "race_strategy_end" to "End", + "race_strategy_late" to "Late", + "race_strategy_pace" to "Pace", + "race_strategy_front" to "Front" + ) + + for ((imageName, strategyName) in strategyImages) { + if (game.imageUtils.findImage(imageName).first != null) { + return strategyName + } + } + + return null + } + + /** + * Clicks on a specific strategy button using coordinate offsets from the confirm button. + * + * @param baseX The X coordinate of the confirm button. + * @param baseY The Y coordinate of the confirm button. + * @param strategy The strategy to select ("Front", "Pace", "Late", "End"). + * @return True if the click was successful, false otherwise. + */ + private fun modifyRacingStrategy(baseX: Int, baseY: Int, strategy: String): Boolean { + val strategyOffsets = mapOf( + "end" to Pair(-585, -210), + "late" to Pair(-355, -210), + "pace" to Pair(-125, -210), + "front" to Pair(105, -210) + ) + + val offset = strategyOffsets[strategy.lowercase()] + if (offset == null) { + game.printToLog("[ERROR] Unknown strategy: $strategy", tag = tag, isError = true) + return false + } + + val targetX = (baseX + offset.first).toDouble() + val targetY = (baseY + offset.second).toDouble() + + game.printToLog("[RACE] Clicking strategy button at ($targetX, $targetY) for strategy: $strategy", tag = tag) + + return game.gestureUtils.tap(targetX, targetY) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/steve1316/uma_android_automation/bot/Training.kt b/android/app/src/main/java/com/steve1316/uma_android_automation/bot/Training.kt new file mode 100644 index 00000000..50ad2c65 --- /dev/null +++ b/android/app/src/main/java/com/steve1316/uma_android_automation/bot/Training.kt @@ -0,0 +1,876 @@ +package com.steve1316.uma_android_automation.bot + +import android.util.Log +import com.steve1316.uma_android_automation.MainActivity +import com.steve1316.uma_android_automation.utils.SettingsHelper +import com.steve1316.uma_android_automation.utils.CustomImageUtils +import com.steve1316.automation_library.data.SharedData +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.intArrayOf + +class Training(private val game: Game) { + private val tag: String = "[${MainActivity.loggerTag}]Training" + + data class TrainingOption( + val name: String, + val statGains: IntArray, + val failureChance: Int, + val relationshipBars: ArrayList, + val isRainbow: Boolean + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as TrainingOption + + if (failureChance != other.failureChance) return false + if (name != other.name) return false + if (!statGains.contentEquals(other.statGains)) return false + if (relationshipBars != other.relationshipBars) return false + if (isRainbow != other.isRainbow) return false + + return true + } + + override fun hashCode(): Int { + var result = failureChance + result = 31 * result + name.hashCode() + result = 31 * result + statGains.contentHashCode() + result = 31 * result + relationshipBars.hashCode() + result = 31 * result + isRainbow.hashCode() + return result + } + } + + private val trainings: List = listOf("Speed", "Stamina", "Power", "Guts", "Wit") + private val trainingMap: MutableMap = mutableMapOf() + var currentStatsMap: MutableMap = mutableMapOf( + "Speed" to 0, + "Stamina" to 0, + "Power" to 0, + "Guts" to 0, + "Wit" to 0 + ) + private val blacklist: List = SettingsHelper.getStringArraySetting("training", "trainingBlacklist") + private val statPrioritizationRaw = SettingsHelper.getStringArraySetting("training", "statPrioritization") + val statPrioritization: List = if (!statPrioritizationRaw.isEmpty()) { + statPrioritizationRaw + } else { + listOf("Speed", "Stamina", "Power", "Wit", "Guts") + } + private val maximumFailureChance: Int = SettingsHelper.getIntSetting("training", "maximumFailureChance") + private val disableTrainingOnMaxedStat: Boolean = SettingsHelper.getBooleanSetting("training", "disableTrainingOnMaxedStat") + private val focusOnSparkStatTarget: Boolean = SettingsHelper.getBooleanSetting("training", "focusOnSparkStatTarget") + private val enableRainbowTrainingBonus: Boolean = SettingsHelper.getBooleanSetting("training", "enableRainbowTrainingBonus") + private val preferredDistanceOverride: String = SettingsHelper.getStringSetting("training", "preferredDistanceOverride") + private val enableRiskyTraining: Boolean = SettingsHelper.getBooleanSetting("training", "enableRiskyTraining") + private val riskyTrainingMinStatGain: Int = SettingsHelper.getIntSetting("training", "riskyTrainingMinStatGain") + private val riskyTrainingMaxFailureChance: Int = SettingsHelper.getIntSetting("training", "riskyTrainingMaxFailureChance") + private val statTargetsByDistance: MutableMap = mutableMapOf( + "Sprint" to intArrayOf(0, 0, 0, 0, 0), + "Mile" to intArrayOf(0, 0, 0, 0, 0), + "Medium" to intArrayOf(0, 0, 0, 0, 0), + "Long" to intArrayOf(0, 0, 0, 0, 0) + ) + var preferredDistance: String = "" + var firstTrainingCheck = true + private val currentStatCap = 1200 + + //////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////// + + /** + * Updates the preferred distance based on character aptitudes or manual override setting. + * + * Priority order for automatic determination: + * 1. Check all distances for S aptitude first (Sprint → Mile → Medium → Long) + * 2. If no S aptitude found, check for A aptitude in same order + * 3. If no S or A aptitude found, default to "Medium" + */ + fun updatePreferredDistance() { + game.printToLog("\n[TRAINING] Updating preferred distance...", tag = tag) + + // If manual override is set and not "Auto", use the manual value. + if (preferredDistanceOverride != "Auto") { + preferredDistance = preferredDistanceOverride + game.printToLog("[TRAINING] Using manual override: $preferredDistance.", tag = tag) + return + } + + // Automatic determination based on aptitudes. + val aptitudes = game.aptitudes.distance + + // First, check all distances for S aptitude. + if (aptitudes.sprint == "S") { + preferredDistance = "Sprint" + } else if (aptitudes.mile == "S") { + preferredDistance = "Mile" + } else if (aptitudes.medium == "S") { + preferredDistance = "Medium" + } else if (aptitudes.long == "S") { + preferredDistance = "Long" + } + // Then check for A aptitude if no S found. + else if (aptitudes.sprint == "A") { + preferredDistance = "Sprint" + } else if (aptitudes.mile == "A") { + preferredDistance = "Mile" + } else if (aptitudes.medium == "A") { + preferredDistance = "Medium" + } else if (aptitudes.long == "A") { + preferredDistance = "Long" + } + // Default fallback if no S or A aptitude found. + else { + preferredDistance = "Medium" + } + + game.printToLog("[TRAINING] Determined preferred distance: $preferredDistance (Sprint: ${aptitudes.sprint}, Mile: ${aptitudes.mile}, Medium: ${aptitudes.medium}, Long: ${aptitudes.long})", tag = tag) + } + + /** + * Sets up stat targets for different race distances by reading values from SQLite settings. These targets are used to determine training priorities based on the expected race distance. + */ + fun setStatTargetsByDistances() { + val sprintSpeedTarget = SettingsHelper.getIntSetting("trainingStatTarget", "trainingSprintStatTarget_speedStatTarget") + val sprintStaminaTarget = SettingsHelper.getIntSetting("trainingStatTarget", "trainingSprintStatTarget_staminaStatTarget") + val sprintPowerTarget = SettingsHelper.getIntSetting("trainingStatTarget", "trainingSprintStatTarget_powerStatTarget") + val sprintGutsTarget = SettingsHelper.getIntSetting("trainingStatTarget", "trainingSprintStatTarget_gutsStatTarget") + val sprintWitTarget = SettingsHelper.getIntSetting("trainingStatTarget", "trainingSprintStatTarget_witStatTarget") + + val mileSpeedTarget = SettingsHelper.getIntSetting("trainingStatTarget", "trainingMileStatTarget_speedStatTarget") + val mileStaminaTarget = SettingsHelper.getIntSetting("trainingStatTarget", "trainingMileStatTarget_staminaStatTarget") + val milePowerTarget = SettingsHelper.getIntSetting("trainingStatTarget", "trainingMileStatTarget_powerStatTarget") + val mileGutsTarget = SettingsHelper.getIntSetting("trainingStatTarget", "trainingMileStatTarget_gutsStatTarget") + val mileWitTarget = SettingsHelper.getIntSetting("trainingStatTarget", "trainingMileStatTarget_witStatTarget") + + val mediumSpeedTarget = SettingsHelper.getIntSetting("trainingStatTarget", "trainingMediumStatTarget_speedStatTarget") + val mediumStaminaTarget = SettingsHelper.getIntSetting("trainingStatTarget", "trainingMediumStatTarget_staminaStatTarget") + val mediumPowerTarget = SettingsHelper.getIntSetting("trainingStatTarget", "trainingMediumStatTarget_powerStatTarget") + val mediumGutsTarget = SettingsHelper.getIntSetting("trainingStatTarget", "trainingMediumStatTarget_gutsStatTarget") + val mediumWitTarget = SettingsHelper.getIntSetting("trainingStatTarget", "trainingMediumStatTarget_witStatTarget") + + val longSpeedTarget = SettingsHelper.getIntSetting("trainingStatTarget", "trainingLongStatTarget_speedStatTarget") + val longStaminaTarget = SettingsHelper.getIntSetting("trainingStatTarget", "trainingLongStatTarget_staminaStatTarget") + val longPowerTarget = SettingsHelper.getIntSetting("trainingStatTarget", "trainingLongStatTarget_powerStatTarget") + val longGutsTarget = SettingsHelper.getIntSetting("trainingStatTarget", "trainingLongStatTarget_gutsStatTarget") + val longWitTarget = SettingsHelper.getIntSetting("trainingStatTarget", "trainingLongStatTarget_witStatTarget") + + // Set the stat targets for each distance type. + // Order: Speed, Stamina, Power, Guts, Wit + statTargetsByDistance["Sprint"] = intArrayOf(sprintSpeedTarget, sprintStaminaTarget, sprintPowerTarget, sprintGutsTarget, sprintWitTarget) + statTargetsByDistance["Mile"] = intArrayOf(mileSpeedTarget, mileStaminaTarget, milePowerTarget, mileGutsTarget, mileWitTarget) + statTargetsByDistance["Medium"] = intArrayOf(mediumSpeedTarget, mediumStaminaTarget, mediumPowerTarget, mediumGutsTarget, mediumWitTarget) + statTargetsByDistance["Long"] = intArrayOf(longSpeedTarget, longStaminaTarget, longPowerTarget, longGutsTarget, longWitTarget) + } + + /** + * Handles the test to perform OCR on the current training on display for stat gains and failure chance. + */ + fun startSingleTrainingOCRTest() { + game.printToLog("\n[TEST] Now beginning Single Training OCR test on the Training screen for the current training on display.", tag = tag) + game.printToLog("[TEST] Note that this test is dependent on having the correct scale.", tag = tag) + analyzeTrainings(test = true, singleTraining = true) + printTrainingMap() + } + + /** + * Handles the test to perform OCR on all 5 trainings on display for stat gains and failure chances. + */ + fun startComprehensiveTrainingOCRTest() { + game.printToLog("\n[TEST] Now beginning Comprehensive Training OCR test on the Training screen for all 5 trainings on display.", tag = tag) + game.printToLog("[TEST] Note that this test is dependent on having the correct scale.", tag = tag) + analyzeTrainings(test = true) + printTrainingMap() + } + + //////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////// + + /** + * The entry point for handling Training. + */ + fun handleTraining() { + game.printToLog("\n********************", tag = tag) + game.printToLog("[TRAINING] Starting Training process on ${game.printFormattedDate()}.", tag = tag) + + // Enter the Training screen. + if (game.findAndTapImage("training_option", region = game.imageUtils.regionBottomHalf)) { + // Acquire the percentages and stat gains for each training. + game.wait(0.5) + analyzeTrainings() + + if (trainingMap.isEmpty()) { + game.printToLog("[TRAINING] Backing out of Training and returning on the Main screen.", tag = tag) + game.findAndTapImage("back", region = game.imageUtils.regionBottomHalf) + game.wait(1.0) + + if (game.checkMainScreen()) { + game.printToLog("[TRAINING] Will recover energy due to either failure chance was high enough to do so or no failure chances were detected via OCR.", tag = tag) + game.recoverEnergy() + } else { + game.printToLog("[ERROR] Could not head back to the Main screen in order to recover energy.", tag = tag) + } + } else { + // Now select the training option with the highest weight. + executeTraining() + firstTrainingCheck = false + } + + game.racing.raceRepeatWarningCheck = false + game.printToLog("[TRAINING] Training process completed.", tag = tag) + } else { + game.printToLog("[ERROR] Cannot start the Training process. Moving on...", tag = tag, isError = true) + } + game.printToLog("********************", tag = tag) + } + + /** + * Analyze all 5 Trainings for their details including stat gains, relationship bars, etc. + * + * @param test Flag that forces the failure chance through even if it is not in the acceptable range for testing purposes. + * @param singleTraining Flag that forces only singular training analysis for the current training on the screen. + */ + private fun analyzeTrainings(test: Boolean = false, singleTraining: Boolean = false) { + if (singleTraining) game.printToLog("\n[TRAINING] Now starting process to analyze the training on screen.", tag = tag) + else game.printToLog("\n[TRAINING] Now starting process to analyze all 5 Trainings.", tag = tag) + + // Acquire the position of the speed stat text. + val (speedStatTextLocation, _) = if (game.campaign == "Ao Haru") { + game.imageUtils.findImage("aoharu_stat_speed", tries = 1, region = game.imageUtils.regionBottomHalf) + } else { + game.imageUtils.findImage("stat_speed", tries = 1, region = game.imageUtils.regionBottomHalf) + } + + if (speedStatTextLocation != null) { + // Perform a percentage check of Speed training to see if the bot has enough energy to do training. As a result, Speed training will be the one selected for the rest of the algorithm. + if (!singleTraining && game.imageUtils.findImage("speed_training_header", tries = 1, region = game.imageUtils.regionTopHalf, suppressError = true).first == null) { + game.findAndTapImage("training_speed", region = game.imageUtils.regionBottomHalf) + game.wait(0.5) + } + + val failureChance: Int = game.imageUtils.findTrainingFailureChance() + if (failureChance == -1) { + game.printToLog("[WARNING] Skipping training due to not being able to confirm whether or not the bot is at the Training screen.", tag = tag) + return + } + + if (test || failureChance <= maximumFailureChance) { + if (!test) game.printToLog("[TRAINING] $failureChance% within acceptable range of ${maximumFailureChance}%. Proceeding to acquire all other percentages and total stat increases...", tag = tag) + + // Iterate through every training that is not blacklisted. + for ((index, training) in trainings.withIndex()) { + if (!test && blacklist.getOrElse(index) { "" } == training) { + game.printToLog("[TRAINING] Skipping $training training due to being blacklisted.", tag = tag) + continue + } + + if (singleTraining) { + if (game.imageUtils.findImage("${training.lowercase()}_training_header", tries = 1, region = game.imageUtils.regionTopHalf, suppressError = true).first == null) { + // Keep iterating until the current training is found. + continue + } + game.printToLog("[TRAINING] The $training training is currently selected on the screen.", tag = tag) + } + + // Select the Training to make it active except Speed Training since that is already selected at the start. + val newX: Double = when (training) { + "Stamina" -> { + 280.0 + } + "Power" -> { + 402.0 + } + "Guts" -> { + 591.0 + } + "Wit" -> { + 779.0 + } + else -> { + 0.0 + } + } + + if (newX != 0.0 && !singleTraining) { + if (game.imageUtils.isTablet) { + if (training == "Stamina") { + game.tap( + speedStatTextLocation.x + game.imageUtils.relWidth((newX * 1.05).toInt()), + speedStatTextLocation.y + game.imageUtils.relHeight((319 * 1.50).toInt()), + "training_option_circular", + ignoreWaiting = true + ) + } else { + game.tap( + speedStatTextLocation.x + game.imageUtils.relWidth((newX * 1.36).toInt()), + speedStatTextLocation.y + game.imageUtils.relHeight((319 * 1.50).toInt()), + "training_option_circular", + ignoreWaiting = true + ) + } + } else { + game.tap( + speedStatTextLocation.x + game.imageUtils.relWidth(newX.toInt()), + speedStatTextLocation.y + game.imageUtils.relHeight(319), + "training_option_circular", + ignoreWaiting = true + ) + } + } + + // Update the object in the training map. + // Use CountDownLatch to run the 4 operations in parallel to cut down on processing time. + val latch = CountDownLatch(4) + + // Variables to store results from parallel threads. + var statGains: IntArray = intArrayOf() + var failureChance: Int = -1 + var relationshipBars: ArrayList = arrayListOf() + var isRainbow = false + + // Get the Points and source Bitmap beforehand before starting the threads to make them safe for parallel processing. + val (skillPointsLocation, sourceBitmap) = game.imageUtils.findImage("skill_points", tries = 1, region = game.imageUtils.regionMiddle) + val (trainingSelectionLocation, _) = game.imageUtils.findImage("training_failure_chance", tries = 1, region = game.imageUtils.regionBottomHalf) + + // Thread 1: Determine stat gains. + Thread { + try { + statGains = game.imageUtils.determineStatGainFromTraining(training, sourceBitmap, skillPointsLocation!!) + } catch (e: Exception) { + Log.e(tag, "[ERROR] Error in determineStatGainFromTraining: ${e.stackTraceToString()}") + statGains = intArrayOf(0, 0, 0, 0, 0) + } finally { + latch.countDown() + } + }.start() + + // Thread 2: Find failure chance. + Thread { + try { + failureChance = game.imageUtils.findTrainingFailureChance(sourceBitmap, trainingSelectionLocation!!) + } catch (e: Exception) { + game.printToLog("[ERROR] Error in findTrainingFailureChance: ${e.stackTraceToString()}", tag = tag, isError = true) + failureChance = -1 + } finally { + latch.countDown() + } + }.start() + + // Thread 3: Analyze relationship bars. + Thread { + try { + relationshipBars = game.imageUtils.analyzeRelationshipBars(sourceBitmap) + } catch (e: Exception) { + Log.e(tag, "[ERROR] Error in analyzeRelationshipBars: ${e.stackTraceToString()}") + relationshipBars = arrayListOf() + } finally { + latch.countDown() + } + }.start() + + // Thread 4: Detect rainbow training. + Thread { + try { + isRainbow = game.imageUtils.findImage("training_rainbow", tries = 2, confidence = 0.9, suppressError = true, region = game.imageUtils.regionBottomHalf).first != null + } catch (e: Exception) { + Log.e(tag, "[ERROR] Error in rainbow detection: ${e.stackTraceToString()}") + isRainbow = false + } finally { + latch.countDown() + } + }.start() + try { + latch.await(10, TimeUnit.SECONDS) + } catch (_: InterruptedException) { + Log.e(tag, "[ERROR] Parallel training analysis timed out") + } finally { + game.printToLog("[INFO] All 5 stat regions processed for $training training. Results: ${statGains.toList()}", tag = tag) + } + + // Check if risky training logic should apply based on main stat gain. + // The main stat gain for each training type corresponds to its index in the statGains array. + val mainStatGain = statGains[index] + val effectiveFailureChance = if (enableRiskyTraining && mainStatGain >= riskyTrainingMinStatGain) { + riskyTrainingMaxFailureChance + } else { + maximumFailureChance + } + + // Filter out trainings that exceed the effective failure chance threshold. + if (!test && failureChance > effectiveFailureChance) { + if (enableRiskyTraining && mainStatGain >= riskyTrainingMinStatGain) { + game.printToLog("[TRAINING] Skipping $training training due to failure chance ($failureChance%) exceeding risky threshold (${riskyTrainingMaxFailureChance}%) despite high main stat gain of $mainStatGain.", tag = tag) + } else { + game.printToLog("[TRAINING] Skipping $training training due to failure chance ($failureChance%) exceeding threshold (${maximumFailureChance}%).", tag = tag) + } + continue + } + + val newTraining = TrainingOption( + name = training, + statGains = statGains, + failureChance = failureChance, + relationshipBars = relationshipBars, + isRainbow = isRainbow + ) + trainingMap.put(training, newTraining) + if (singleTraining) { + break + } + } + + if (singleTraining) { + game.printToLog("[TRAINING] Process to analyze the singular Training complete.", tag = tag) + } else { + game.printToLog("[TRAINING] Process to analyze all 5 Trainings complete.", tag = tag) + } + } else { + // Clear the Training map if the bot failed to have enough energy to conduct the training. + game.printToLog("[TRAINING] $failureChance% is not within acceptable range of ${maximumFailureChance}%. Proceeding to recover energy.", tag = tag) + trainingMap.clear() + } + } + } + + /** + * Recommends the best training option based on ratio completion toward stat targets. + * + * This function implements a ratio-based training recommendation system that treats + * stat targets as desired distribution rather than sequential goals. + * + * **Early Game (Pre-Debut/Year 1):** + * - Focuses on relationship building using `scoreFriendshipTraining()` + * - Prioritizes training options that build friendship bars, especially blue bars + * - Ignores stat gains in favor of relationship development + * + * **Mid/Late Game (Year 2+):** + * - Uses ratio-based scoring via `scoreStatTraining()` + * - Scores based on completion percentage (currentStat / targetStat) + * - Trains stats furthest behind their target ratio + * - Priority order only breaks ties when completion percentages are similar + * + * The scoring system considers multiple factors: + * - **Ratio Completion:** How far each stat is toward its target percentage (primary driver) + * - **Priority Tiebreaker:** Only matters when stats have similar completion percentages + * - **Main Stat Bonus:** High gains on main stat get bonus (likely undetected rainbow) + * - **Rainbow Detection:** Heavily favored for overall ratio balance + * - **Late Game Stamina:** Ensures 600+ stamina in Year 3 + * + * @return The name of the recommended training option, or empty string if no suitable option found. + */ + private fun recommendTraining(): String { + /** + * Scores the currently selected training option during Junior Year based on friendship bar progress. + * + * This algorithm prefers training options with the least relationship progress (especially blue bars). + * It ignores stat gains unless all else is equal. + * + * @param training The training option to evaluate. + * + * @return A score representing relationship-building value. + */ + fun scoreFriendshipTraining(training: TrainingOption): Double { + // Ignore the blacklist in favor of making sure we build up the relationship bars as fast as possible. + game.printToLog("\n[TRAINING] Starting process to score ${training.name} Training with a focus on building relationship bars.", tag = tag) + + val barResults = training.relationshipBars + if (barResults.isEmpty()) return Double.NEGATIVE_INFINITY + + var score = 0.0 + for (bar in barResults) { + val contribution = when (bar.dominantColor) { + "orange" -> 0.0 + "green" -> 1.0 + "blue" -> 2.5 + else -> 0.0 + } + score += contribution + } + + game.printToLog("[TRAINING] ${training.name} Training has a score of ${game.decimalFormat.format(score)} with a focus on building relationship bars.") + return score + } + + /** + * Calculates stat efficiency based on ratio completion toward targets. + * + * This function treats stat targets as desired ratios rather than sequential goals. + * It scores training based on how well it balances the overall stat distribution. + * + * Key principles: + * - Stats furthest behind their target ratio get highest priority + * - Completion percentage = currentStat / targetStat + * - Priority order only breaks ties when completion percentages are similar + * - High main stat gains receive bonus (likely undetected rainbow) + * + * @param training The training option to evaluate. + * @param target Array of target stat values representing desired ratio. + * + * @return Raw score representing stat efficiency (will be normalized later). + */ + fun calculateStatEfficiencyScore(training: TrainingOption, target: IntArray): Double { + var score = 0.0 + + for ((index, stat) in trainings.withIndex()) { + val currentStat = currentStatsMap.getOrDefault(stat, 0) + val targetStat = target.getOrElse(index) { 0 } + val statGain = training.statGains.getOrElse(index) { 0 } + + if (statGain > 0 && targetStat > 0) { + val priorityIndex = statPrioritization.indexOf(stat) + + // Calculate completion percentage (how far along this stat is toward its target). + val completionPercent = (currentStat.toDouble() / targetStat) * 100.0 + + // Ratio-based multiplier: Stats furthest behind get highest priority. + val ratioMultiplier = when { + completionPercent < 30.0 -> 5.0 // Severely behind. + completionPercent < 50.0 -> 4.0 // Significantly behind. + completionPercent < 70.0 -> 3.0 // Moderately behind. + completionPercent < 90.0 -> 2.0 // Slightly behind. + completionPercent < 110.0 -> 1.0 // At target. + completionPercent < 130.0 -> 0.5 // Slightly over. + else -> 0.3 // Well over. + } + + // Priority-based tiebreaker (only applies when completion is similar). + // Find the completion percentage of the highest priority stat for comparison. + val highestPriorityStat = statPrioritization.firstOrNull() ?: stat + val highestPriorityIndex = trainings.indexOf(highestPriorityStat) + val highestPriorityCompletion = if (highestPriorityIndex != -1) { + val hpCurrent = currentStatsMap.getOrDefault(highestPriorityStat, 0) + val hpTarget = target.getOrElse(highestPriorityIndex) { 1 } + (hpCurrent.toDouble() / hpTarget) * 100.0 + } else { + completionPercent + } + + // Only apply priority bonus if this stat's completion is within 10% of highest priority stat. + val priorityMultiplier = if (priorityIndex != -1 && kotlin.math.abs(completionPercent - highestPriorityCompletion) <= 10.0) { + 1.0 + (0.1 * (statPrioritization.size - priorityIndex)) + } else { + 1.0 + } + + // Main stat gain bonus: If training improves its MAIN stat by a large amount, it is most likely an undetected rainbow. + val isMainStat = training.name == stat + val mainStatBonus = if (isMainStat && statGain >= 30) { + 2.0 + } else { + 1.0 + } + + // Special case: Ensure Stamina is at least 600 in late game. + val isLateGame = game.currentDate.year == 3 + val isStamina = stat == "Stamina" + val staminaBelowMinimum = isStamina && currentStat < 600 + val lateGameStaminaBonus = if (isLateGame && staminaBelowMinimum) { + game.printToLog("[TRAINING] Stamina of $currentStat is currently less than 600 so bringing its score higher for Senior Year.", tag = tag) + 2.0 + } else 1.0 + + if (game.debugMode) { + val bonusNote = if (isMainStat && statGain >= 30) " [HIGH MAIN STAT]" else "" + val staminaNote = if (isLateGame && staminaBelowMinimum) " [LATE GAME MINIMUM]" else "" + game.printToLog("[DEBUG] $stat: gain=$statGain, completion=${game.decimalFormat.format(completionPercent)}%, " + + "ratioMult=${game.decimalFormat.format(ratioMultiplier)}, priorityMult=${game.decimalFormat.format(priorityMultiplier)}$bonusNote$staminaNote", + tag = tag + ) + } else { + Log.d(tag, "[DEBUG] $stat: gain=$statGain, completion=${game.decimalFormat.format(completionPercent)}%, ratioMult=$ratioMultiplier, priorityMult=$priorityMultiplier") + } + + // Calculate final score for this stat. + var statScore = statGain.toDouble() + statScore *= ratioMultiplier + statScore *= priorityMultiplier + statScore *= mainStatBonus + statScore *= lateGameStaminaBonus + + score += statScore + } + } + + return score + } + + /** + * Calculates relationship building score with diminishing returns. + * + * Evaluates the value of relationship bars based on their color and fill level: + * - Blue bars: 2.5 points (highest priority) + * - Green bars: 1.0 points (medium priority) + * - Orange bars: 0.0 points (no value) + * + * Applies diminishing returns as bars fill up and early game bonuses for relationship building. + * + * @param training The training option to evaluate. + * + * @return A normalized score (0-100) representing relationship building value. + */ + fun calculateRelationshipScore(training: TrainingOption): Double { + if (training.relationshipBars.isEmpty()) return 0.0 + + var score = 0.0 + var maxScore = 0.0 + + for (bar in training.relationshipBars) { + val baseValue = when (bar.dominantColor) { + "orange" -> 0.0 + "green" -> 1.0 + "blue" -> 2.5 + else -> 0.0 + } + + if (baseValue > 0) { + // Apply diminishing returns for relationship building. + val fillLevel = bar.fillPercent / 100.0 + val diminishingFactor = 1.0 - (fillLevel * 0.5) // Less valuable as bars fill up. + + // Early game bonus for relationship building. + val earlyGameBonus = if (game.currentDate.year == 1 || game.currentDate.phase == "Pre-Debut") 1.3 else 1.0 + + val contribution = baseValue * diminishingFactor * earlyGameBonus + score += contribution + maxScore += 2.5 * 1.3 + } + } + + return if (maxScore > 0) (score / maxScore * 100.0) else 0.0 + } + + /** + * Calculates miscellaneous bonuses and penalties based on training properties. + * + * Applies bonuses for skill hints that provide additional value to training sessions. + * Removed complex phase bonuses to avoid conflicts with target-based scoring. + * + * @param training The training option to evaluate. + * + * @return A misc score between 0-100 representing situational bonuses. + */ + fun calculateMiscScore(training: TrainingOption): Double { + // Start with neutral score. + var score = 50.0 + + // Bonuses for skill hints. + val skillHintLocations = game.imageUtils.findAll( + "stat_skill_hint", + region = intArrayOf( + SharedData.displayWidth - (SharedData.displayWidth / 3), + 0, + (SharedData.displayWidth / 3), + SharedData.displayHeight - (SharedData.displayHeight / 3) + ) + ) + if (skillHintLocations.isNotEmpty()) { + game.printToLog("[TRAINING] Skill hint(s) detected for ${training.name} Training.", tag = tag) + } + score += 10.0 * skillHintLocations.size + + return score.coerceIn(0.0, 100.0) + } + + /** + * Performs comprehensive scoring of training options using ratio-based evaluation. + * + * This scoring system combines multiple components: + * - Stat efficiency: Ratio completion toward target distribution + * - Relationship building: Value of friendship bar progress + * - Context bonuses: Skill hints and situational bonuses + * - Rainbow multiplier: Multiplies the score based on the existence of a rainbow training and whether rainbow training bonus is enabled. + * + * @param training The training option to evaluate. + * + * @return A score (0-100) representing overall training value. + */ + fun scoreStatTraining(training: TrainingOption): Double { + if (training.name in blacklist) return 0.0 + + // Don't score for stats that are maxed or would be maxed. + if ((disableTrainingOnMaxedStat && currentStatsMap[training.name]!! >= currentStatCap) || + (currentStatsMap.getOrDefault(training.name, 0) + training.statGains[trainings.indexOf(training.name)] >= currentStatCap)) { + return 0.0 + } + + game.printToLog("\n[TRAINING] Starting scoring for ${training.name} Training.", tag = tag) + + val target = statTargetsByDistance[preferredDistance] ?: intArrayOf(600, 600, 600, 300, 300) + + var totalScore = 0.0 + + // 1. Stat Efficiency scoring + val statScore = calculateStatEfficiencyScore(training, target) + + // 2. Friendship scoring + val relationshipScore = calculateRelationshipScore(training) + + // 3. Misc-aware scoring + val miscScore = calculateMiscScore(training) + + // Define scoring weights based on relationship bars presence. + val statWeight = if (training.relationshipBars.isNotEmpty()) 0.4 else 0.6 + val relationshipWeight = if (training.relationshipBars.isNotEmpty()) 0.3 else 0.0 + val miscWeight = 0.4 + + // Calculate weighted total score. + totalScore += statScore * statWeight + totalScore += relationshipScore * relationshipWeight + totalScore += miscScore * miscWeight + + // 4. Rainbow training multiplier (Year 2+ only). + // Rainbow is heavily favored because it improves overall ratio balance. + val rainbowMultiplier = if (training.isRainbow && game.currentDate.year >= 2) { + if (enableRainbowTrainingBonus) { + game.printToLog("[TRAINING] ${training.name} Training is detected as a rainbow training.", tag = tag) + 2.0 + } else { + game.printToLog("[TRAINING] ${training.name} Training is detected as a rainbow training, but rainbow training bonus is not enabled.", tag = tag) + 1.5 + } + } else { + game.printToLog("[TRAINING] ${training.name} Training is not detected as a rainbow training.", tag = tag) + 1.0 + } + + // Apply rainbow multiplier to total score. + totalScore *= rainbowMultiplier + + game.printToLog( + "[TRAINING] Scores | Current Stat: ${currentStatsMap[training.name]}, Target Stat: ${target[trainings.indexOf(training.name)]}, " + + "Stat Efficiency: ${game.decimalFormat.format(statScore)}, Relationship: ${game.decimalFormat.format(relationshipScore)}, " + + "Misc: ${game.decimalFormat.format(miscScore)}, Rainbow Multiplier: ${game.decimalFormat.format(rainbowMultiplier)}", + tag = tag + ) + + val finalScore = totalScore.coerceIn(0.0, 100.0) + + game.printToLog("[TRAINING] Enhanced final score for ${training.name} Training: ${game.decimalFormat.format(finalScore)}/100.0", tag = tag) + + return finalScore + } + + /** + * Calculates raw training score without normalization. + * + * This function contains the same logic as scoreStatTraining but returns raw scores + * that will be normalized based on the actual maximum score in the current session. + * + * @param training The training option to evaluate. + * + * @return Raw score representing overall training value. + */ + fun calculateRawTrainingScore(training: TrainingOption): Double { + if (training.name in blacklist) return 0.0 + + // Don't score for stats that are maxed or would be maxed. + if ((disableTrainingOnMaxedStat && currentStatsMap[training.name]!! >= currentStatCap) || + (currentStatsMap.getOrDefault(training.name, 0) + training.statGains[trainings.indexOf(training.name)] >= currentStatCap)) { + return 0.0 + } + + val target = statTargetsByDistance[preferredDistance] ?: intArrayOf(600, 600, 600, 300, 300) + + var totalScore = 0.0 + + // 1. Stat Efficiency scoring + val statScore = calculateStatEfficiencyScore(training, target) + + // 2. Friendship scoring + val relationshipScore = calculateRelationshipScore(training) + + // 3. Misc-aware scoring + val miscScore = calculateMiscScore(training) + + // Define scoring weights based on relationship bars presence. + val statWeight = if (training.relationshipBars.isNotEmpty()) 0.6 else 0.7 + val relationshipWeight = if (training.relationshipBars.isNotEmpty()) 0.1 else 0.0 + val miscWeight = 0.3 + + // Calculate weighted total score. + totalScore += statScore * statWeight + totalScore += relationshipScore * relationshipWeight + totalScore += miscScore * miscWeight + + // 4. Rainbow training multiplier (Year 2+ only). + // Rainbow is heavily favored because it improves overall ratio balance. + val rainbowMultiplier = if (training.isRainbow && game.currentDate.year >= 2) { + if (enableRainbowTrainingBonus) { + game.printToLog("[TRAINING] ${training.name} Training is detected as a rainbow training. Adding multiplier to score.", tag = tag) + 2.0 + } else { + game.printToLog("[TRAINING] ${training.name} Training is detected as a rainbow training, but rainbow training bonus is not enabled.", tag = tag) + 1.5 + } + } else { + 1.0 + } + + // Apply rainbow multiplier to total score. + totalScore *= rainbowMultiplier + + return totalScore.coerceAtLeast(0.0) + } + + // Decide which scoring function to use based on the current phase or year. + // Junior Year will focus on building relationship bars. + val best = if (game.currentDate.phase == "Pre-Debut" || game.currentDate.year == 1) { + trainingMap.values.maxByOrNull { scoreFriendshipTraining(it) } + } else { + // For Year 2+, calculate all scores first, then normalize based on actual maximum. + val trainingScores = trainingMap.values.map { training -> + training to calculateRawTrainingScore(training) + }.toMap() + + val maxScore = trainingScores.values.maxOrNull() ?: 0.0 + + // Normalize scores to 0-100 scale based on actual maximum. + val normalizedScores = trainingScores.mapValues { (_, score) -> + if (maxScore > 0) (score / maxScore * 100.0).coerceIn(0.0, 100.0) else 0.0 + } + + // Log normalized scores for debugging. + normalizedScores.forEach { (training, score) -> + game.printToLog("[TRAINING] ${training.name}: ${game.decimalFormat.format(score)}/100", tag = tag) + } + + trainingScores.keys.maxByOrNull { normalizedScores[it] ?: 0.0 } + } + + return best?.name ?: (trainingMap.keys.firstOrNull { it !in blacklist } ?: "") + } + + /** + * Execute the training with the highest stat weight. + */ + private fun executeTraining() { + + game.printToLog("[TRAINING] Now starting process to execute training...", tag = tag) + val trainingSelected = recommendTraining() + + if (trainingSelected != "") { + printTrainingMap() + game.printToLog("[TRAINING] Executing the $trainingSelected Training.", tag = tag) + game.findAndTapImage("training_${trainingSelected.lowercase()}", region = game.imageUtils.regionBottomHalf, taps = 3) + game.printToLog("[TRAINING] Process to execute training completed.", tag = tag) + } else { + game.printToLog("[TRAINING] Conditions have not been met so training will not be done.", tag = tag) + } + + // Now reset the Training map. + trainingMap.clear() + } + + /** + * Prints the training map object for informational purposes. + */ + private fun printTrainingMap() { + game.printToLog("\n[INFO] Stat Gains by Training:", tag = tag) + trainingMap.forEach { name, training -> + game.printToLog("[INFO] $name Training stat gains: ${training.statGains.contentToString()}, failure chance: ${training.failureChance}%.", tag = tag) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/steve1316/uma_android_automation/bot/TrainingEvent.kt b/android/app/src/main/java/com/steve1316/uma_android_automation/bot/TrainingEvent.kt new file mode 100644 index 00000000..306bdf56 --- /dev/null +++ b/android/app/src/main/java/com/steve1316/uma_android_automation/bot/TrainingEvent.kt @@ -0,0 +1,358 @@ +package com.steve1316.uma_android_automation.bot + +import com.steve1316.uma_android_automation.MainActivity +import com.steve1316.uma_android_automation.utils.SettingsHelper +import org.opencv.core.Point +import org.json.JSONObject + +class TrainingEvent(private val game: Game) { + private val tag: String = "[${MainActivity.loggerTag}]TrainingEvent" + + private val trainingEventRecognizer: TrainingEventRecognizer = TrainingEventRecognizer(game, game.imageUtils) + + private val enablePrioritizeEnergyOptions: Boolean = SettingsHelper.getBooleanSetting("trainingEvent", "enablePrioritizeEnergyOptions") + + private val positiveStatuses = listOf("Charming", "Fast Learner", "Practice Practice") + private val negativeStatuses = listOf("Practice Poor", "Migraine", "Night Owl", "Slow Metabolism", "Slacker") + + // Load special event overrides from settings. + private val specialEventOverrides: Map = try { + val overridesString = SettingsHelper.getStringSetting("trainingEvent", "specialEventOverrides") + if (overridesString.isNotEmpty()) { + val jsonObject = JSONObject(overridesString) + val overridesMap = mutableMapOf() + jsonObject.keys().forEach { eventName -> + val eventData = jsonObject.getJSONObject(eventName) + overridesMap[eventName] = EventOverride( + selectedOption = eventData.getString("selectedOption"), + requiresConfirmation = eventData.getBoolean("requiresConfirmation") + ) + } + overridesMap + } else { + emptyMap() + } + } catch (e: Exception) { + game.printToLog("[WARNING] Could not parse special event overrides: ${e.message}", tag = tag) + emptyMap() + } + + data class EventOverride(val selectedOption: String, val requiresConfirmation: Boolean) + + //////////////////////////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////////////////////////// + // Functions to handle Training Events with the help of the TrainingEventRecognizer class. + + /** + * Check if the given event title matches any special event overrides. + * + * @param eventTitle The detected event title from OCR + * @return Pair of (optionIndex, requiresConfirmation) if match found, null otherwise + */ + private fun checkSpecialEventOverride(eventTitle: String): Pair? { + for ((eventName, patterns) in trainingEventRecognizer.eventPatterns) { + val override = specialEventOverrides[eventName] + if (override != null) { + // Check if any pattern matches the event title. + val matches = patterns.any { pattern -> eventTitle.contains(pattern) } + if (matches) { + game.printToLog("[TRAINING_EVENT] Detected special event: $eventName", tag = tag) + + // Parse the option number from the setting (e.g., "Option 5: Energy +10" -> 5) + val optionMatch = Regex("Option (\\d+)").find(override.selectedOption) + val optionIndex = if (optionMatch != null) { + val optionNumber = optionMatch.groupValues[1].toInt() + game.printToLog("[TRAINING_EVENT] Using setting: ${override.selectedOption} (Option $optionNumber)", tag = tag) + optionNumber - 1 + } else { + game.printToLog("[WARNING] Could not parse option number from setting: ${override.selectedOption}. Using option 1 by default.", tag = tag) + 0 + } + + return Pair(optionIndex, override.requiresConfirmation) + } + } + } + + return null + } + + /** + * Start text detection to determine what Training Event it is and the event rewards for each option. + * It will then select the best option according to the user's preferences. By default, it will choose the first option. + */ + fun handleTrainingEvent() { + game.printToLog("\n********************", tag = tag) + game.printToLog("[TRAINING_EVENT] Starting Training Event process on ${game.printFormattedDate()}.", tag = tag) + + // Double check if the bot is at the Main screen or not. + if (game.checkMainScreen()) { + game.printToLog("[TRAINING_EVENT] Bot is at the Main Screen. Ending the Training Event process.", tag = tag) + game.printToLog("********************", tag = tag) + return + } + + val (eventRewards, confidence, eventTitle) = trainingEventRecognizer.start() + + val regex = Regex("[a-zA-Z]+") + var optionSelected = 0 + + if (eventRewards.isNotEmpty() && eventRewards[0] != "") { + // Check for special event overrides. + val specialEventResult = checkSpecialEventOverride(eventTitle) + if (specialEventResult != null) { + val (selectedOptionIndex, requiresConfirmation) = specialEventResult + optionSelected = selectedOptionIndex + + // Ensure the selected option is within bounds. + if (optionSelected >= eventRewards.size) { + game.printToLog("[WARNING] Selected special event option $optionSelected is out of bounds. Using last option.", tag = tag) + optionSelected = eventRewards.size - 1 + } + + game.printToLog("[TRAINING_EVENT] Special event override applied: option ${optionSelected + 1}: \"${eventRewards[optionSelected]}\"", tag = tag) + } else { + // Initialize the List for normal event processing. + val selectionWeight = List(eventRewards.size) { 0 }.toMutableList() + + // Sum up the stat gains with additional weight applied to stats that are prioritized. + eventRewards.forEach { reward -> + val formattedReward: List = reward.split("\n") + + formattedReward.forEach { line -> + val formattedLine: String = regex + .replace(line, "") + .replace("(", "") + .replace(")", "") + .trim() + .lowercase() + + game.printToLog("[TRAINING_EVENT] Original line is \"$line\".", tag = tag) + game.printToLog("[TRAINING_EVENT] Formatted line is \"$formattedLine\".", tag = tag) + + var priorityStatCheck = false + if (line.lowercase().contains("energy")) { + val finalEnergyValue = try { + val energyValue = if (formattedLine.contains("/")) { + val splits = formattedLine.split("/") + var sum = 0 + for (split in splits) { + sum += try { + split.trim().toInt() + } catch (_: NumberFormatException) { + game.printToLog("[WARNING] Could not convert $formattedLine to a number for energy with a forward slash.", tag = tag) + 20 + } + } + sum + } else { + formattedLine.toInt() + } + + if (enablePrioritizeEnergyOptions) { + energyValue * 100 + } else { + energyValue * 3 + } + } catch (_: NumberFormatException) { + game.printToLog("[WARNING] Could not convert $formattedLine to a number for energy.", tag = tag) + 20 + } + game.printToLog("[TRAINING_EVENT] Adding weight for option #${optionSelected + 1} of $finalEnergyValue for energy.", tag = tag) + selectionWeight[optionSelected] += finalEnergyValue + } else if (line.lowercase().contains("mood")) { + val moodWeight = if (formattedLine.contains("-")) -50 else 50 + game.printToLog("[TRAINING-EVENT] Adding weight for option#${optionSelected + 1} of $moodWeight for ${if (moodWeight > 0) "positive" else "negative"} mood gain.", tag = tag) + selectionWeight[optionSelected] += moodWeight + } else if (line.lowercase().contains("bond")) { + game.printToLog("[TRAINING_EVENT] Adding weight for option #${optionSelected + 1} of 20 for bond.", tag = tag) + selectionWeight[optionSelected] += 20 + } else if (line.lowercase().contains("event chain ended")) { + game.printToLog("[TRAINING_EVENT] Adding weight for option #${optionSelected + 1} of -50 for event chain ending.", tag = tag) + selectionWeight[optionSelected] += -50 + } else if (line.lowercase().contains("(random)")) { + game.printToLog("[TRAINING_EVENT] Adding weight for option #${optionSelected + 1} of -10 for random reward.", tag = tag) + selectionWeight[optionSelected] += -10 + } else if (line.lowercase().contains("randomly")) { + game.printToLog("[TRAINING_EVENT] Adding weight for option #${optionSelected + 1} of 50 for random options.", tag = tag) + selectionWeight[optionSelected] += 50 + } else if (line.lowercase().contains("hint")) { + game.printToLog("[TRAINING_EVENT] Adding weight for option #${optionSelected + 1} of 25 for skill hint(s).", tag = tag) + selectionWeight[optionSelected] += 25 + } else if (positiveStatuses.any { status -> line.contains(status) }) { + game.printToLog("[TRAINING_EVENT] Adding weight for option #${optionSelected + 1} of 25 for positive status effect.", tag = tag) + selectionWeight[optionSelected] += 25 + } else if (negativeStatuses.any { status -> line.contains(status) }) { + game.printToLog("[TRAINING_EVENT] Adding weight for option #${optionSelected + 1} of -25 for negative status effect.", tag = tag) + selectionWeight[optionSelected] += -25 + } else if (line.lowercase().contains("skill")) { + val finalSkillPoints = if (formattedLine.contains("/")) { + val splits = formattedLine.split("/") + var sum = 0 + for (split in splits) { + sum += try { + split.trim().toInt() + } catch (_: NumberFormatException) { + game.printToLog("[WARNING] Could not convert $formattedLine to a number for skill points with a forward slash.", tag = tag) + 10 + } + } + sum + } else { + formattedLine.toInt() + } + game.printToLog("[TRAINING_EVENT] Adding weight for option #${optionSelected + 1} of $finalSkillPoints for skill points.", tag = tag) + selectionWeight[optionSelected] += finalSkillPoints + } else { + // Apply inflated weights to the prioritized stats based on their order. + game.training.statPrioritization.forEachIndexed { index, stat -> + if (line.contains(stat)) { + // Calculate weight bonus based on position (higher priority = higher bonus). + val priorityBonus = when (index) { + 0 -> 50 + 1 -> 40 + 2 -> 30 + 3 -> 20 + else -> 10 + } + + val finalStatValue = try { + priorityStatCheck = true + if (formattedLine.contains("/")) { + val splits = formattedLine.split("/") + var sum = 0 + for (split in splits) { + sum += try { + split.trim().toInt() + } catch (_: NumberFormatException) { + game.printToLog("[WARNING] Could not convert $formattedLine to a number for a priority stat with a forward slash.", tag = tag) + 10 + } + } + sum + priorityBonus + } else { + formattedLine.toInt() + priorityBonus + } + } catch (_: NumberFormatException) { + game.printToLog("[WARNING] Could not convert $formattedLine to a number for a priority stat.", tag = tag) + priorityStatCheck = false + 10 + } + game.printToLog("[TRAINING_EVENT] Adding weight for option #${optionSelected + 1} of $finalStatValue for prioritized stat.", tag = tag) + selectionWeight[optionSelected] += finalStatValue + } + } + + // Apply normal weights to the rest of the stats. + if (!priorityStatCheck) { + val finalStatValue = try { + if (formattedLine.contains("/")) { + val splits = formattedLine.split("/") + var sum = 0 + for (split in splits) { + sum += try { + split.trim().toInt() + } catch (_: NumberFormatException) { + game.printToLog("[WARNING] Could not convert $formattedLine to a number for non-prioritized stat with a forward slash.", tag = tag) + 10 + } + } + sum + } else { + formattedLine.toInt() + } + } catch (_: NumberFormatException) { + game.printToLog("[WARNING] Could not convert $formattedLine to a number for non-prioritized stat.", tag = tag) + 10 + } + game.printToLog("[TRAINING_EVENT] Adding weight for option #${optionSelected + 1} of $finalStatValue for non-prioritized stat.", tag = tag) + selectionWeight[optionSelected] += finalStatValue + } + } + + game.printToLog("[TRAINING_EVENT] Final weight for option #${optionSelected + 1} is: ${selectionWeight[optionSelected]}.", tag = tag) + } + + optionSelected++ + } + + // Select the best option that aligns with the stat prioritization made in the Training options. + var max: Int? = selectionWeight.maxOrNull() + if (max == null) { + max = 0 + optionSelected = 0 + } else { + optionSelected = selectionWeight.indexOf(max) + } + + // Print the selection weights. + game.printToLog("[TRAINING_EVENT] Selection weights for each option:", tag = tag) + selectionWeight.forEachIndexed { index, weight -> + game.printToLog("Option ${index + 1}: $weight", tag = tag) + } + } + + // Format the string to display each option's rewards. + var eventRewardsString = "" + var optionNumber = 1 + eventRewards.forEach { reward -> + eventRewardsString += "Option $optionNumber: \"$reward\"\n" + optionNumber += 1 + } + + val minimumConfidence = SettingsHelper.getStringSetting("debug", "templateMatchConfidence").toDouble() + val resultString = if (confidence >= minimumConfidence) { + "[TRAINING_EVENT] For this Training Event consisting of:\n$eventRewardsString\nThe bot will select Option ${optionSelected + 1}: \"${eventRewards[optionSelected]}\"." + } else { + "[TRAINING_EVENT] Since the confidence was less than the set minimum, first option will be selected." + } + + game.printToLog(resultString, tag = tag) + } else { + game.printToLog("[WARNING] First option will be selected since OCR failed to match the event title.", tag = tag) + optionSelected = 0 + } + + val trainingOptionLocations: ArrayList = game.imageUtils.findAll("training_event_active") + val selectedLocation: Point? = if (trainingOptionLocations.isNotEmpty()) { + // Account for the situation where it could go out of bounds if the detected event options is incorrect and gives too many results. + try { + trainingOptionLocations[optionSelected] + } catch (_: IndexOutOfBoundsException) { + // Default to the first option. + trainingOptionLocations[0] + } + } else { + game.imageUtils.findImage("training_event_active", tries = 5, region = game.imageUtils.regionMiddle).first + } + + if (selectedLocation != null) { + game.tap(selectedLocation.x + game.imageUtils.relWidth(100), selectedLocation.y, "training_event_active") + + // Check if this special event requires confirmation. + val specialEventResult = checkSpecialEventOverride(eventTitle) + if (specialEventResult != null) { + val (_, requiresConfirmation) = specialEventResult + if (requiresConfirmation) { + game.printToLog("[TRAINING_EVENT] Special event requires confirmation, waiting for dialog...", tag = tag) + + // Wait a moment for the confirmation dialog to appear. + game.wait(1.0) + + // Look for confirmation options and select the first one (Yes). + val confirmationLocations: ArrayList = game.imageUtils.findAll("training_event_active") + if (confirmationLocations.isNotEmpty()) { + val confirmLocation = confirmationLocations[0] + game.tap(confirmLocation.x + game.imageUtils.relWidth(100), confirmLocation.y, "training_event_active") + game.printToLog("[TRAINING_EVENT] Special event confirmed.", tag = tag) + } else { + game.printToLog("[WARNING] Could not find confirmation options for special event.", tag = tag) + } + } + } + } + + game.printToLog("[TRAINING_EVENT] Process to handle detected Training Event completed.", tag = tag) + game.printToLog("********************", tag = tag) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/steve1316/uma_android_automation/bot/TrainingEventRecognizer.kt b/android/app/src/main/java/com/steve1316/uma_android_automation/bot/TrainingEventRecognizer.kt new file mode 100644 index 00000000..43de584e --- /dev/null +++ b/android/app/src/main/java/com/steve1316/uma_android_automation/bot/TrainingEventRecognizer.kt @@ -0,0 +1,312 @@ +package com.steve1316.uma_android_automation.bot + +import android.util.Log +import com.steve1316.uma_android_automation.MainActivity +import com.steve1316.uma_android_automation.utils.CustomImageUtils +import com.steve1316.uma_android_automation.utils.SettingsHelper +import net.ricecode.similarity.JaroWinklerStrategy +import net.ricecode.similarity.StringSimilarityServiceImpl +import org.json.JSONObject + +/** + * Recognizes training events by performing OCR on event titles and matching them against + * known character and support card event data using string similarity algorithms. + */ +class TrainingEventRecognizer(private val game: Game, private val imageUtils: CustomImageUtils) { + private val tag: String = "[${MainActivity.loggerTag}]TrainingEventRecognizer" + + private var result = "" + private var confidence = 0.0 + private var category = "" + private var eventTitle = "" + private var supportCardTitle = "" + private var eventOptionRewards: ArrayList = arrayListOf() + + private var character = "" + + // Define event matching patterns to filter false positives during detection. + val eventPatterns = mapOf( + "New Year's Resolutions" to listOf("New Year's Resolutions", "Resolutions"), + "New Year's Shrine Visit" to listOf("New Year's Shrine Visit", "Shrine Visit"), + "Victory!" to listOf("Victory!"), + "Solid Showing" to listOf("Solid Showing"), + "Defeat" to listOf("Defeat"), + "Get Well Soon!" to listOf("Get Well Soon"), + "Don't Overdo It!" to listOf("Don't Overdo It"), + "Extra Training" to listOf("Extra Training"), + "Acupuncture (Just an Acupuncturist, No Worries! ☆)" to listOf("Acupuncture", "Just an Acupuncturist"), + "Etsuko's Exhaustive Coverage" to listOf("Etsuko", "Exhaustive Coverage") + ) + + // Get character event data from settings. + private val characterEventData: JSONObject? = try { + val characterDataString = SettingsHelper.getStringSetting("trainingEvent", "characterEventData") + if (characterDataString.isNotEmpty()) { + val jsonObject = JSONObject(characterDataString) + if (game.debugMode) game.printToLog("[DEBUG] Character event data length: ${jsonObject.length()}.", tag = tag) + jsonObject + } else { + null + } + } catch (_: Exception) { + null + } + + // Get support event data from settings. + private val supportEventData: JSONObject? = try { + val supportDataString = SettingsHelper.getStringSetting("trainingEvent", "supportEventData") + if (supportDataString.isNotEmpty()) { + val jsonObject = JSONObject(supportDataString) + if (game.debugMode) game.printToLog("[DEBUG] Support event data length: ${jsonObject.length()}.", tag = tag) + jsonObject + } else { + null + } + } catch (_: Exception) { + null + } + + private val supportCards: List = try { + if (supportEventData != null) { + supportEventData.keys().asSequence().toList() + } else { + emptyList() + } + } catch (_: Exception) { + emptyList() + } + private val hideComparisonResults: Boolean = SettingsHelper.getBooleanSetting("debug", "enableHideOCRComparisonResults") + private val selectAllCharacters: Boolean = SettingsHelper.getBooleanSetting("trainingEvent", "selectAllCharacters") + private val selectAllSupportCards: Boolean = SettingsHelper.getBooleanSetting("trainingEvent", "selectAllSupportCards") + private val minimumConfidence = SettingsHelper.getIntSetting("ocr", "ocrConfidence").toDouble() / 100.0 + private val threshold = SettingsHelper.getIntSetting("ocr", "ocrThreshold").toDouble() + private val enableAutomaticRetry = SettingsHelper.getBooleanSetting("ocr", "enableAutomaticOCRRetry") + + //////////////////////////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////////////////////////// + + /** + * Attempt to find the most similar string from data compared to the string returned by OCR. + */ + private fun findMostSimilarString() { + game.printToLog("[TRAINING_EVENT_RECOGNIZER] Now starting process to find most similar string to: $result", tag = tag) + + // Check if this matches any special event patterns first to filter false positives. + var matchedSpecialEvent: String? = null + for ((eventName, patterns) in eventPatterns) { + if (patterns.any { pattern -> result.contains(pattern) }) { + matchedSpecialEvent = eventName + break + } + } + val isSpecialEvent = matchedSpecialEvent != null + if (isSpecialEvent) { + game.printToLog("[TRAINING_EVENT_RECOGNIZER] Detected special event pattern: $matchedSpecialEvent. Will restrict search to this event.", tag = tag) + eventTitle = matchedSpecialEvent + } + + // Remove any detected whitespaces. + result = result.replace(" ", "") + + // Use the Jaro Winkler algorithm to compare similarities the OCR detected string and the rest of the strings inside the data classes. + val service = StringSimilarityServiceImpl(JaroWinklerStrategy()) + + // Attempt to find the most similar string inside the character event data. + if (characterEventData != null) { + if (selectAllCharacters) { + // Check all characters in the event data. + characterEventData.keys().forEach { characterKey -> + val characterEvents = characterEventData.getJSONObject(characterKey) + characterEvents.keys().forEach { eventName -> + // Skip if this is a special event and the event name doesn't match our detected pattern. + if (isSpecialEvent && eventName != matchedSpecialEvent) { + return@forEach + } + + val eventOptionsArray = characterEvents.getJSONArray(eventName) + val eventOptions = ArrayList() + for (i in 0 until eventOptionsArray.length()) { + eventOptions.add(eventOptionsArray.getString(i)) + } + + val score = service.score(result, eventName) + if (!hideComparisonResults) { + game.printToLog("[CHARA] $characterKey \"${result}\" vs. \"${eventName}\" confidence: ${game.decimalFormat.format(score)}", tag = tag) + } + + if (score >= confidence) { + confidence = score + eventTitle = eventName + eventOptionRewards = eventOptions + category = "character" + character = characterKey + } + } + } + } else { + // Check only the specific character if it exists in the event data. + if (character.isNotEmpty() && characterEventData.has(character)) { + val characterEvents = characterEventData.getJSONObject(character) + characterEvents.keys().forEach { eventName -> + // Skip if this is a special event and the event name doesn't match our detected pattern. + if (isSpecialEvent && eventName != matchedSpecialEvent) { + return@forEach + } + + val eventOptionsArray = characterEvents.getJSONArray(eventName) + val eventOptions = ArrayList() + for (i in 0 until eventOptionsArray.length()) { + eventOptions.add(eventOptionsArray.getString(i)) + } + + val score = service.score(result, eventName) + if (!hideComparisonResults) { + game.printToLog("[CHARA] $character \"${result}\" vs. \"${eventName}\" confidence: $score", tag = tag) + } + + if (score >= confidence) { + confidence = score + eventTitle = eventName + eventOptionRewards = eventOptions + category = "character" + } + } + } + } + } + + // Finally, do the same with the user-selected Support Cards. + if (supportEventData != null) { + if (!selectAllSupportCards) { + supportCards.forEach { supportCardName -> + if (supportEventData.has(supportCardName)) { + val supportEvents = supportEventData.getJSONObject(supportCardName) + supportEvents.keys().forEach { eventName -> + // Skip if this is a special event and the event name doesn't match our detected pattern. + if (isSpecialEvent && eventName != matchedSpecialEvent) { + return@forEach + } + + val eventOptionsArray = supportEvents.getJSONArray(eventName) + val eventOptions = ArrayList() + for (i in 0 until eventOptionsArray.length()) { + eventOptions.add(eventOptionsArray.getString(i)) + } + + val score = service.score(result, eventName) + if (!hideComparisonResults) { + game.printToLog("[SUPPORT] $supportCardName \"${result}\" vs. \"${eventName}\" confidence: $score", tag = tag) + } + + if (score >= confidence) { + confidence = score + eventTitle = eventName + supportCardTitle = supportCardName + eventOptionRewards = eventOptions + category = "support" + } + } + } + } + } else { + // Check all support cards in the event data. + supportEventData.keys().forEach { supportName -> + val supportEvents = supportEventData.getJSONObject(supportName) + supportEvents.keys().forEach { eventName -> + // Skip if this is a special event and the event name doesn't match our detected pattern. + if (isSpecialEvent && eventName != matchedSpecialEvent) { + return@forEach + } + + val eventOptionsArray = supportEvents.getJSONArray(eventName) + val eventOptions = ArrayList() + for (i in 0 until eventOptionsArray.length()) { + eventOptions.add(eventOptionsArray.getString(i)) + } + + val score = service.score(result, eventName) + if (!hideComparisonResults) { + game.printToLog("[SUPPORT] $supportName \"${result}\" vs. \"${eventName}\" confidence: $score", tag = tag) + } + + if (score >= confidence) { + confidence = score + eventTitle = eventName + supportCardTitle = supportName + eventOptionRewards = eventOptions + category = "support" + } + } + } + } + } + + game.printToLog("${if (!hideComparisonResults) "\n" else ""}[TRAINING_EVENT_RECOGNIZER] Finished process to find similar string.", tag = tag) + game.printToLog("[TRAINING_EVENT_RECOGNIZER] Event data fetched for \"${eventTitle}\".", tag = tag) + } + + //////////////////////////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////////////////////////// + + /** + * Starts the training event recognition process by performing OCR on the event title + * and matching it against known event data. + * + * @return A triple containing the event option rewards, confidence score, and event title. + */ + fun start(): Triple, Double, String> { + game.printToLog("\n********************", tag = tag) + + // Reset to default values. + result = "" + confidence = 0.0 + category = "" + eventTitle = "" + supportCardTitle = "" + eventOptionRewards.clear() + + var increment = 0.0 + + val startTime: Long = System.currentTimeMillis() + while (true) { + // Perform Tesseract OCR detection. + if ((255.0 - threshold - increment) > 0.0) { + result = imageUtils.findText(increment) + } else { + break + } + + if (result.isNotEmpty() && result != "empty!") { + // Now attempt to find the most similar string compared to the one from OCR. + findMostSimilarString() + + when (category) { + "character" -> { + game.printToLog("\n[RESULT] Character $character Event Name = $eventTitle with confidence = $confidence", tag = tag) + } + "support" -> { + game.printToLog("\n[RESULT] Support $supportCardTitle Event Name = $eventTitle with confidence = $confidence", tag = tag) + } + } + + if (enableAutomaticRetry && !hideComparisonResults) { + game.printToLog("\n[RESULT] Threshold incremented by $increment", tag = tag) + } + + if (confidence < minimumConfidence && enableAutomaticRetry) { + increment += 5.0 + } else { + break + } + } else { + increment += 5.0 + } + } + + val endTime: Long = System.currentTimeMillis() + Log.d(tag, "Total Runtime for recognizing training event: ${endTime - startTime}ms") + game.printToLog("********************", tag = tag) + + return Triple(eventOptionRewards, confidence, eventTitle) + } +} diff --git a/app/src/main/java/com/steve1316/uma_android_automation/bot/campaigns/AoHaru.kt b/android/app/src/main/java/com/steve1316/uma_android_automation/bot/campaigns/AoHaru.kt similarity index 90% rename from app/src/main/java/com/steve1316/uma_android_automation/bot/campaigns/AoHaru.kt rename to android/app/src/main/java/com/steve1316/uma_android_automation/bot/campaigns/AoHaru.kt index c676caf1..ab17a6a7 100644 --- a/app/src/main/java/com/steve1316/uma_android_automation/bot/campaigns/AoHaru.kt +++ b/android/app/src/main/java/com/steve1316/uma_android_automation/bot/campaigns/AoHaru.kt @@ -16,11 +16,11 @@ class AoHaru(game: Game) : Campaign(game) { override fun handleRaceEvents(): Boolean { // Check for Ao Haru specific race screens first. - if (aoHaruRaceFirstTime && game.imageUtils.confirmLocation("aoharu_set_initial_team", tries = 1)) { + if (aoHaruRaceFirstTime && game.imageUtils.findImage("aoharu_set_initial_team_header", tries = 1).first != null) { game.findAndTapImage("race_accept_trophy") handleRaceEventsAoHaru() return true - } else if (game.imageUtils.confirmLocation("aoharu_race", tries = 1)) { + } else if (game.imageUtils.findImage("aoharu_race_header", tries = 1).first != null) { handleRaceEventsAoHaru() return true } @@ -38,7 +38,7 @@ class AoHaru(game: Game) : Campaign(game) { */ private fun handleTrainingEventAoHaru() { if (tutorialChances > 0) { - if (game.imageUtils.confirmLocation("aoharu_tutorial", tries = 2)) { + if (game.imageUtils.findImage("aoharu_tutorial_header", tries = 2).first != null) { game.printToLog("\n[AOHARU] Detected tutorial for Ao Haru. Closing it now...", tag = aoHaruTag) // If the tutorial is detected, select the second option to close it. @@ -47,10 +47,10 @@ class AoHaru(game: Game) : Campaign(game) { tutorialChances = 0 } else { tutorialChances -= 1 - game.handleTrainingEvent() + game.trainingEvent.handleTrainingEvent() } } else { - game.handleTrainingEvent() + game.trainingEvent.handleTrainingEvent() } } diff --git a/android/app/src/main/java/com/steve1316/uma_android_automation/utils/CustomImageUtils.kt b/android/app/src/main/java/com/steve1316/uma_android_automation/utils/CustomImageUtils.kt new file mode 100644 index 00000000..5f56b381 --- /dev/null +++ b/android/app/src/main/java/com/steve1316/uma_android_automation/utils/CustomImageUtils.kt @@ -0,0 +1,1527 @@ +package com.steve1316.uma_android_automation.utils + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.Log +import com.google.mlkit.vision.common.InputImage +import com.steve1316.automation_library.utils.BotService +import com.steve1316.automation_library.utils.ImageUtils +import com.steve1316.uma_android_automation.MainActivity +import com.steve1316.uma_android_automation.bot.Game +import org.opencv.android.Utils +import org.opencv.core.* +import org.opencv.imgcodecs.Imgcodecs +import org.opencv.imgproc.Imgproc +import java.lang.Integer.max +import androidx.core.graphics.scale +import androidx.core.graphics.createBitmap +import com.steve1316.automation_library.data.SharedData +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.math.sqrt +import kotlin.text.replace + + +/** + * Utility functions for image processing via CV like OpenCV. + */ +class CustomImageUtils(context: Context, private val game: Game) : ImageUtils(context) { + private val tag: String = "[${MainActivity.loggerTag}]ImageUtils" + + //////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////// + // SQLite Settings + private val threshold: Int = SettingsHelper.getIntSetting("ocr", "ocrThreshold") + override var debugMode: Boolean = SettingsHelper.getBooleanSetting("debug", "enableDebugMode") + override var confidence: Double = SettingsHelper.getStringSetting("debug", "templateMatchConfidence").toDouble() + override var customScale: Double = SettingsHelper.getStringSetting("debug", "templateMatchCustomScale").toDouble() + + //////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////// + + data class RaceDetails ( + val fans: Int, + val hasDoublePredictions: Boolean + ) + + data class BarFillResult( + val fillPercent: Double, + val filledSegments: Int, + val dominantColor: String + ) + + //////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////// + + init { + initTesseract("eng.traineddata") + SharedData.templateSubfolderPathName = "images/" + } + + /** + * Find all occurrences of the specified image in the images folder using a provided source bitmap. Useful for parallel processing to avoid exceeding the maxImages buffer. + * + * @param templateName File name of the template image. + * @param sourceBitmap The source bitmap to search in. + * @param region Specify the region consisting of (x, y, width, height) of the source screenshot to template match. Defaults to (0, 0, 0, 0) which is equivalent to searching the full image. + * @return An ArrayList of Point objects containing all the occurrences of the specified image or null if not found. + */ + private fun findAllWithBitmap(templateName: String, sourceBitmap: Bitmap, region: IntArray = intArrayOf(0, 0, 0, 0)): ArrayList { + var templateBitmap: Bitmap? + context.assets?.open("images/$templateName.png").use { inputStream -> + templateBitmap = BitmapFactory.decodeStream(inputStream) + } + + if (templateBitmap != null) { + val matchLocations = matchAll(sourceBitmap, templateBitmap, region = region) + + // Sort the match locations by ascending x and y coordinates. + matchLocations.sortBy { it.x } + matchLocations.sortBy { it.y } + + if (debugMode) { + game.printToLog("[DEBUG] Found match locations for $templateName: $matchLocations.", tag = tag) + } else { + Log.d(tag, "[DEBUG] Found match locations for $templateName: $matchLocations.") + } + + return matchLocations + } + + return arrayListOf() + } + + //////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////// + + /** + * Perform OCR text detection using Tesseract along with some image manipulation via thresholding to make the cropped screenshot black and white using OpenCV. + * + * @param increment Increments the threshold by this value. Defaults to 0.0. + * @return The detected String in the cropped region. + */ + fun findText(increment: Double = 0.0): String { + val (sourceBitmap, templateBitmap) = getBitmaps("shift") + + // Acquire the location of the energy text image. + val (_, energyTemplateBitmap) = getBitmaps("energy") + val (_, matchLocation) = match(sourceBitmap, energyTemplateBitmap!!, "energy") + if (matchLocation == null) { + game.printToLog("[WARNING] Could not proceed with OCR text detection due to not being able to find the energy template on the source image.", tag = tag) + return "empty!" + } + + // Use the match location acquired from finding the energy text image and acquire the (x, y) coordinates of the event title container right below the location of the energy text image. + val newX: Int + val newY: Int + var croppedBitmap: Bitmap? = if (isTablet) { + newX = max(0, matchLocation.x.toInt() - relWidth(250)) + newY = max(0, matchLocation.y.toInt() + relHeight(154)) + createSafeBitmap(sourceBitmap, newX, newY, relWidth(746), relHeight(85), "findText tablet crop") + } else { + newX = max(0, matchLocation.x.toInt() - relWidth(125)) + newY = max(0, matchLocation.y.toInt() + relHeight(116)) + createSafeBitmap(sourceBitmap, newX, newY, relWidth(645), relHeight(65), "findText phone crop") + } + if (croppedBitmap == null) { + game.printToLog("[ERROR] Failed to create cropped bitmap for text detection", tag = tag, isError = true) + return "empty!" + } + + val tempImage = Mat() + Utils.bitmapToMat(croppedBitmap, tempImage) + if (debugMode) Imgcodecs.imwrite("$matchFilePath/debugEventTitleText.png", tempImage) + + // Now see if it is necessary to shift the cropped region over by 70 pixels or not to account for certain events. + val (shiftMatch, _) = match(croppedBitmap, templateBitmap!!, "shift") + croppedBitmap = if (shiftMatch) { + Log.d(tag, "Shifting the region over by 70 pixels!") + createSafeBitmap(sourceBitmap, relX(newX.toDouble(), 70), newY, 645 - 70, 65, "findText shifted crop") ?: croppedBitmap + } else { + Log.d(tag, "Do not need to shift.") + croppedBitmap + } + + // Make the cropped screenshot grayscale. + val cvImage = Mat() + Utils.bitmapToMat(croppedBitmap, cvImage) + Imgproc.cvtColor(cvImage, cvImage, Imgproc.COLOR_BGR2GRAY) + + // Save the cropped image before converting it to black and white in order to troubleshoot issues related to differing device sizes and cropping. + if (debugMode) Imgcodecs.imwrite("$matchFilePath/debugEventTitleText_afterCrop.png", cvImage) + + // Thresh the grayscale cropped image to make it black and white. + val bwImage = Mat() + Imgproc.threshold(cvImage, bwImage, threshold.toDouble() + increment, 255.0, Imgproc.THRESH_BINARY) + if (debugMode) Imgcodecs.imwrite("$matchFilePath/debugEventTitleText_afterThreshold.png", bwImage) + + // Convert the Mat directly to Bitmap and then pass it to the text reader. + val resultBitmap = createBitmap(bwImage.cols(), bwImage.rows()) + Utils.matToBitmap(bwImage, resultBitmap) + tessBaseAPI.setImage(resultBitmap) + + var result = "empty!" + try { + // Finally, detect text on the cropped region. + result = tessBaseAPI.utF8Text + game.printToLog("[INFO] Detected text with Tesseract: $result", tag = tag) + } catch (e: Exception) { + game.printToLog("[ERROR] Cannot perform OCR: ${e.stackTraceToString()}", tag = tag, isError = true) + } + + tessBaseAPI.clear() + tempImage.release() + cvImage.release() + bwImage.release() + + return result + } + + /** + * Find the success percentage chance on the currently selected stat. Parameters are optional to allow for thread-safe operations. + * + * @param sourceBitmap Bitmap of the source image separately taken. Defaults to null. + * @param trainingSelectionLocation Point location of the template image separately taken. Defaults to null. + * + * @return Integer representing the percentage. + */ + fun findTrainingFailureChance(sourceBitmap: Bitmap? = null, trainingSelectionLocation: Point? = null): Int { + // Crop the source screenshot to hold the success percentage only. + val (trainingSelectionLocation, sourceBitmap) = if (sourceBitmap == null && trainingSelectionLocation == null) { + findImage("training_failure_chance") + } else { + Pair(trainingSelectionLocation, sourceBitmap) + } + + if (trainingSelectionLocation == null) { + return -1 + } + + // Determine crop region based on device type. + val (offsetX, offsetY, width, height) = if (isTablet) { + listOf(-65, 23, relWidth(130), relHeight(50)) + } else { + listOf(-45, 15, relWidth(100), relHeight(37)) + } + + // Perform OCR with 2x scaling and no thresholding. + val detectedText = performOCROnRegion( + sourceBitmap!!, + relX(trainingSelectionLocation.x, offsetX), + relY(trainingSelectionLocation.y, offsetY), + width, + height, + useThreshold = false, + useGrayscale = true, + scaleUp = 2, + ocrEngine = "mlkit", + debugName = "TrainingFailureChance" + ) + + // Parse the result. + val result = try { + val cleanedResult = detectedText.replace("%", "").replace(Regex("[^0-9]"), "").trim() + cleanedResult.toInt() + } catch (_: NumberFormatException) { + game.printToLog("[ERROR] Could not convert \"$detectedText\" to integer.", tag = tag, isError = true) + -1 + } + + if (debugMode) { + game.printToLog("[DEBUG] Failure chance detected to be at $result%.", tag = tag) + } else { + Log.d(tag, "Failure chance detected to be at $result%.") + } + + return result + } + + /** + * Determines the day number to see if today is eligible for doing an extra race. + * + * @return Number of the day. + */ + fun determineDayForExtraRace(): Int { + val (energyTextLocation, sourceBitmap) = findImage("energy", tries = 1, region = regionTopHalf) + + if (energyTextLocation != null) { + // Determine crop region based on campaign and device type. + val (offsetX, offsetY, width, height) = if (game.campaign == "Ao Haru") { + if (isTablet) { + listOf(-(260 * 1.32).toInt(), -(140 * 1.32).toInt(), relWidth(135), relHeight(100)) + } else { + listOf(-260, -140, relWidth(105), relHeight(75)) + } + } else { + if (isTablet) { + listOf(-(246 * 1.32).toInt(), -(96 * 1.32).toInt(), relWidth(175), relHeight(116)) + } else { + listOf(-246, -100, relWidth(140), relHeight(95)) + } + } + + // Perform OCR with 2x scaling. + val detectedText = performOCROnRegion( + sourceBitmap, + relX(energyTextLocation.x, offsetX), + relY(energyTextLocation.y, offsetY), + width, + height, + useThreshold = true, + useGrayscale = true, + scaleUp = 2, + ocrEngine = "mlkit", + debugName = "DayForExtraRace" + ) + + // Parse the result. + val result = try { + val cleanedResult = detectedText.replace(Regex("[^0-9]"), "") + game.printToLog("[INFO] Detected day for extra racing: $detectedText", tag = tag) + cleanedResult.toInt() + } catch (_: NumberFormatException) { + game.printToLog("[ERROR] Could not convert \"$detectedText\" to integer.", tag = tag, isError = true) + -1 + } + + return result + } + + return -1 + } + + /** + * Extract the race name from the extra race selection screen using OCR. + * + * @param extraRaceLocation Point object of the extra race's location. + * @return The race name as detected by OCR, or empty string if not found. + */ + fun extractRaceName(extraRaceLocation: Point): String { + try { + val detectedText = performOCRFromReference( + referencePoint = extraRaceLocation, + offsetX = -455, + offsetY = -105, + width = relWidth(585), + height = relHeight(45), + useThreshold = true, + useGrayscale = true, + scaleUp = 2, + ocrEngine = "mlkit", + debugName = "extractRaceName" + ) + + game.printToLog("[INFO] Extracted race name: \"$detectedText\"", tag = tag) + return detectedText + } catch (e: Exception) { + game.printToLog("[ERROR] Exception during race name extraction: ${e.message}", tag = tag, isError = true) + return "" + } + } + + /** + * Determine the amount of fans that the extra race will give only if it matches the double star prediction. + * + * @param extraRaceLocation Point object of the extra race's location. + * @param sourceBitmap Bitmap of the source screenshot. + * @param doubleStarPredictionBitmap Bitmap of the double star prediction template image. + * @param forceRacing Flag to allow the extra race to forcibly pass double star prediction check. Defaults to false. + * @return Number of fans to be gained from the extra race or -1 if not found as an object. + */ + fun determineExtraRaceFans(extraRaceLocation: Point, sourceBitmap: Bitmap, doubleStarPredictionBitmap: Bitmap, forceRacing: Boolean = false): RaceDetails { + // Crop the source screenshot to show only the fan amount and the predictions. + val croppedBitmap = if (isTablet) { + createSafeBitmap(sourceBitmap, relX(extraRaceLocation.x, -(173 * 1.34).toInt()), relY(extraRaceLocation.y, -(106 * 1.34).toInt()), relWidth(220), relHeight(125), "determineExtraRaceFans prediction tablet") + } else { + createSafeBitmap(sourceBitmap, relX(extraRaceLocation.x, -173), relY(extraRaceLocation.y, -106), relWidth(163), relHeight(96), "determineExtraRaceFans prediction phone") + } + if (croppedBitmap == null) { + game.printToLog("[ERROR] Failed to create cropped bitmap for extra race prediction detection.", tag = tag, isError = true) + return RaceDetails(-1, false) + } + + val cvImage = Mat() + Utils.bitmapToMat(croppedBitmap, cvImage) + if (debugMode) Imgcodecs.imwrite("$matchFilePath/debugExtraRacePrediction.png", cvImage) + + // Determine if the extra race has double star prediction. + val (predictionCheck, _) = match(croppedBitmap, doubleStarPredictionBitmap, "race_extra_double_prediction") + + return if (forceRacing || predictionCheck) { + if (debugMode && !forceRacing) game.printToLog("[DEBUG] This race has double predictions. Now checking how many fans this race gives.", tag = tag) + else if (debugMode) game.printToLog("[DEBUG] Check for double predictions was skipped due to the force racing flag being enabled. Now checking how many fans this race gives.", tag = tag) + + // Crop the source screenshot to show only the fans. + val croppedBitmap2 = if (isTablet) { + createSafeBitmap(sourceBitmap, relX(extraRaceLocation.x, -(625 * 1.40).toInt()), relY(extraRaceLocation.y, -(75 * 1.34).toInt()), relWidth(320), relHeight(45), "determineExtraRaceFans fans tablet") + } else { + createSafeBitmap(sourceBitmap, relX(extraRaceLocation.x, -625), relY(extraRaceLocation.y, -75), relWidth(250), relHeight(35), "determineExtraRaceFans fans phone") + } + if (croppedBitmap2 == null) { + game.printToLog("[ERROR] Failed to create cropped bitmap for extra race fans detection.", tag = tag, isError = true) + return RaceDetails(-1, predictionCheck) + } + + // Make the cropped screenshot grayscale. + Utils.bitmapToMat(croppedBitmap2, cvImage) + Imgproc.cvtColor(cvImage, cvImage, Imgproc.COLOR_BGR2GRAY) + if (debugMode) Imgcodecs.imwrite("$matchFilePath/debugExtraRaceFans_afterCrop.png", cvImage) + + // Convert the Mat directly to Bitmap and then pass it to the text reader. + var resultBitmap = createBitmap(cvImage.cols(), cvImage.rows()) + Utils.matToBitmap(cvImage, resultBitmap) + + // Thresh the grayscale cropped image to make it black and white. + val bwImage = Mat() + Imgproc.threshold(cvImage, bwImage, threshold.toDouble(), 255.0, Imgproc.THRESH_BINARY) + if (debugMode) Imgcodecs.imwrite("$matchFilePath/debugExtraRaceFans_afterThreshold.png", bwImage) + + resultBitmap = createBitmap(bwImage.cols(), bwImage.rows()) + Utils.matToBitmap(bwImage, resultBitmap) + tessDigitsBaseAPI.setImage(resultBitmap) + + var result = "empty!" + try { + // Finally, detect text on the cropped region. + result = tessDigitsBaseAPI.utF8Text + } catch (e: Exception) { + game.printToLog("[ERROR] Cannot perform OCR with Tesseract: ${e.stackTraceToString()}", tag = tag, isError = true) + } + + tessDigitsBaseAPI.clear() + cvImage.release() + bwImage.release() + + // Format the string to be converted to an integer. + game.printToLog("[INFO] Detected number of fans from Tesseract before formatting: $result", tag = tag) + result = result + .replace(",", "") + .replace(".", "") + .replace("+", "") + .replace("-", "") + .replace(">", "") + .replace("<", "") + .replace("(", "") + .replace("人", "") + .replace("ォ", "") + .replace("fans", "").trim() + + try { + Log.d(tag, "Converting $result to integer for fans") + val cleanedResult = result.replace(Regex("[^0-9]"), "") + RaceDetails(cleanedResult.toInt(), predictionCheck) + } catch (_: NumberFormatException) { + RaceDetails(-1, predictionCheck) + } + } else { + Log.d(tag, "This race has no double prediction.") + return RaceDetails(-1, false) + } + } + + /** + * Determine the number of skill points. + * + * @return Number of skill points or -1 if not found. + */ + fun determineSkillPoints(): Int { + val (skillPointLocation, sourceBitmap) = findImage("skill_points", tries = 1) + + return if (skillPointLocation != null) { + // Determine crop region based on device type. + val (offsetX, offsetY, width, height) = if (isTablet) { + listOf(-75, 45, relWidth(150), relHeight(70)) + } else { + listOf(-70, 28, relWidth(135), relHeight(70)) + } + + // Perform OCR with thresholding. + val detectedText = performOCROnRegion( + sourceBitmap, + relX(skillPointLocation.x, offsetX), + relY(skillPointLocation.y, offsetY), + width, + height, + useThreshold = true, + useGrayscale = true, + scaleUp = 1, + ocrEngine = "mlkit", + debugName = "SkillPoints" + ) + + // Parse the result. + game.printToLog("[INFO] Detected number of skill points before formatting: $detectedText", tag = tag) + try { + Log.d(tag, "Converting $detectedText to integer for skill points") + val cleanedResult = detectedText.replace(Regex("[^0-9]"), "") + cleanedResult.toInt() + } catch (_: NumberFormatException) { + -1 + } + } else { + game.printToLog("[ERROR] Could not start the process of detecting skill points.", tag = tag, isError = true) + -1 + } + } + + /** + * Analyze the relationship bars on the Training screen for the currently selected training. Parameter is optional to allow for thread-safe operations. + * + * @param sourceBitmap Bitmap of the source image separately taken. Defaults to null. + * + * @return A list of the results for each relationship bar. + */ + fun analyzeRelationshipBars(sourceBitmap: Bitmap? = null): ArrayList { + val customRegion = intArrayOf(displayWidth - (displayWidth / 3), 0, (displayWidth / 3), displayHeight - (displayHeight / 3)) + + // Take a single screenshot first to avoid buffer overflow. + val sourceBitmap = sourceBitmap ?: getSourceBitmap() + + var allStatBlocks = mutableListOf() + + val latch = CountDownLatch(6) + + // Create arrays to store results from each thread. + val speedBlocks = arrayListOf() + val staminaBlocks = arrayListOf() + val powerBlocks = arrayListOf() + val gutsBlocks = arrayListOf() + val witBlocks = arrayListOf() + val friendshipBlocks = arrayListOf() + + // Start parallel threads for each findAll call, passing the same source bitmap. + Thread { + speedBlocks.addAll(findAllWithBitmap("stat_speed_block", sourceBitmap, region = customRegion)) + latch.countDown() + }.start() + + Thread { + staminaBlocks.addAll(findAllWithBitmap("stat_stamina_block", sourceBitmap, region = customRegion)) + latch.countDown() + }.start() + + Thread { + powerBlocks.addAll(findAllWithBitmap("stat_power_block", sourceBitmap, region = customRegion)) + latch.countDown() + }.start() + + Thread { + gutsBlocks.addAll(findAllWithBitmap("stat_guts_block", sourceBitmap, region = customRegion)) + latch.countDown() + }.start() + + Thread { + witBlocks.addAll(findAllWithBitmap("stat_wit_block", sourceBitmap, region = customRegion)) + latch.countDown() + }.start() + + Thread { + friendshipBlocks.addAll(findAllWithBitmap("stat_friendship_block", sourceBitmap, region = customRegion)) + latch.countDown() + }.start() + + // Wait for all threads to complete. + try { + latch.await(10, TimeUnit.SECONDS) + } catch (_: InterruptedException) { + game.printToLog("[ERROR] Parallel findAll operations timed out.", tag = tag, isError = true) + } + + // Combine all results. + allStatBlocks.addAll(speedBlocks) + allStatBlocks.addAll(staminaBlocks) + allStatBlocks.addAll(powerBlocks) + allStatBlocks.addAll(gutsBlocks) + allStatBlocks.addAll(witBlocks) + allStatBlocks.addAll(friendshipBlocks) + + // Filter out duplicates based on exact coordinate matches. + allStatBlocks = allStatBlocks.distinctBy { "${it.x},${it.y}" }.toMutableList() + + // Sort the combined stat blocks by ascending y-coordinate. + allStatBlocks.sortBy { it.y } + + // Define HSV color ranges. + val blueLower = Scalar(10.0, 150.0, 150.0) + val blueUpper = Scalar(25.0, 255.0, 255.0) + val greenLower = Scalar(40.0, 150.0, 150.0) + val greenUpper = Scalar(80.0, 255.0, 255.0) + val orangeLower = Scalar(100.0, 150.0, 150.0) + val orangeUpper = Scalar(130.0, 255.0, 255.0) + + val (_, maxedTemplateBitmap) = getBitmaps("stat_maxed") + val results = arrayListOf() + + for ((index, statBlock) in allStatBlocks.withIndex()) { + if (debugMode) game.printToLog("[DEBUG] Processing stat block #${index + 1} at position: (${statBlock.x}, ${statBlock.y})", tag = tag) + + val croppedBitmap = createSafeBitmap(sourceBitmap, relX(statBlock.x, -9), relY(statBlock.y, 107), 111, 13, "analyzeRelationshipBars stat block ${index + 1}") + if (croppedBitmap == null) { + game.printToLog("[ERROR] Failed to create cropped bitmap for stat block #${index + 1}.", tag = tag, isError = true) + continue + } + + val (isMaxed, _) = match(croppedBitmap, maxedTemplateBitmap!!, "stat_maxed") + if (isMaxed) { + // Skip if the relationship bar is already maxed. + if (debugMode) game.printToLog("[DEBUG] Relationship bar #${index + 1} is full.", tag = tag) + results.add(BarFillResult(100.0, 5, "orange")) + continue + } + + val barMat = Mat() + Utils.bitmapToMat(croppedBitmap, barMat) + + // Convert to RGB and then to HSV for better color detection. + val rgbMat = Mat() + Imgproc.cvtColor(barMat, rgbMat, Imgproc.COLOR_BGR2RGB) + if (debugMode) Imgcodecs.imwrite("$matchFilePath/debug_relationshipBar${index + 1}AfterRGB.png", rgbMat) + val hsvMat = Mat() + Imgproc.cvtColor(rgbMat, hsvMat, Imgproc.COLOR_RGB2HSV) + + val blueMask = Mat() + val greenMask = Mat() + val orangeMask = Mat() + + // Count the pixels for each color. + Core.inRange(hsvMat, blueLower, blueUpper, blueMask) + Core.inRange(hsvMat, greenLower, greenUpper, greenMask) + Core.inRange(hsvMat, orangeLower, orangeUpper, orangeMask) + val bluePixels = Core.countNonZero(blueMask) + val greenPixels = Core.countNonZero(greenMask) + val orangePixels = Core.countNonZero(orangeMask) + + // Sum the colored pixels. + val totalColoredPixels = bluePixels + greenPixels + orangePixels + val totalPixels = barMat.rows() * barMat.cols() + + // Estimate the fill percentage based on the total colored pixels. + val fillPercent = if (totalPixels > 0) { + (totalColoredPixels.toDouble() / totalPixels.toDouble()) * 100.0 + } else 0.0 + + // Estimate the filled segments (each segment is about 20% of the whole bar). + val filledSegments = (fillPercent / 20).coerceAtMost(5.0).toInt() + + val dominantColor = when { + orangePixels > greenPixels && orangePixels > bluePixels -> "orange" + greenPixels > bluePixels -> "green" + bluePixels > 0 -> "blue" + else -> "none" + } + + blueMask.release() + greenMask.release() + orangeMask.release() + hsvMat.release() + barMat.release() + + if (debugMode) game.printToLog("[DEBUG] Relationship bar #${index + 1} is ${decimalFormat.format(fillPercent)}% filled with $filledSegments filled segments and the dominant color is $dominantColor", tag = tag) + results.add(BarFillResult(fillPercent, filledSegments, dominantColor)) + } + + return results + } + + /** + * Determines the aptitudes of the current character based on the levels (S, A, B) on the Full Stats popup. The priority order of the aptitude levels is S > A > B. + * + * @return The aptitudes of the current character. + */ + fun determineAptitudes(currentAptitudes: Game.Aptitudes): Game.Aptitudes { + val (_, statAptitudeSTemplate) = getBitmaps("stat_aptitude_S") + val (_, statAptitudeATemplate) = getBitmaps("stat_aptitude_A") + val (_, statAptitudeBTemplate) = getBitmaps("stat_aptitude_B") + + val aptitudes = mutableMapOf( + "stat_track" to mutableMapOf("turf" to "", "dirt" to ""), + "stat_distance" to mutableMapOf("sprint" to "", "mile" to "", "medium" to "", "long" to ""), + "stat_style" to mutableMapOf("front" to "", "pace" to "", "late" to "", "end" to "") + ) + + for ((templateName, keys) in aptitudes) { + val (aptitudeLocation, sourceBitmap) = findImage(templateName, tries = 1, region = regionMiddle) + if (aptitudeLocation == null) { + game.printToLog( + "[ERROR] Could not determine aptitude using $templateName. Keeping previous values.", + tag = tag, + isError = true + ) + continue + } + + keys.keys.forEachIndexed { i, key -> + // Only two aptitudes for Track: Turf and Dirt. + if (templateName == "stat_track" && i > 1) return@forEachIndexed + + val croppedBitmap = createSafeBitmap( + sourceBitmap, + relX(aptitudeLocation.x, 108 + (i * 190)), + relY(aptitudeLocation.y, -25), + 176, + 52, + "determineAptitudes $templateName $key" + ) + + if (croppedBitmap == null) { + game.printToLog("[ERROR] Failed to crop bitmap for $templateName $key.", tag = tag, isError = true) + return@forEachIndexed + } + + // Determine level by priority: S > A > B. + val level = when { + match(croppedBitmap, statAptitudeSTemplate!!, "stat_aptitude_S").first -> "S" + match(croppedBitmap, statAptitudeATemplate!!, "stat_aptitude_A").first -> "A" + match(croppedBitmap, statAptitudeBTemplate!!, "stat_aptitude_B").first -> "B" + else -> "X" + } + + aptitudes[templateName]?.set(key, level) + } + } + + // Build updated Aptitudes object + return Game.Aptitudes( + track = Game.Track( + turf = aptitudes["stat_track"]?.get("turf") ?: currentAptitudes.track.turf, + dirt = aptitudes["stat_track"]?.get("dirt") ?: currentAptitudes.track.dirt + ), + distance = Game.Distance( + sprint = aptitudes["stat_distance"]?.get("sprint") ?: currentAptitudes.distance.sprint, + mile = aptitudes["stat_distance"]?.get("mile") ?: currentAptitudes.distance.mile, + medium = aptitudes["stat_distance"]?.get("medium") ?: currentAptitudes.distance.medium, + long = aptitudes["stat_distance"]?.get("long") ?: currentAptitudes.distance.long + ), + style = Game.Style( + front = aptitudes["stat_style"]?.get("front") ?: currentAptitudes.style.front, + pace = aptitudes["stat_style"]?.get("pace") ?: currentAptitudes.style.pace, + late = aptitudes["stat_style"]?.get("late") ?: currentAptitudes.style.late, + end = aptitudes["stat_style"]?.get("end") ?: currentAptitudes.style.end + ) + ) + } + + /** + * Reads the 5 stat values on the Main screen. + * + * @return The mapping of all 5 stats names to their respective integer values. + */ + fun determineStatValues(statValueMapping: MutableMap): MutableMap { + val (skillPointsLocation, sourceBitmap) = findImage("skill_points") + + if (skillPointsLocation != null) { + // Process all stats at once using the mapping. + statValueMapping.keys.forEachIndexed { index, statName -> + // Each stat is evenly spaced at 170 pixel intervals starting at offset -862. + val offsetX = -862 + (index * 170) + + // Perform OCR with no thresholding (stats are on solid background). + val result = performOCROnRegion( + sourceBitmap, + relX(skillPointsLocation.x, offsetX), + relY(skillPointsLocation.y, 25), + relWidth(98), + relHeight(42), + useThreshold = false, + useGrayscale = true, + scaleUp = 1, + ocrEngine = "tesseract_digits", + debugName = "${statName}StatValue" + ) + + // Parse the result. + game.printToLog("[INFO] Detected number of stats for $statName from Tesseract before formatting: $result", tag = tag) + if (result.lowercase().contains("max") || result.lowercase().contains("ax")) { + game.printToLog("[INFO] $statName seems to be maxed out. Setting it to 1200.", tag = tag) + statValueMapping[statName] = 1200 + } else { + try { + Log.d(tag, "Converting $result to integer for $statName stat value") + val cleanedResult = result.replace(Regex("[^0-9]"), "") + statValueMapping[statName] = cleanedResult.toInt() + } catch (_: NumberFormatException) { + statValueMapping[statName] = -1 + } + } + } + } else { + game.printToLog("[ERROR] Could not start the process of detecting stat values.", tag = tag, isError = true) + } + + return statValueMapping + } + + /** + * Performs OCR on the date region from either the Race List screen or the Main screen to extract the current date string. + * + * @return The detected date string from the game screen, or empty string if detection fails. + */ + fun determineDayString(): String { + var result = "" + val (raceStatusLocation, sourceBitmap) = findImage("race_status", tries = 1) + if (raceStatusLocation != null) { + // Perform OCR with thresholding (date text is on solid white background). + game.printToLog("[INFO] Detecting date from the Race List screen.", tag = tag) + result = performOCROnRegion( + sourceBitmap, + relX(raceStatusLocation.x, -170), + relY(raceStatusLocation.y, 105), + relWidth(640), + relHeight(70), + useThreshold = true, + useGrayscale = true, + scaleUp = 1, + ocrEngine = "mlkit", + debugName = "dateString" + ) + } else { + val (energyLocation, _) = findImage("energy") + if (energyLocation != null) { + // Perform OCR with no thresholding (date text is on moving background). + game.printToLog("[INFO] Detecting date from the Main screen.", tag = tag) + result = performOCROnRegion( + sourceBitmap, + relX(energyLocation.x, -268), + relY(energyLocation.y, -180), + relWidth(308), + relHeight(35), + useThreshold = false, + useGrayscale = true, + scaleUp = 1, + ocrEngine = "mlkit", + debugName = "dateString" + ) + } + } + + if (result != "") { + game.printToLog("[INFO] Detected date: $result", tag = tag) + + if (debugMode) { + game.printToLog("[DEBUG] Date string detected to be at \"$result\".", tag = tag) + } else { + Log.d(tag, "Date string detected to be at \"$result\".") + } + + return result + } else { + game.printToLog("[ERROR] Could not start the process of detecting the date string.", tag = tag, isError = true) + } + + return "" + } + + /** + * Determines the stat gain values from training. Parameters are optional to allow for thread-safe operations. + * + * This function uses template matching to find individual digits and the "+" symbol in the + * stat gain area of the training screen. It processes templates for digits 0-9 and the "+" + * symbol, then constructs the final integer value by analyzing the spatial arrangement + * of detected matches. + * + * @param trainingName Name of the currently selected training to determine which stats to read. + * @param sourceBitmap Bitmap of the source image separately taken. Defaults to null. + * @param skillPointsLocation Point location of the template image separately taken. Defaults to null. + * + * @return Array of 5 detected stat gain values as integers, or -1 for failed detections. + */ + fun determineStatGainFromTraining(trainingName: String, sourceBitmap: Bitmap? = null, skillPointsLocation: Point? = null): IntArray { + val templates = listOf("+", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9") + val statNames = listOf("Speed", "Stamina", "Power", "Guts", "Wit") + // Define a mapping of training types to their stat indices + val trainingToStatIndices = mapOf( + "Speed" to listOf(0, 2), + "Stamina" to listOf(1, 3), + "Power" to listOf(1, 2), + "Guts" to listOf(0, 2, 3), + "Wit" to listOf(0, 4) + ) + + val (skillPointsLocation, sourceBitmap) = if (sourceBitmap == null && skillPointsLocation == null) { + findImage("skill_points") + } else { + Pair(skillPointsLocation, sourceBitmap) + } + + val threadSafeResults = IntArray(5) + + if (skillPointsLocation != null) { + // Pre-load all template bitmaps to avoid thread contention + val templateBitmaps = mutableMapOf() + for (templateName in templates) { + context.assets?.open("images/$templateName.png").use { inputStream -> + templateBitmaps[templateName] = BitmapFactory.decodeStream(inputStream) + } + } + + // Process all stats in parallel using threads. + val statLatch = CountDownLatch(5) + for (i in 0 until 5) { + Thread { + try { + // Stop the Thread early if the selected Training would not offer stats for the stat to be checked. + // Speed gives Speed and Power + // Stamina gives Stamina and Guts + // Power gives Stamina and Power + // Guts gives Speed, Power and Guts + // Wits gives Speed and Wits + val validIndices = trainingToStatIndices[trainingName] ?: return@Thread + if (i !in validIndices) return@Thread + + val statName = statNames[i] + val xOffset = i * 180 // All stats are evenly spaced at 180 pixel intervals. + + val croppedBitmap = createSafeBitmap(sourceBitmap!!, relX(skillPointsLocation.x, -934 + xOffset), relY(skillPointsLocation.y, -103), relWidth(150), relHeight(82), "determineStatGainFromTraining $statName") + if (croppedBitmap == null) { + Log.e(tag, "[ERROR] Failed to create cropped bitmap for $statName stat gain detection from $trainingName training.") + threadSafeResults[i] = 0 + statLatch.countDown() + return@Thread + } + + // Convert to Mat and then turn it to grayscale. + val sourceMat = Mat() + Utils.bitmapToMat(croppedBitmap, sourceMat) + val sourceGray = Mat() + Imgproc.cvtColor(sourceMat, sourceGray, Imgproc.COLOR_BGR2GRAY) + + val workingMat = Mat() + sourceGray.copyTo(workingMat) + + var matchResults = mutableMapOf>() + templates.forEach { template -> + matchResults[template] = mutableListOf() + } + + for (templateName in templates) { + val templateBitmap = templateBitmaps[templateName] + if (templateBitmap != null) { + matchResults = processStatGainTemplateWithTransparency(templateName, templateBitmap, workingMat, matchResults) + } else { + Log.e(tag, "[ERROR] Could not load template \"$templateName\" to process stat gains for $trainingName training.") + } + } + + // Analyze results and construct the final integer value for this region. + val finalValue = constructIntegerFromMatches(matchResults) + threadSafeResults[i] = finalValue + Log.d(tag, "[INFO] $statName region final constructed value from $trainingName training: $finalValue.") + + // Draw final visualization with all matches for this region. + if (debugMode) { + val resultMat = Mat() + Utils.bitmapToMat(croppedBitmap, resultMat) + templates.forEachIndexed { index, templateName -> + matchResults[templateName]?.forEach { point -> + val templateBitmap = templateBitmaps[templateName] + if (templateBitmap != null) { + val templateWidth = templateBitmap.width + val templateHeight = templateBitmap.height + + // Calculate the bounding box coordinates. + val x1 = (point.x - templateWidth/2).toInt() + val y1 = (point.y - templateHeight/2).toInt() + val x2 = (point.x + templateWidth/2).toInt() + val y2 = (point.y + templateHeight/2).toInt() + + // Draw the bounding box. + Imgproc.rectangle(resultMat, Point(x1.toDouble(), y1.toDouble()), Point(x2.toDouble(), y2.toDouble()), Scalar(0.0, 0.0, 0.0), 2) + + // Add text label. + Imgproc.putText(resultMat, templateName, Point(point.x, point.y), Imgproc.FONT_HERSHEY_SIMPLEX, 0.5, Scalar(0.0, 0.0, 0.0), 1) + } + } + } + + Imgcodecs.imwrite("$matchFilePath/debug_${trainingName}TrainingStatGain_${statNames[i]}.png", resultMat) + } + + sourceMat.release() + sourceGray.release() + workingMat.release() + } catch (e: Exception) { + Log.e(tag, "[ERROR] Error processing stat ${statNames[i]} for $trainingName training: ${e.stackTraceToString()}") + threadSafeResults[i] = 0 + } finally { + statLatch.countDown() + } + }.start() + } + + // Wait for all threads to complete. + try { + statLatch.await(30, TimeUnit.SECONDS) + } catch (_: InterruptedException) { + game.printToLog("[ERROR] Stat processing timed out for $trainingName training.", tag = tag, isError = true) + } + + // Apply artificial boost to main stat gains if they appear lower than side-effect stats. + val boostedResults = applyStatGainBoost(trainingName, threadSafeResults, statNames, trainingToStatIndices) + return boostedResults + } else { + game.printToLog("[ERROR] Could not find the skill points location to start determining stat gains for $trainingName training.", tag = tag, isError = true) + } + + return threadSafeResults + } + + /** + * Applies artificial boost to main stat gains when they appear lower than side-effect stats due to OCR failure. + * + * @param trainingName Name of the training type (Speed, Stamina, Power, Guts, Wit). + * @param statGains Array of 5 stat gains. + * @param statNames List of stat names in order. + * @param trainingToStatIndices Mapping of training types to their affected stat indices. + * @return Array of stat gains with potential artificial boost applied to main stat. + */ + private fun applyStatGainBoost(trainingName: String, statGains: IntArray, statNames: List, trainingToStatIndices: Map>): IntArray { + val boostedResults = statGains.clone() + + // Define the main stat index for each training type. + val mainStatIndex = when (trainingName) { + "Speed" -> 0 + "Stamina" -> 1 + "Power" -> 2 + "Guts" -> 3 + "Wit" -> 4 + else -> return boostedResults + } + + // Get the stat indices affected by this training type and filter out the main stat to get side-effects. + val affectedIndices = trainingToStatIndices[trainingName] ?: return boostedResults + val sideEffectIndices = affectedIndices.filter { it != mainStatIndex } + + val mainStatGain = boostedResults[mainStatIndex] + val mainStatName = statNames[mainStatIndex] + + // Check if any side-effect stat has a higher gain than the main stat. + val maxSideEffectGain = sideEffectIndices.maxOfOrNull { boostedResults[it] } ?: 0 + + if (mainStatGain > 0 && maxSideEffectGain > mainStatGain) { + // Set main stat to be 10 points higher than the highest side-effect stat. + val originalGain = boostedResults[mainStatIndex] + boostedResults[mainStatIndex] = maxSideEffectGain + 10 + Log.d(tag, + "[DEBUG] Artificially increased $mainStatName stat gain from $originalGain to ${boostedResults[mainStatIndex]} due to possible OCR failure. " + + "Side-effect stats had higher gains: ${sideEffectIndices.joinToString(", ") { "${statNames[it]} = ${boostedResults[it]}" }}" + ) + } else if (mainStatGain == 0) { + // Set main stat to be 10 points higher than the highest side-effect stat when main stat is 0. + boostedResults[mainStatIndex] = maxSideEffectGain + 10 + Log.d(tag, "[DEBUG] Artificially increased $mainStatName stat gain to ${boostedResults[mainStatIndex]} due to possible OCR failure of 0 gains for the main stat. " + + "Based on highest side-effect: ${sideEffectIndices.joinToString(", ") { "${statNames[it]} = ${boostedResults[it]}" }}" + ) + } + + // If the side-effect stat gains were zeroes, boost them to half of the main stat gain. + val boostedMainStatGain = boostedResults[mainStatIndex] + sideEffectIndices.forEach { idx -> + if (boostedResults[idx] == 0 && boostedMainStatGain > 0) { + boostedResults[idx] = boostedMainStatGain / 2 + Log.d(tag, "[DEBUG] Artificially increased ${statNames[idx]} side-effect stat gain to ${boostedResults[idx]} because it was 0 due to possible OCR failure. " + + "Based on half of boosted $mainStatName = $boostedMainStatGain." + ) + } + } + + return boostedResults + } + + /** + * Processes a single template with transparency to find all valid matches in the working matrix through a multi-stage algorithm. + * + * The algorithm uses two validation criteria: + * - Pixel match ratio: Ensures sufficient pixel-level similarity. + * - Correlation coefficient: Validates statistical correlation between template and matched region. + * + * @param templateName Name of the template being processed (used for logging and debugging). + * @param templateBitmap Bitmap of the template image (must have 4-channel RGBA format with transparency). + * @param workingMat Working matrix to search in (grayscale source image). + * @param matchResults Map to store match results, organized by template name. + * + * @return The modified matchResults mapping containing all valid matches found for this template + */ + private fun processStatGainTemplateWithTransparency(templateName: String, templateBitmap: Bitmap, workingMat: Mat, matchResults: MutableMap>): MutableMap> { + // These values have been tested for the best results against the dynamic background. + val matchConfidence = 0.9 + val minPixelMatchRatio = 0.1 + val minPixelCorrelation = 0.85 + + // Convert template to Mat and then to grayscale. + val templateMat = Mat() + val templateGray = Mat() + Utils.bitmapToMat(templateBitmap, templateMat) + Imgproc.cvtColor(templateMat, templateGray, Imgproc.COLOR_BGR2GRAY) + + // Check if template has an alpha channel (transparency). + if (templateMat.channels() != 4) { + Log.e(tag, "[ERROR] Template \"$templateName\" is not transparent and is a requirement.") + templateMat.release() + templateGray.release() + return matchResults + } + + // Extract alpha channel for the alpha mask. + val alphaChannels = ArrayList() + Core.split(templateMat, alphaChannels) + val alphaMask = alphaChannels[3] // Alpha channel is the 4th channel. + + // Create binary mask for non-transparent pixels. + val validPixels = Mat() + Core.compare(alphaMask, Scalar(0.0), validPixels, Core.CMP_GT) + + // Check transparency ratio. + val nonZeroPixels = Core.countNonZero(alphaMask) + val totalPixels = alphaMask.rows() * alphaMask.cols() + val transparencyRatio = nonZeroPixels.toDouble() / totalPixels + if (transparencyRatio < 0.1) { + Log.w(tag, "[DEBUG] Template \"$templateName\" appears to be mostly transparent!") + alphaChannels.forEach { it.release() } + validPixels.release() + alphaMask.release() + templateMat.release() + templateGray.release() + return matchResults + } + + //////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////// + + var continueSearching = true + var searchMat = Mat() + var xOffset = 0 + workingMat.copyTo(searchMat) + + while (continueSearching) { + var failedPixelMatchRatio = false + var failedPixelCorrelation = false + + // Template match with the alpha mask. + val result = Mat() + Imgproc.matchTemplate(searchMat, templateGray, result, Imgproc.TM_CCORR_NORMED, alphaMask) + val mmr = Core.minMaxLoc(result) + val matchVal = mmr.maxVal + val matchLocation = mmr.maxLoc + + if (matchVal >= matchConfidence) { + val x = matchLocation.x.toInt() + val y = matchLocation.y.toInt() + val h = templateGray.rows() + val w = templateGray.cols() + + // Validate that the match location is within bounds. + if (x >= 0 && y >= 0 && x + w <= searchMat.cols() && y + h <= searchMat.rows()) { + // Extract the matched region from the source image. + val matchedRegion = Mat(searchMat, Rect(x, y, w, h)) + + // Create masked versions of the template and matched region using only non-transparent pixels. + val templateValid = Mat() + val regionValid = Mat() + templateGray.copyTo(templateValid, validPixels) + matchedRegion.copyTo(regionValid, validPixels) + + // For the first test, compare pixel-by-pixel equality between the matched region and template to calculate match ratio. + val templateComparison = Mat() + Core.compare(matchedRegion, templateGray, templateComparison, Core.CMP_EQ) + val matchingPixels = Core.countNonZero(templateComparison) + val pixelMatchRatio = matchingPixels.toDouble() / (w * h) + if (pixelMatchRatio < minPixelMatchRatio) { + failedPixelMatchRatio = true + } + + // Extract pixel values into double arrays for correlation calculation. + val templateValidMat = Mat() + val regionValidMat = Mat() + templateValid.convertTo(templateValidMat, CvType.CV_64F) + regionValid.convertTo(regionValidMat, CvType.CV_64F) + val templateArray = DoubleArray(templateValid.total().toInt()) + val regionArray = DoubleArray(regionValid.total().toInt()) + templateValidMat.get(0, 0, templateArray) + regionValidMat.get(0, 0, regionArray) + + // For the second test, validate the match quality by performing correlation calculation. + val pixelCorrelation = calculateCorrelation(templateArray, regionArray) + if (pixelCorrelation < minPixelCorrelation) { + failedPixelCorrelation = true + } + + // If both tests passed, then the match is valid. + if (!failedPixelMatchRatio && !failedPixelCorrelation) { + val centerX = (x + xOffset) + (w / 2) + val centerY = y + (h / 2) + + // Check for overlap with existing matches within 10 pixels on both axes. + val hasOverlap = matchResults.values.flatten().any { existingPoint -> + val existingX = existingPoint.x + val existingY = existingPoint.y + + // Check if the new match overlaps with existing match within 10 pixels. + val xOverlap = kotlin.math.abs(centerX - existingX) < 10 + val yOverlap = kotlin.math.abs(centerY - existingY) < 10 + + xOverlap && yOverlap + } + + if (!hasOverlap) { + Log.d(tag, "[DEBUG] Found valid match for template \"$templateName\" at ($centerX, $centerY).") + matchResults[templateName]?.add(Point(centerX.toDouble(), centerY.toDouble())) + } + } + + // Draw a box to prevent re-detection in the next loop iteration. + Imgproc.rectangle(searchMat, Point(x.toDouble(), y.toDouble()), Point((x + w).toDouble(), (y + h).toDouble()), Scalar(0.0, 0.0, 0.0), 10) + + templateComparison.release() + matchedRegion.release() + templateValid.release() + regionValid.release() + templateValidMat.release() + regionValidMat.release() + + // Crop the Mat horizontally to exclude the supposed matched area. + val cropX = x + w + val remainingWidth = searchMat.cols() - cropX + when { + remainingWidth < templateGray.cols() -> { + continueSearching = false + } + else -> { + val newSearchMat = Mat(searchMat, Rect(cropX, 0, remainingWidth, searchMat.rows())) + searchMat.release() + searchMat = newSearchMat + xOffset += cropX + } + } + } else { + // Stop searching when the source has been traversed. + continueSearching = false + } + } else { + // No match found above threshold, stop searching for this template. + continueSearching = false + } + + result.release() + + // Safety check to prevent infinite loops. + if ((matchResults[templateName]?.size ?: 0) > 10) { + continueSearching = false + } + if (!BotService.isRunning) { + throw InterruptedException() + } + } + + //////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////// + + searchMat.release() + alphaChannels.forEach { it.release() } + validPixels.release() + alphaMask.release() + templateMat.release() + templateGray.release() + + return matchResults + } + + /** + * Constructs the final integer value from matched template locations of numbers by analyzing spatial arrangement. + * + * The function is designed for OCR-like scenarios where individual character templates + * are matched separately and need to be reconstructed into a complete number. + * + * If matchResults contains: {"+" -> [(10, 20)], "1" -> [(15, 20)], "2" -> [(20, 20)]}, it returns: 12 (from string "+12"). + * + * @param matchResults Map of template names (e.g., "0", "1", "2", "+") to their match locations. + * + * @return The constructed integer value or -1 if it failed. + */ + private fun constructIntegerFromMatches(matchResults: Map>): Int { + // Collect all matches with their template names. + val allMatches = mutableListOf>() + matchResults.forEach { (templateName, points) -> + points.forEach { point -> + allMatches.add(Pair(templateName, point)) + } + } + + if (allMatches.isEmpty()) { + Log.d(tag, "[WARNING] No matches found to construct integer value.") + return 0 + } + + // Sort matches by x-coordinate (left to right). + allMatches.sortBy { it.second.x } + Log.d(tag, "[DEBUG] Sorted matches: ${allMatches.map { "${it.first}@(${it.second.x}, ${it.second.y})" }}") + + // Construct the string representation and then validate the format: start with + and contain only digits after. + val constructedString = allMatches.joinToString("") { it.first } + Log.d(tag, "[DEBUG] Constructed string: \"$constructedString\".") + + // Extract the numeric part and convert to integer. + return try { + if (constructedString === "+") { + Log.w(tag, "[WARNING] Constructed string was just the plus sign. Setting the result to 0.") + return 0 + } + + val numericPart = if (constructedString.startsWith("+") && constructedString.substring(1).isNotEmpty()) { + constructedString.substring(1) + } else { + constructedString + } + + val result = numericPart.toInt() + Log.d(tag, "[DEBUG] Successfully constructed integer value: $result from \"$constructedString\".") + result + } catch (e: NumberFormatException) { + Log.e(tag, "[ERROR] Could not convert \"$constructedString\" to integer: ${e.stackTraceToString()}") + 0 + } + } + + /** + * Calculates the Pearson correlation coefficient between two arrays of pixel values. + * + * The Pearson correlation coefficient measures the linear correlation between two variables, + * ranging from -1 (perfect negative correlation) to +1 (perfect positive correlation). + * A value of 0 indicates no linear correlation. + * + * @param array1 First array of pixel values from the template image. + * @param array2 Second array of pixel values from the matched region. + * @return Correlation coefficient between -1.0 and +1.0, or 0.0 if arrays are invalid + */ + private fun calculateCorrelation(array1: DoubleArray, array2: DoubleArray): Double { + if (array1.size != array2.size || array1.isEmpty()) { + return 0.0 + } + + val n = array1.size + val sum1 = array1.sum() + val sum2 = array2.sum() + val sum1Sq = array1.sumOf { it * it } + val sum2Sq = array2.sumOf { it * it } + val pSum = array1.zip(array2).sumOf { it.first * it.second } + + // Calculate the numerator: n*Σ(xy) - Σx*Σy + val num = pSum - (sum1 * sum2 / n) + // Calculate the denominator: sqrt((n*Σx² - (Σx)²) * (n*Σy² - (Σy)²)) + val den = sqrt((sum1Sq - sum1 * sum1 / n) * (sum2Sq - sum2 * sum2 / n)) + + // Return the correlation coefficient, handling division by zero. + return if (den == 0.0) 0.0 else num / den + } + + //////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////// + // Helper functions for OCR operations. + + /** + * Performs OCR using Tesseract on the provided bitmap. + * + * @param bitmap The bitmap to perform OCR on. + * @return The detected text string or empty string if OCR fails. + */ + private fun performTesseractOCR(bitmap: Bitmap): String { + tessBaseAPI.setImage(bitmap) + return try { + val result = tessBaseAPI.utF8Text + tessBaseAPI.clear() + result + } catch (e: Exception) { + game.printToLog("[ERROR] Cannot perform OCR with Tesseract: ${e.stackTraceToString()}", tag = tag, isError = true) + tessBaseAPI.clear() + "" + } + } + + /** + * Performs OCR using Tesseract with digits-only training data on the provided bitmap. + * + * @param bitmap The bitmap to perform OCR on. + * @return The detected text string or empty string if OCR fails. + */ + private fun performTesseractDigitsOCR(bitmap: Bitmap): String { + tessDigitsBaseAPI.setImage(bitmap) + return try { + val result = tessDigitsBaseAPI.utF8Text + tessDigitsBaseAPI.clear() + result + } catch (e: Exception) { + game.printToLog("[ERROR] Cannot perform OCR with Tesseract Digits: ${e.stackTraceToString()}", tag = tag, isError = true) + tessDigitsBaseAPI.clear() + "" + } + } + + /** + * Performs OCR using Google ML Kit on the provided bitmap with fallback to Tesseract. + * + * @param bitmap The bitmap to perform OCR on. + * @param fallbackToTesseract Whether to fallback to Tesseract if ML Kit fails. Defaults to true. + * @return The detected text string or empty string if OCR fails. + */ + private fun performMLKitOCR(bitmap: Bitmap, fallbackToTesseract: Boolean = true): String { + val inputImage: InputImage = InputImage.fromBitmap(bitmap, 0) + val latch = CountDownLatch(1) + var result = "" + var mlkitFailed = false + + googleTextRecognizer.process(inputImage) + .addOnSuccessListener { text -> + if (text.textBlocks.isNotEmpty()) { + for (block in text.textBlocks) { + result = block.text + } + } + latch.countDown() + } + .addOnFailureListener { + game.printToLog("[ERROR] Failed to do text detection via Google's ML Kit.", tag = tag, isError = true) + mlkitFailed = true + latch.countDown() + } + + // Wait for the async operation to complete. + try { + latch.await(5, TimeUnit.SECONDS) + } catch (_: InterruptedException) { + game.printToLog("[ERROR] Google ML Kit operation timed out.", tag = tag, isError = true) + } + + // Fallback to Tesseract if ML Kit failed or didn't find result. + if (fallbackToTesseract && (mlkitFailed || result.isEmpty())) { + game.printToLog("[INFO] Falling back to Tesseract OCR.", tag = tag) + return performTesseractDigitsOCR(bitmap) + } + + return result + } + + /** + * Performs OCR on a cropped region of a source bitmap with optional preprocessing. + * + * @param sourceBitmap The source image to crop from. + * @param x The x-coordinate of the crop region. + * @param y The y-coordinate of the crop region. + * @param width The width of the crop region. + * @param height The height of the crop region. + * @param useThreshold Whether to apply binary thresholding. Defaults to true. + * @param useGrayscale Whether to convert to grayscale first. Defaults to true. + * @param scaleUp Factor to scale up the cropped image before OCR. Defaults to 1 (no scaling). + * @param ocrEngine The OCR engine to use ("tesseract", "mlkit", or "tesseract_digits"). Defaults to "tesseract". + * @param debugName Optional name for debug image saving. + * + * @return The detected text string or empty string if OCR fails. + */ + fun performOCROnRegion( + sourceBitmap: Bitmap, + x: Int, + y: Int, + width: Int, + height: Int, + useThreshold: Boolean = true, + useGrayscale: Boolean = true, + scaleUp: Int = 1, + ocrEngine: String = "tesseract", + debugName: String = "" + ): String { + val croppedBitmap = createSafeBitmap(sourceBitmap, x, y, width, height, debugName) + ?: return "" + + val cvImage = Mat() + Utils.bitmapToMat(croppedBitmap, cvImage) + + // Apply grayscale if needed. + if (useGrayscale) { + Imgproc.cvtColor(cvImage, cvImage, Imgproc.COLOR_BGR2GRAY) + if (debugMode && debugName.isNotEmpty()) { + Imgcodecs.imwrite("$matchFilePath/debug_${debugName}_afterGrayscale.png", cvImage) + } + } + + // Apply thresholding if needed. + val processedImage = if (useThreshold) { + val bwImage = Mat() + Imgproc.threshold(cvImage, bwImage, threshold.toDouble(), 255.0, Imgproc.THRESH_BINARY) + if (debugMode && debugName.isNotEmpty()) { + Imgcodecs.imwrite("$matchFilePath/debug_${debugName}_afterThreshold.png", bwImage) + } + cvImage.release() + bwImage + } else { + cvImage + } + + // Scale up if needed. + val finalBitmap = if (scaleUp > 1) { + val resultBitmap = createBitmap(processedImage.cols(), processedImage.rows()) + Utils.matToBitmap(processedImage, resultBitmap) + resultBitmap.scale(resultBitmap.width * scaleUp, resultBitmap.height * scaleUp) + } else { + val resultBitmap = createBitmap(processedImage.cols(), processedImage.rows()) + Utils.matToBitmap(processedImage, resultBitmap) + resultBitmap + } + + // Perform OCR based on selected engine. + val result = when (ocrEngine) { + "mlkit" -> performMLKitOCR(finalBitmap) + "tesseract_digits" -> performTesseractDigitsOCR(finalBitmap) + else -> performTesseractOCR(finalBitmap) + } + + processedImage.release() + return result + } + + /** + * Performs OCR on a custom region using a reference point. + * + * @param referencePoint The point to base the crop region on. + * @param offsetX Offset from reference point x-coordinate. + * @param offsetY Offset from reference point y-coordinate. + * @param width Width of the crop region. + * @param height Height of the crop region. + * @param useThreshold Whether to apply binary thresholding. Defaults to true. + * @param useGrayscale Whether to convert to grayscale first. Defaults to true. + * @param scaleUp Factor to scale up the cropped image before OCR. Defaults to 1. + * @param ocrEngine The OCR engine to use. Defaults to "tesseract". + * @param debugName Optional name for debug image saving. + * + * @return The detected text string or empty string if OCR fails. + */ + fun performOCRFromReference( + referencePoint: Point, + offsetX: Int, + offsetY: Int, + width: Int, + height: Int, + useThreshold: Boolean = true, + useGrayscale: Boolean = true, + scaleUp: Int = 1, + ocrEngine: String = "tesseract", + debugName: String = "" + ): String { + val sourceBitmap = getSourceBitmap() + val finalX = relX(referencePoint.x, offsetX) + val finalY = relY(referencePoint.y, offsetY) + + return performOCROnRegion( + sourceBitmap, + finalX, + finalY, + width, + height, + useThreshold, + useGrayscale, + scaleUp, + ocrEngine, + debugName + ) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/steve1316/uma_android_automation/utils/GameDateParser.kt b/android/app/src/main/java/com/steve1316/uma_android_automation/utils/GameDateParser.kt new file mode 100644 index 00000000..56bd90a0 --- /dev/null +++ b/android/app/src/main/java/com/steve1316/uma_android_automation/utils/GameDateParser.kt @@ -0,0 +1,131 @@ +package com.steve1316.uma_android_automation.utils + +import com.steve1316.uma_android_automation.bot.Game +import net.ricecode.similarity.JaroWinklerStrategy +import net.ricecode.similarity.StringSimilarityServiceImpl + +/** + * Utility class for parsing game date strings and converting them to structured Game.Date objects. + */ +class GameDateParser { + /** + * Parses a date string from the game and converts it to a structured Game.Date object. + * + * This function handles two types of date formats: Pre-Debut and regular date strings. + * + * For Pre-Debut dates, the function calculates the current turn based on remaining turns + * and determines the month within the Pre-Debut phase (which spans 12 turns). + * + * For regular dates, the function parses the year (Junior/Classic/Senior), phase (Early/Late), + * and month (Jan-Dec) components. If exact matches aren't found in the predefined mappings, + * it uses Jaro Winkler similarity scoring to find the best match. + * + * @param dateString The date string to parse (e.g., "Classic Year Early Jan" or "Pre-Debut"). + * @param imageUtils CustomImageUtils instance for determining day number in Pre-Debut phase. + * @param game Game instance for logging. + * @param tag Optional tag for logging. Defaults to "GameDateParser". + * + * @return A Game.Date object containing the parsed year, phase, month, and calculated turn number. + */ + fun parseDateString( + dateString: String, + imageUtils: CustomImageUtils, + game: Game, + tag: String = "GameDateParser" + ): Game.Date { + if (dateString == "") { + game.printToLog("[ERROR] Received date string from OCR was empty. Defaulting to \"Senior Year Early Jan\" at turn number 49.", tag = tag) + return Game.Date(3, "Early", 1, 49) + } else if (dateString.lowercase().contains("debut")) { + // Special handling for the Pre-Debut phase. + val turnsRemaining = imageUtils.determineDayForExtraRace() + + // Pre-Debut ends on Early July (turn 13), so we calculate backwards. + // This includes the Race day. + val totalTurnsInPreDebut = 12 + val currentTurnInPreDebut = totalTurnsInPreDebut - turnsRemaining + 1 + + val month = ((currentTurnInPreDebut - 1) / 2) + 1 + return Game.Date(1, "Pre-Debut", month, currentTurnInPreDebut) + } + + // Example input is "Classic Year Early Jan". + val years = mapOf( + "Junior Year" to 1, + "Classic Year" to 2, + "Senior Year" to 3 + ) + val months = mapOf( + "Jan" to 1, + "Feb" to 2, + "Mar" to 3, + "Apr" to 4, + "May" to 5, + "Jun" to 6, + "Jul" to 7, + "Aug" to 8, + "Sep" to 9, + "Oct" to 10, + "Nov" to 11, + "Dec" to 12 + ) + + // Split the input string by whitespace. + val parts = dateString.trim().split(" ") + if (parts.size < 3) { + game.printToLog("[DATE-PARSER] Invalid date string format: $dateString", tag = tag) + return Game.Date(3, "Early", 1, 49) + } + + // Extract the parts with safe indexing using default values. + val yearPart = parts.getOrNull(0)?.let { first -> + parts.getOrNull(1)?.let { second -> "$first $second" } + } ?: "Senior Year" + val phase = parts.getOrNull(2) ?: "Early" + val monthPart = parts.getOrNull(3) ?: "Jan" + + // Find the best match for year using Jaro Winkler if not found in mapping. + var year = years[yearPart] + if (year == null) { + val service = StringSimilarityServiceImpl(JaroWinklerStrategy()) + var bestYearScore = 0.0 + var bestYear = 3 + + years.keys.forEach { yearKey -> + val score = service.score(yearPart, yearKey) + if (score > bestYearScore) { + bestYearScore = score + bestYear = years[yearKey]!! + } + } + year = bestYear + game.printToLog("[DATE-PARSER] Year not found in mapping, using best match: $yearPart -> $year", tag = tag) + } + + // Find the best match for month using Jaro Winkler if not found in mapping. + var month = months[monthPart] + if (month == null) { + val service = StringSimilarityServiceImpl(JaroWinklerStrategy()) + var bestMonthScore = 0.0 + var bestMonth = 1 + + months.keys.forEach { monthKey -> + val score = service.score(monthPart, monthKey) + if (score > bestMonthScore) { + bestMonthScore = score + bestMonth = months[monthKey]!! + } + } + month = bestMonth + game.printToLog("[DATE-PARSER] Month not found in mapping, using best match: $monthPart -> $month", tag = tag) + } + + // Calculate the turn number. + // Each year has 24 turns (12 months x 2 phases each). + // Each month has 2 turns (Early and Late). + val turnNumber = ((year - 1) * 24) + ((month - 1) * 2) + (if (phase == "Early") 1 else 2) + + return Game.Date(year, phase, month, turnNumber) + } +} + diff --git a/android/app/src/main/java/com/steve1316/uma_android_automation/utils/SQLiteSettingsManager.kt b/android/app/src/main/java/com/steve1316/uma_android_automation/utils/SQLiteSettingsManager.kt new file mode 100644 index 00000000..9019dc2f --- /dev/null +++ b/android/app/src/main/java/com/steve1316/uma_android_automation/utils/SQLiteSettingsManager.kt @@ -0,0 +1,316 @@ +package com.steve1316.uma_android_automation.utils + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.util.Log +import org.json.JSONObject +import java.io.File + +/** + * Manages settings persistence using SQLite database. + * Reads settings from the same database used by the React Native frontend. + */ +class SQLiteSettingsManager(private val context: Context) { + companion object { + private const val TAG = "SQLiteSettingsManager" + private const val DATABASE_NAME = "settings.db" + private const val TABLE_SETTINGS = "settings" + + // Database columns. + private const val COLUMN_ID = "id" + private const val COLUMN_CATEGORY = "category" + private const val COLUMN_KEY = "key" + private const val COLUMN_VALUE = "value" + private const val COLUMN_UPDATED_AT = "updated_at" + } + + private var database: SQLiteDatabase? = null + private var isInitialized = false + + /** + * Initialize the database connection by opening the existing database file. + * The database is created by expo-sqlite in the app's files directory. + */ + fun initialize(): Boolean { + if (isInitialized) { + Log.d(TAG, "Database already initialized.") + return true + } + + val dbFile = File(context.filesDir, "SQLite/$DATABASE_NAME") + + try { + Log.d(TAG, "Attempting to open database at: SQLite/${DATABASE_NAME}") + + if (!dbFile.exists()) { + Log.d(TAG, "Database file does not exist at: ${dbFile.absolutePath}") + } else if (!dbFile.canRead()) { + Log.d(TAG, "Database file is not readable at: ${dbFile.absolutePath}") + } else { + Log.d(TAG, "Found database file at: ${dbFile.absolutePath} (${dbFile.length()} bytes)") + + // Open the existing database in read-only mode first to verify it's accessible. + database = SQLiteDatabase.openDatabase(dbFile.absolutePath, null, SQLiteDatabase.OPEN_READONLY) + Log.d(TAG, "Database opened successfully in read-only mode.") + + // Verify the database has the expected table structure. + if (!verifyDatabaseStructure()) { + Log.d(TAG, "Database structure verification failed for: ${dbFile.absolutePath}") + database?.close() + database = null + } else { + // Close read-only connection and open in read-write mode. + database?.close() + database = SQLiteDatabase.openDatabase(dbFile.absolutePath, null, SQLiteDatabase.OPEN_READWRITE) + Log.d(TAG, "Database opened successfully in read-write mode at: ${dbFile.absolutePath}") + + isInitialized = true + return true + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to open database at ${dbFile.absolutePath}: ${e.message}", e) + database?.close() + database = null + } + + Log.e(TAG, "Failed to find or open the SQLite database.") + + // If no database exists, try to create one in the default location. + Log.d(TAG, "Now attempting to create a new SQLite database at ${dbFile.absolutePath}...") + return createNewDatabase() + } + + /** + * Create a new database if none exists. + */ + private fun createNewDatabase(): Boolean { + try { + val dbFile = File(context.filesDir, "SQLite/$DATABASE_NAME") + val sqliteDir = File(context.filesDir, "SQLite") + + // Create the SQLite directory if it doesn't exist. + if (!sqliteDir.exists()) { + sqliteDir.mkdirs() + Log.d(TAG, "Created SQLite directory: ${sqliteDir.absolutePath}") + } + + // Create a new database. + database = SQLiteDatabase.openOrCreateDatabase(dbFile.absolutePath, null) + Log.d(TAG, "Created new database at: ${dbFile.absolutePath}") + + // Create the settings table. + database?.execSQL(""" + CREATE TABLE IF NOT EXISTS $TABLE_SETTINGS ( + $COLUMN_ID INTEGER PRIMARY KEY AUTOINCREMENT, + $COLUMN_CATEGORY TEXT NOT NULL, + `$COLUMN_KEY` TEXT NOT NULL, + $COLUMN_VALUE TEXT NOT NULL, + $COLUMN_UPDATED_AT DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE($COLUMN_CATEGORY, `$COLUMN_KEY`) + ) + """) + Log.d(TAG, "Created settings table.") + + // Create index for faster queries. + database?.execSQL(""" + CREATE INDEX IF NOT EXISTS idx_settings_category_key + ON $TABLE_SETTINGS($COLUMN_CATEGORY, `$COLUMN_KEY`) + """) + Log.d(TAG, "Created index.") + + isInitialized = true + return true + } catch (e: Exception) { + Log.e(TAG, "Failed to create new database: ${e.message}", e) + database?.close() + database = null + isInitialized = false + return false + } + } + + /** + * Verify that the database has the expected table structure. + */ + private fun verifyDatabaseStructure(): Boolean { + return try { + Log.d(TAG, "Verifying database structure - checking for table '$TABLE_SETTINGS'") + val cursor = database?.rawQuery( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", + arrayOf(TABLE_SETTINGS) + ) + val hasTable = cursor?.moveToFirst() == true + cursor?.close() + + if (hasTable) { + Log.d(TAG, "Database structure verification successful - table '$TABLE_SETTINGS' found") + } else { + Log.w(TAG, "Database structure verification failed - table '$TABLE_SETTINGS' not found") + } + hasTable + } catch (e: Exception) { + Log.e(TAG, "Error verifying database structure: ${e.message}", e) + false + } + } + + /** + * Check if the database is available and initialized. + */ + fun isAvailable(): Boolean { + val available = isInitialized && database != null && database?.isOpen == true + Log.d(TAG, "Database availability check: initialized=$isInitialized, database=${database != null}, open=${database?.isOpen}, available=$available") + return available + } + + /** + * Load a specific setting from the database. + * + * @param category The settings category (e.g., "general", "racing", "training"). + * @param key The setting key. + * @return The setting value or null if not found. + */ + fun loadSetting(category: String, key: String): String? { + if (!isAvailable()) { + Log.e(TAG, "Database not available.") + return null + } + + return try { + val cursor = database?.query( + TABLE_SETTINGS, + arrayOf(COLUMN_VALUE), + "$COLUMN_CATEGORY = ? AND `$COLUMN_KEY` = ?", + arrayOf(category, key), + null, null, null + ) + + val value = if (cursor?.moveToFirst() == true) { + val result = cursor.getString(0) + result + } else { + Log.e(TAG, "Setting not found: $category.$key") + null + } + + cursor?.close() + value + } catch (e: Exception) { + Log.e(TAG, "Error loading setting $category.$key: ${e.message}", e) + null + } + } + + /** + * Get a boolean setting value. + * Throws exception if setting doesn't exist. + */ + fun getBooleanSetting(category: String, key: String): Boolean { + val value = loadSetting(category, key) + ?: throw RuntimeException("Setting not found: $category.$key") + return try { + value.toBoolean() + } catch (e: Exception) { + throw RuntimeException("Error parsing boolean value for $category.$key: $value", e) + } + } + + /** + * Get an integer setting value. + * Throws exception if setting doesn't exist. + */ + fun getIntSetting(category: String, key: String): Int { + val value = loadSetting(category, key) + ?: throw RuntimeException("Setting not found: $category.$key") + return try { + value.toInt() + } catch (e: Exception) { + throw RuntimeException("Error parsing integer value for $category.$key: $value", e) + } + } + + /** + * Get a string setting value. + * Throws exception if setting doesn't exist. + */ + fun getStringSetting(category: String, key: String): String { + return loadSetting(category, key) + ?: throw RuntimeException("Setting not found: $category.$key") + } + + /** + * Get a JSON array setting value. + * Throws exception if setting doesn't exist. + */ + fun getStringArraySetting(category: String, key: String): List { + val value = loadSetting(category, key) + ?: throw RuntimeException("Setting not found: $category.$key") + return try { + val jsonArray = JSONObject("{\"array\": $value}").getJSONArray("array") + val list = mutableListOf() + for (i in 0 until jsonArray.length()) { + list.add(jsonArray.getString(i)) + } + list + } catch (e: Exception) { + throw RuntimeException("Error parsing string array value for $category.$key: $value", e) + } + } + + /** + * Save a setting to the database. + */ + fun saveSetting(category: String, key: String, value: String): Boolean { + if (!isAvailable()) { + Log.e(TAG, "Database not available.") + return false + } + + return try { + Log.d(TAG, "Saving setting: category='$category', key='$key', value='$value'") + database?.execSQL( + "INSERT OR REPLACE INTO $TABLE_SETTINGS ($COLUMN_CATEGORY, `$COLUMN_KEY`, $COLUMN_VALUE, $COLUMN_UPDATED_AT) VALUES (?, ?, ?, CURRENT_TIMESTAMP)", + arrayOf(category, key, value) + ) + Log.d(TAG, "Successfully saved setting: $category.$key = '$value'") + true + } catch (e: Exception) { + Log.e(TAG, "Error saving setting $category.$key: ${e.message}", e) + false + } + } + + /** + * Check if the database file exists and is accessible. + */ + fun isDatabaseAvailable(): Boolean { + val possiblePaths = listOf( + File(context.filesDir, "SQLite/$DATABASE_NAME"), + File(context.filesDir, DATABASE_NAME), + File(context.filesDir, "databases/$DATABASE_NAME"), + context.getDatabasePath(DATABASE_NAME) + ) + + return possiblePaths.any { it.exists() && it.canRead() } + } + + /** + * Get the database instance for direct access. + * This should only be used by classes that need direct database access. + */ + fun getDatabase(): SQLiteDatabase? { + return if (isAvailable()) database else null + } + + /** + * Close the database connection. + */ + fun close() { + database?.close() + database = null + isInitialized = false + Log.d(TAG, "Database connection closed.") + } +} + diff --git a/android/app/src/main/java/com/steve1316/uma_android_automation/utils/SettingsHelper.kt b/android/app/src/main/java/com/steve1316/uma_android_automation/utils/SettingsHelper.kt new file mode 100644 index 00000000..d959cc1d --- /dev/null +++ b/android/app/src/main/java/com/steve1316/uma_android_automation/utils/SettingsHelper.kt @@ -0,0 +1,87 @@ +package com.steve1316.uma_android_automation.utils + +import android.annotation.SuppressLint +import android.content.Context +import android.util.Log + +/** + * Helper class to provide easy access to settings from SQLite database. + * This class provides a centralized way to access settings throughout the app. + */ +object SettingsHelper { + private const val TAG = "SettingsHelper" + @SuppressLint("StaticFieldLeak") + private var settingsManager: SQLiteSettingsManager? = null + + /** + * Initialize the settings helper with a context. + * This should be called once during app initialization. + */ + fun initialize(context: Context) { + settingsManager = SQLiteSettingsManager(context) + if (settingsManager?.initialize() == true) { + Log.d(TAG, "Settings helper initialized successfully.") + } else { + Log.w(TAG, "Failed to initialize settings helper.") + } + } + + /** + * Get a boolean setting value. + * Throws exception if setting doesn't exist. + */ + fun getBooleanSetting(category: String, key: String): Boolean { + return settingsManager?.getBooleanSetting(category, key) + ?: throw RuntimeException("Setting not found: $category.$key") + } + + /** + * Get an integer setting value. + * Throws exception if setting doesn't exist. + */ + fun getIntSetting(category: String, key: String): Int { + return settingsManager?.getIntSetting(category, key) + ?: throw RuntimeException("Setting not found: $category.$key") + } + + /** + * Get a string setting value. + * Throws exception if setting doesn't exist. + */ + fun getStringSetting(category: String, key: String): String { + return settingsManager?.getStringSetting(category, key) + ?: throw RuntimeException("Setting not found: $category.$key") + } + + /** + * Get a string array setting value. + * Throws exception if setting doesn't exist. + */ + fun getStringArraySetting(category: String, key: String): List { + return settingsManager?.getStringArraySetting(category, key) + ?: throw RuntimeException("Setting not found: $category.$key") + } + + /** + * Save a setting value. + */ + fun saveSetting(category: String, key: String, value: String): Boolean { + return settingsManager?.saveSetting(category, key, value) ?: false + } + + /** + * Check if the settings manager is available. + */ + fun isAvailable(): Boolean { + return settingsManager != null && settingsManager?.isDatabaseAvailable() == true + } + + /** + * Close the settings manager. + */ + fun close() { + settingsManager?.close() + settingsManager = null + } +} + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml similarity index 100% rename from app/src/main/res/drawable/ic_launcher_background.xml rename to android/app/src/main/res/drawable/ic_launcher_background.xml diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml rename to android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml similarity index 100% rename from app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml rename to android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..5d7ad398 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..b65052c0 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 00000000..34d3be2f Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..d8b4b5bb Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..272ae11c Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 00000000..5e43ab84 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..a80aa047 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..087a93df Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 00000000..5ac10c44 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..4a820c67 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..aaf86505 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..2b5d82c1 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..076166db Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..cc962888 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..17693a8a Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml similarity index 100% rename from app/src/main/res/values/strings.xml rename to android/app/src/main/res/values/strings.xml diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..3534338a --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/app/update.xml b/android/app/update.xml similarity index 100% rename from app/update.xml rename to android/app/update.xml diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 00000000..6725aa94 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,41 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath(libs.androidGradleBuildTools) + classpath(libs.react.native.gradle.plugin) + classpath(libs.kotlinGradlePlugin) + } +} + +ext { + ndkVersion = "27.1.12297006" +} + +def reactNativeAndroidDir = new File( + providers.exec { + workingDir(rootDir) + commandLine("node", "--print", "require.resolve('react-native/package.json')") + }.standardOutput.asText.get().trim(), + "../android" +) + +allprojects { + repositories { + maven { + // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm + url(reactNativeAndroidDir) + } + + google() + mavenCentral() + maven { url 'https://www.jitpack.io' } + } +} + +apply plugin: "expo-root-project" +apply plugin: "com.facebook.react.rootproject" diff --git a/android/generate-bundle.js b/android/generate-bundle.js new file mode 100644 index 00000000..ed7fa37d --- /dev/null +++ b/android/generate-bundle.js @@ -0,0 +1,52 @@ +const { execSync } = require("child_process") +const path = require("path") +const fs = require("fs") + +// Get the project root directory (two levels up from android/). +// This script is located in android/, so we need to go up to the React Native project root. +const projectRoot = path.resolve(__dirname, "..") +const assetsDir = path.resolve(__dirname, "app/src/main/assets") + +// Ensure the Android assets directory exists before attempting to generate the bundle. +// This directory is where the React Native JavaScript bundle and assets will be stored. +// The recursive option creates parent directories if they don't exist. +if (!fs.existsSync(assetsDir)) { + fs.mkdirSync(assetsDir, { recursive: true }) +} + +console.log("Generating React Native bundle...") +console.log("Project root:", projectRoot) +console.log("Assets directory:", assetsDir) + +try { + // Generate the React Native JavaScript bundle for Android platform. + // This command bundles all JavaScript code into a single file that can be loaded by the Android app. + // + // Command breakdown: + // - npx react-native bundle: Uses the React Native CLI to create a bundle + // - --platform android: Specifies the target platform + // - --dev false: Disables development mode for production-ready bundle + // - --entry-file index.js: Specifies the main entry point of the React Native app + // - --bundle-output: Sets the output path for the JavaScript bundle + // - --assets-dest: Sets the destination for images, fonts, and other assets + const bundleCommand = `npx react-native bundle --platform android --dev false --entry-file index.js --bundle-output "${path.join(assetsDir, "index.android.bundle")}" --assets-dest "${path.join(assetsDir, "..")}"` + + console.log("Running command:", bundleCommand) + + // Execute the bundle command synchronously. + // The command runs in the project root directory to access React Native configuration. + // stdio: "inherit" ensures console output is visible during execution. + // shell: true allows the command to run in a shell environment for better compatibility. + execSync(bundleCommand, { + cwd: projectRoot, + stdio: "inherit", + shell: true, + }) + + console.log("Bundle generated successfully!") +} catch (error) { + // If bundle generation fails, log the error and exit with a non-zero code. + // This ensures the build process fails if the bundle cannot be created. + console.error("Failed to generate bundle:", error.message) + process.exit(1) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 00000000..9863f884 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,55 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8 + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +org.gradle.parallel=true +org.gradle.workers.max=4 + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true + +# Automatically convert third-party libraries to use AndroidX +android.enableJetifier=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official + +# Enable AAPT2 PNG crunching +android.enablePngCrunchInReleaseBuilds=true + +# Use this property to specify which architecture you want to build. +# You can also override it from the CLI using +# ./gradlew -PreactNativeArchitectures=x86_64 +reactNativeArchitectures=arm64-v8a,armeabi-v7a + +# Use this property to enable support to the new architecture. +# This will allow you to use TurboModules and the Fabric render in +# your application. You should enable this flag either if you want +# to write custom TurboModules/Fabric components OR use libraries that +# are providing them. +newArchEnabled=true + +# Use this property to enable or disable the Hermes JS engine. +# If set to false, you will be using JSC instead. +hermesEnabled=true + +# Use legacy packaging to compress native libraries in the resulting APK. +expo.useLegacyPackaging=true + +# Use this property to enable edge-to-edge display support. +# This allows your app to draw behind system bars for an immersive UI. +# Note: Only works with ReactActivity and should not be used with custom Activity. +edgeToEdgeEnabled=true \ No newline at end of file diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml new file mode 100644 index 00000000..717e2f4e --- /dev/null +++ b/android/gradle/libs.versions.toml @@ -0,0 +1,29 @@ +[versions] +# The Android Studio warning about these not being used in any build scripts is wrong and can be ignored. +# https://issuetracker.google.com/issues/343889864 +app-compileSdk = "36" +app-buildToolsVersion = "36.0.0" +app-minSdk = "24" +#noinspection ExpiredTargetSdkVersion +app-targetSdk = "30" +app-versionCode = "27" +app-versionName = "3.0.3" +app-jvm-toolchain = "17" + + +androidGradleBuildTools = "8.13.0" +kotlinGradlePlugin = "2.2.0" +androidCvAutomationLibrary = "2.0.18" +lottie = "6.6.9" + +[libraries] +androidGradleBuildTools = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradleBuildTools" } +kotlinGradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlinGradlePlugin" } +lottie = { module = "com.airbnb.android:lottie", version.ref = "lottie" } +react-native-gradle-plugin = { module = "com.facebook.react:react-native-gradle-plugin" } +android-cv-automation-library = { module = "com.github.steve1316:android-cv-automation-library", version.ref = "androidCvAutomationLibrary" } +react-android = { module = "com.facebook.react:react-android" } +hermes-android = { module = "com.facebook.react:hermes-android" } + +[plugins] +foojayJDKResolver = { id = "org.gradle.toolchains.foojay-resolver-convention", version = "1.0.0" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties similarity index 76% rename from gradle/wrapper/gradle-wrapper.properties rename to android/gradle/wrapper/gradle-wrapper.properties index 3f0183fa..7cb88770 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,8 @@ #Thu Aug 07 16:24:44 WEST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100644 index 00000000..ea64ad0e --- /dev/null +++ b/android/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" \ No newline at end of file diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 00000000..d22863ab --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega \ No newline at end of file diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 00000000..4db32da5 --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,39 @@ +pluginManagement { + def reactNativeGradlePlugin = new File( + providers.exec { + workingDir(rootDir) + commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })") + }.standardOutput.asText.get().trim() + ).getParentFile().absolutePath + includeBuild(reactNativeGradlePlugin) + + def expoPluginsPath = new File( + providers.exec { + workingDir(rootDir) + commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })") + }.standardOutput.asText.get().trim(), + "../android/expo-gradle-plugin" + ).absolutePath + includeBuild(expoPluginsPath) +} + +plugins { + id("com.facebook.react.settings") + id("expo-autolinking-settings") +} + +extensions.configure(com.facebook.react.ReactSettingsExtension) { ex -> + if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') { + ex.autolinkLibrariesFromCommand() + } else { + ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand) + } +} +expoAutolinking.useExpoModules() + +rootProject.name = 'Uma Android Automation' + +expoAutolinking.useExpoVersionCatalog() + +include ':app' +includeBuild(expoAutolinking.reactNativeGradlePlugin) diff --git a/app.json b/app.json new file mode 100644 index 00000000..522d4313 --- /dev/null +++ b/app.json @@ -0,0 +1,7 @@ +{ + "expo": { + "name": "Uma Android Automation", + "slug": "uma-android-automation", + "platforms": ["android"] + } +} diff --git a/app/.gitignore b/app/.gitignore deleted file mode 100644 index 42afabfd..00000000 --- a/app/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts deleted file mode 100644 index ed44b987..00000000 --- a/app/build.gradle.kts +++ /dev/null @@ -1,111 +0,0 @@ -plugins { - id("com.android.application") - id("kotlin-android") -} - -android { - namespace = "com.steve1316.uma_android_automation" - compileSdk = libs.versions.app.compileSdk.get().toInt() - buildToolsVersion = libs.versions.app.buildToolsVersion.get() - - defaultConfig { - applicationId = "com.steve1316.uma_android_automation" - minSdk { - version = release(libs.versions.app.minSdk.get().toInt()) - } - targetSdk { - version = release(libs.versions.app.targetSdk.get().toInt()) - } - versionCode = libs.versions.app.versionCode.get().toInt() - versionName = libs.versions.app.versionName.get() - } - - buildTypes { - release { - isMinifyEnabled = true - isShrinkResources = true - isDebuggable = false - isProfileable = false - isJniDebuggable = false - signingConfig = signingConfigs.getByName("debug") - } - debug { - isDefault = true - isMinifyEnabled = false - isShrinkResources = false - } - all { - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - - applicationVariants.all { - val releaseType = this.buildType.name - // Allow layout XMLs to get a reference to the application's version number. - resValue("string", "versionName", "v${versionName}") - - // Auto-generate the file name. - // To access the output file name, the apk variants must be explicitly cast to, - // as in the previous groovy version (where they were implicitly cast) - outputs.asSequence() - .filter { - it is com.android.build.gradle.internal.api.ApkVariantOutputImpl - }.map { - it as com.android.build.gradle.internal.api.ApkVariantOutputImpl - }.forEach { - val type = releaseType - val versionName = defaultConfig.versionName - val architecture = it.filters.first().identifier - it.outputFileName = "v${versionName}-UmaAndroidAutomation-${architecture}-${type}.apk" - } - } - } - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - // Specify which architecture to make apks for, or set universalApk to true for an all-in-one apk with increased file size. - splits { - abi { - isEnable = true - reset() - //noinspection ChromeOsAbiSupport - include("armeabi-v7a", "arm64-v8a") - // include "armeabi","armeabi-v7a",'arm64-v8a',"mips","x86","x86_64" - isUniversalApk = false - } - } -} - -dependencies { - - implementation(libs.bundles.androidApp) - - // OpenCV Android 4.12.0 for image processing. - implementation(libs.opencv) - - // Tesseract4Android for OCR text recognition. - implementation(libs.tesseract4android) - - // string-similarity to compare the string from OCR to the strings in data. - implementation(libs.stringSimilarity) - - // Klaxon to parse JSON data files. - implementation(libs.klaxon) - - // Google's Firebase Machine Learning OCR for Text Detection. - implementation(libs.mlkitTextRecognition) - - // AppUpdater for notifying users when there is a new update available. - implementation(libs.appUpdater) -} - -kotlin { - jvmToolchain { - languageVersion.set(JavaLanguageVersion.of(libs.versions.app.jvm.toolchain.get().toInt())) - } -} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro deleted file mode 100644 index 73725c7a..00000000 --- a/app/proguard-rules.pro +++ /dev/null @@ -1,26 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle.kts. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile - -# Ignore SSL provider and conscrypt, they should be provided -# by the android runtime in the actual device. --dontwarn org.conscrypt.Conscrypt --dontwarn org.conscrypt.OpenSSLProvider diff --git a/app/src/main/assets/images/training_rainbow.png b/app/src/main/assets/images/training_rainbow.png deleted file mode 100644 index 878f41b2..00000000 Binary files a/app/src/main/assets/images/training_rainbow.png and /dev/null differ diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png deleted file mode 100644 index 8003140d..00000000 Binary files a/app/src/main/ic_launcher-playstore.png and /dev/null differ diff --git a/app/src/main/java/com/steve1316/uma_android_automation/MainActivity.kt b/app/src/main/java/com/steve1316/uma_android_automation/MainActivity.kt deleted file mode 100644 index aaae7faf..00000000 --- a/app/src/main/java/com/steve1316/uma_android_automation/MainActivity.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.steve1316.uma_android_automation - -import android.content.Intent -import android.content.res.Configuration -import android.os.Bundle -import android.widget.TextView -import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.widget.Toolbar -import androidx.core.net.toUri -import androidx.drawerlayout.widget.DrawerLayout -import androidx.navigation.findNavController -import androidx.navigation.ui.AppBarConfiguration -import androidx.navigation.ui.navigateUp -import androidx.navigation.ui.setupActionBarWithNavController -import androidx.navigation.ui.setupWithNavController -import com.google.android.material.navigation.NavigationView -import org.opencv.android.OpenCVLoader -import java.util.Locale - - -class MainActivity : AppCompatActivity() { - private lateinit var appBarConfiguration: AppBarConfiguration - - companion object { - const val loggerTag: String = "UAA" - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) - val toolbar: Toolbar = findViewById(R.id.toolbar) - setSupportActionBar(toolbar) - - val drawerLayout: DrawerLayout = findViewById(R.id.drawer_layout) - val navView: NavigationView = findViewById(R.id.nav_view) - val navController = findNavController(R.id.nav_host_fragment) - - // Set application locale to combat cases where user's language uses commas instead of decimal points for floating numbers. - val config: Configuration? = this.getResources().configuration - val locale = Locale("en") - Locale.setDefault(locale) - this.getResources().updateConfiguration(config, this.getResources().displayMetrics) - - // Set the Link to the "Go to GitHub" button. - val githubTextView: TextView = findViewById(R.id.github_textView) - githubTextView.setOnClickListener { - val newIntent = Intent(Intent.ACTION_VIEW, "https://github.com/steve1316/uma-android-automation".toUri()) - startActivity(newIntent) - } - - appBarConfiguration = AppBarConfiguration(setOf(R.id.nav_home, R.id.nav_settings), drawerLayout) - - setupActionBarWithNavController(navController, appBarConfiguration) - navView.setupWithNavController(navController) - - // Load OpenCV native library. This will throw a "E/OpenCV/StaticHelper: OpenCV error: Cannot load info library for OpenCV". It is safe to - // ignore this error. OpenCV functionality is not impacted by this error. - OpenCVLoader.initDebug() - } - - override fun onSupportNavigateUp(): Boolean { - val navController = findNavController(R.id.nav_host_fragment) - return navController.navigateUp(appBarConfiguration) || super.onSupportNavigateUp() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/steve1316/uma_android_automation/bot/Game.kt b/app/src/main/java/com/steve1316/uma_android_automation/bot/Game.kt deleted file mode 100644 index 3b79ff6a..00000000 --- a/app/src/main/java/com/steve1316/uma_android_automation/bot/Game.kt +++ /dev/null @@ -1,2001 +0,0 @@ -package com.steve1316.uma_android_automation.bot - -import android.annotation.SuppressLint -import android.content.Context -import android.content.SharedPreferences -import android.util.Log -import androidx.preference.PreferenceManager -import com.steve1316.uma_android_automation.MainActivity -import com.steve1316.uma_android_automation.bot.campaigns.AoHaru -import com.steve1316.uma_android_automation.utils.BotService -import com.steve1316.uma_android_automation.utils.ImageUtils -import com.steve1316.uma_android_automation.utils.MediaProjectionService -import com.steve1316.uma_android_automation.utils.MessageLog -import com.steve1316.uma_android_automation.utils.MyAccessibilityService -import com.steve1316.uma_android_automation.utils.SettingsPrinter -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking -import org.opencv.core.Point -import java.text.DecimalFormat -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import kotlin.intArrayOf - -/** - * Main driver for bot activity and navigation. - */ -class Game(val myContext: Context) { - private val tag: String = "[${MainActivity.loggerTag}]Game" - var notificationMessage: String = "" - private val decimalFormat = DecimalFormat("#.##") - val imageUtils: ImageUtils = ImageUtils(myContext, this) - val gestureUtils: MyAccessibilityService = MyAccessibilityService.getInstance() - private val textDetection: TextDetection = TextDetection(this, imageUtils) - - //////////////////////////////////////////////////////////////////// - //////////////////////////////////////////////////////////////////// - // SharedPreferences - private var sharedPreferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(myContext) - private val campaign: String = sharedPreferences.getString("campaign", "")!! - private val debugMode: Boolean = sharedPreferences.getBoolean("debugMode", false) - - //////////////////////////////////////////////////////////////////// - //////////////////////////////////////////////////////////////////// - // Training - private val trainings: List = listOf("Speed", "Stamina", "Power", "Guts", "Wit") - private val trainingMap: MutableMap = mutableMapOf() - private var currentStatsMap: MutableMap = mutableMapOf( - "Speed" to 0, - "Stamina" to 0, - "Power" to 0, - "Guts" to 0, - "Wit" to 0 - ) - private val blacklist: List = sharedPreferences.getStringSet("trainingBlacklist", setOf())!!.toList() - private var statPrioritization: List = sharedPreferences.getString("statPrioritization", "Speed|Stamina|Power|Guts|Wit")!!.split("|") - private val enablePrioritizeEnergyOptions: Boolean = sharedPreferences.getBoolean("enablePrioritizeEnergyOptions", false) - private val maximumFailureChance: Int = sharedPreferences.getInt("maximumFailureChance", 15) - private val disableTrainingOnMaxedStat: Boolean = sharedPreferences.getBoolean("disableTrainingOnMaxedStat", true) - private val focusOnSparkStatTarget: Boolean = sharedPreferences.getBoolean("focusOnSparkStatTarget", false) - private val statTargetsByDistance: MutableMap = mutableMapOf( - "Sprint" to intArrayOf(0, 0, 0, 0, 0), - "Mile" to intArrayOf(0, 0, 0, 0, 0), - "Medium" to intArrayOf(0, 0, 0, 0, 0), - "Long" to intArrayOf(0, 0, 0, 0, 0) - ) - private var preferredDistance: String = "" - private var firstTrainingCheck = true - private val currentStatCap = 1200 - private val historicalTrainingCounts: MutableMap = mutableMapOf() - - //////////////////////////////////////////////////////////////////// - //////////////////////////////////////////////////////////////////// - // Racing - private val enableFarmingFans = sharedPreferences.getBoolean("enableFarmingFans", false) - private val daysToRunExtraRaces: Int = sharedPreferences.getInt("daysToRunExtraRaces", 4) - private val disableRaceRetries: Boolean = sharedPreferences.getBoolean("disableRaceRetries", false) - val enableForceRacing = sharedPreferences.getBoolean("enableForceRacing", false) - private var raceRetries = 3 - private var raceRepeatWarningCheck = false - var encounteredRacingPopup = false - var skipRacing = false - - //////////////////////////////////////////////////////////////////// - //////////////////////////////////////////////////////////////////// - // Stops - val enableSkillPointCheck: Boolean = sharedPreferences.getBoolean("enableSkillPointCheck", false) - val skillPointsRequired: Int = sharedPreferences.getInt("skillPointCheck", 750) - private val enablePopupCheck: Boolean = sharedPreferences.getBoolean("enablePopupCheck", false) - private val enableStopOnMandatoryRace: Boolean = sharedPreferences.getBoolean("enableStopOnMandatoryRace", false) - var detectedMandatoryRaceCheck = false - - //////////////////////////////////////////////////////////////////// - //////////////////////////////////////////////////////////////////// - // Misc - private var currentDate: Date = Date(1, "Early", 1, 1) - private var inheritancesDone = 0 - private val startTime: Long = System.currentTimeMillis() - - //////////////////////////////////////////////////////////////////// - //////////////////////////////////////////////////////////////////// - - data class Training( - val name: String, - val statGains: IntArray, - val failureChance: Int, - val relationshipBars: ArrayList - ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as Training - - if (failureChance != other.failureChance) return false - if (name != other.name) return false - if (!statGains.contentEquals(other.statGains)) return false - if (relationshipBars != other.relationshipBars) return false - - return true - } - - override fun hashCode(): Int { - var result = failureChance - result = 31 * result + name.hashCode() - result = 31 * result + statGains.contentHashCode() - result = 31 * result + relationshipBars.hashCode() - return result - } - } - - data class Date( - val year: Int, - val phase: String, - val month: Int, - val turnNumber: Int - ) - - //////////////////////////////////////////////////////////////////// - //////////////////////////////////////////////////////////////////// - - /** - * Sets up stat targets for different race distances by reading values from SharedPreferences. These targets are used to determine training priorities based on the expected race distance. - */ - private fun setStatTargetsByDistances() { - val sprintSpeedTarget = sharedPreferences.getInt("trainingSprintStatTarget_speedStatTarget", 900) - val sprintStaminaTarget = sharedPreferences.getInt("trainingSprintStatTarget_staminaStatTarget", 300) - val sprintPowerTarget = sharedPreferences.getInt("trainingSprintStatTarget_powerStatTarget", 600) - val sprintGutsTarget = sharedPreferences.getInt("trainingSprintStatTarget_gutsStatTarget", 300) - val sprintWitTarget = sharedPreferences.getInt("trainingSprintStatTarget_witStatTarget", 300) - - val mileSpeedTarget = sharedPreferences.getInt("trainingMileStatTarget_speedStatTarget", 900) - val mileStaminaTarget = sharedPreferences.getInt("trainingMileStatTarget_staminaStatTarget", 300) - val milePowerTarget = sharedPreferences.getInt("trainingMileStatTarget_powerStatTarget", 600) - val mileGutsTarget = sharedPreferences.getInt("trainingMileStatTarget_gutsStatTarget", 300) - val mileWitTarget = sharedPreferences.getInt("trainingMileStatTarget_witStatTarget", 300) - - val mediumSpeedTarget = sharedPreferences.getInt("trainingMediumStatTarget_speedStatTarget", 800) - val mediumStaminaTarget = sharedPreferences.getInt("trainingMediumStatTarget_staminaStatTarget", 450) - val mediumPowerTarget = sharedPreferences.getInt("trainingMediumStatTarget_powerStatTarget", 550) - val mediumGutsTarget = sharedPreferences.getInt("trainingMediumStatTarget_gutsStatTarget", 300) - val mediumWitTarget = sharedPreferences.getInt("trainingMediumStatTarget_witStatTarget", 300) - - val longSpeedTarget = sharedPreferences.getInt("trainingLongStatTarget_speedStatTarget", 700) - val longStaminaTarget = sharedPreferences.getInt("trainingLongStatTarget_staminaStatTarget", 600) - val longPowerTarget = sharedPreferences.getInt("trainingLongStatTarget_powerStatTarget", 450) - val longGutsTarget = sharedPreferences.getInt("trainingLongStatTarget_gutsStatTarget", 300) - val longWitTarget = sharedPreferences.getInt("trainingLongStatTarget_witStatTarget", 300) - - // Set the stat targets for each distance type. - // Order: Speed, Stamina, Power, Guts, Wit - statTargetsByDistance["Sprint"] = intArrayOf(sprintSpeedTarget, sprintStaminaTarget, sprintPowerTarget, sprintGutsTarget, sprintWitTarget) - statTargetsByDistance["Mile"] = intArrayOf(mileSpeedTarget, mileStaminaTarget, milePowerTarget, mileGutsTarget, mileWitTarget) - statTargetsByDistance["Medium"] = intArrayOf(mediumSpeedTarget, mediumStaminaTarget, mediumPowerTarget, mediumGutsTarget, mediumWitTarget) - statTargetsByDistance["Long"] = intArrayOf(longSpeedTarget, longStaminaTarget, longPowerTarget, longGutsTarget, longWitTarget) - } - - /** - * Returns a formatted string of the elapsed time since the bot started as HH:MM:SS format. - * - * Source is from https://stackoverflow.com/questions/9027317/how-to-convert-milliseconds-to-hhmmss-format/9027379 - * - * @return String of HH:MM:SS format of the elapsed time. - */ - @SuppressLint("DefaultLocale") - private fun printTime(): String { - val elapsedMillis: Long = System.currentTimeMillis() - startTime - - return String.format( - "%02d:%02d:%02d", - TimeUnit.MILLISECONDS.toHours(elapsedMillis), - TimeUnit.MILLISECONDS.toMinutes(elapsedMillis) - TimeUnit.HOURS.toMinutes(TimeUnit.MILLISECONDS.toHours(elapsedMillis)), - TimeUnit.MILLISECONDS.toSeconds(elapsedMillis) - TimeUnit.MINUTES.toSeconds(TimeUnit.MILLISECONDS.toMinutes(elapsedMillis)) - ) - } - - /** - * Print the specified message to debug console and then saves the message to the log. - * - * @param message Message to be saved. - * @param tag Distinguishes between messages for where they came from. Defaults to Game's TAG. - * @param isError Flag to determine whether to display log message in console as debug or error. - * @param isOption Flag to determine whether to append a newline right after the time in the string. - */ - fun printToLog(message: String, tag: String = this.tag, isError: Boolean = false, isOption: Boolean = false) { - if (!isError) { - Log.d(tag, message) - } else { - Log.e(tag, message) - } - - // Remove the newline prefix if needed and place it where it should be. - if (message.startsWith("\n")) { - val newMessage = message.removePrefix("\n") - if (isOption) { - MessageLog.addMessage("\n" + printTime() + "\n" + newMessage) - } else { - MessageLog.addMessage("\n" + printTime() + " " + newMessage) - } - } else { - if (isOption) { - MessageLog.addMessage(printTime() + "\n" + message) - } else { - MessageLog.addMessage(printTime() + " " + message) - } - } - } - - /** - * Wait the specified seconds to account for ping or loading. - * It also checks for interruption every 100ms to allow faster interruption and checks if the game is still in the middle of loading. - * - * @param seconds Number of seconds to pause execution. - * @param skipWaitingForLoading If true, then it will skip the loading check. Defaults to false. - */ - fun wait(seconds: Double, skipWaitingForLoading: Boolean = false) { - val totalMillis = (seconds * 1000).toLong() - // Check for interruption every 100ms. - val checkInterval = 100L - - var remainingMillis = totalMillis - while (remainingMillis > 0) { - if (!BotService.isRunning) { - throw InterruptedException() - } - - val sleepTime = minOf(checkInterval, remainingMillis) - runBlocking { - delay(sleepTime) - } - remainingMillis -= sleepTime - } - - if (!skipWaitingForLoading) { - // Check if the game is still loading as well. - waitForLoading() - } - } - - /** - * Wait for the game to finish loading. - */ - fun waitForLoading() { - while (checkLoading()) { - // Avoid an infinite loop by setting the flag to true. - wait(0.5, skipWaitingForLoading = true) - } - } - - /** - * Find and tap the specified image. - * - * @param imageName Name of the button image file in the /assets/images/ folder. - * @param tries Number of tries to find the specified button. Defaults to 3. - * @param region Specify the region consisting of (x, y, width, height) of the source screenshot to template match. Defaults to (0, 0, 0, 0) which is equivalent to searching the full image. - * @param taps Specify the number of taps on the specified image. Defaults to 1. - * @param suppressError Whether or not to suppress saving error messages to the log in failing to find the button. Defaults to false. - * @return True if the button was found and clicked. False otherwise. - */ - fun findAndTapImage(imageName: String, tries: Int = 3, region: IntArray = intArrayOf(0, 0, 0, 0), taps: Int = 1, suppressError: Boolean = false): Boolean { - if (debugMode) { - printToLog("[DEBUG] Now attempting to find and click the \"$imageName\" button.") - } - - val tempLocation: Point? = imageUtils.findImage(imageName, tries = tries, region = region, suppressError = suppressError).first - - return if (tempLocation != null) { - Log.d(tag, "Found and going to tap: $imageName") - tap(tempLocation.x, tempLocation.y, imageName, taps = taps) - true - } else { - false - } - } - - /** - * Performs a tap on the screen at the coordinates and then will wait until the game processes the server request and gets a response back. - * - * @param x The x-coordinate. - * @param y The y-coordinate. - * @param imageName The template image name to use for tap location randomization. - * @param taps The number of taps. - * @param ignoreWaiting Flag to ignore checking if the game is busy loading. - */ - fun tap(x: Double, y: Double, imageName: String, taps: Int = 1, ignoreWaiting: Boolean = false) { - // Perform the tap. - gestureUtils.tap(x, y, imageName, taps = taps) - - if (!ignoreWaiting) { - // Now check if the game is waiting for a server response from the tap and wait if necessary. - wait(0.20) - waitForLoading() - } - } - - /** - * Handles the test to perform template matching to determine what the best scale will be for the device. - */ - fun startTemplateMatchingTest() { - printToLog("\n[TEST] Now beginning basic template match test on the Home screen.") - printToLog("[TEST] Template match confidence setting will be overridden for the test.\n") - val results = imageUtils.startTemplateMatchingTest() - printToLog("\n[TEST] Basic template match test complete.") - - // Print all scale/confidence combinations that worked for each template. - for ((templateName, scaleConfidenceResults) in results) { - if (scaleConfidenceResults.isNotEmpty()) { - printToLog("[TEST] All working scale/confidence combinations for $templateName:") - for (result in scaleConfidenceResults) { - printToLog("[TEST] Scale: ${result.scale}, Confidence: ${result.confidence}") - } - } else { - printToLog("[WARNING] No working scale/confidence combinations found for $templateName") - } - } - - // Then print the median scales and confidences. - val medianScales = mutableListOf() - val medianConfidences = mutableListOf() - for ((templateName, scaleConfidenceResults) in results) { - if (scaleConfidenceResults.isNotEmpty()) { - val sortedScales = scaleConfidenceResults.map { it.scale }.sorted() - val sortedConfidences = scaleConfidenceResults.map { it.confidence }.sorted() - val medianScale = sortedScales[sortedScales.size / 2] - val medianConfidence = sortedConfidences[sortedConfidences.size / 2] - medianScales.add(medianScale) - medianConfidences.add(medianConfidence) - printToLog("[TEST] Median scale for $templateName: $medianScale") - printToLog("[TEST] Median confidence for $templateName: $medianConfidence") - } - } - - if (medianScales.isNotEmpty()) { - printToLog("\n[TEST] The following are the recommended scales to set (pick one as a whole number value): $medianScales.") - printToLog("[TEST] The following are the recommended confidences to set (pick one as a whole number value): $medianConfidences.") - } else { - printToLog("\n[ERROR] No median scale/confidence can be found.", isError = true) - } - } - - /** - * Handles the test to perform OCR on the training failure chance for the current training on display. - */ - fun startSingleTrainingFailureOCRTest() { - printToLog("\n[TEST] Now beginning Single Training Failure OCR test on the Training screen for the current training on display.") - printToLog("[TEST] Note that this test is dependent on having the correct scale.") - val failureChance: Int = imageUtils.findTrainingFailureChance() - if (failureChance == -1) { - printToLog("[ERROR] Training Failure Chance detection failed.", isError = true) - } else { - printToLog("[TEST] Training Failure Chance: $failureChance") - } - } - - /** - * Handles the test to perform OCR on training failure chances for all 5 of the trainings on display. - */ - fun startComprehensiveTrainingFailureOCRTest() { - printToLog("\n[TEST] Now beginning Comprehensive Training Failure OCR test on the Training screen for all 5 trainings on display.") - printToLog("[TEST] Note that this test is dependent on having the correct scale.") - analyzeTrainings(test = true) - printTrainingMap() - } - - //////////////////////////////////////////////////////////////////////////////////////////////////// - //////////////////////////////////////////////////////////////////////////////////////////////////// - //////////////////////////////////////////////////////////////////////////////////////////////////// - //////////////////////////////////////////////////////////////////////////////////////////////////// - // Helper functions to be shared amongst the various Campaigns. - - //////////////////////////////////////////////////////////////////////////////////////////////////// - //////////////////////////////////////////////////////////////////////////////////////////////////// - // Functions to check what screen the bot is at. - - /** - * Checks if the bot is at the Main screen or the screen with available options to undertake. - * This will also make sure that the Main screen does not contain the option to select a race. - * - * @return True if the bot is at the Main screen. Otherwise false. - */ - fun checkMainScreen(): Boolean { - printToLog("[INFO] Checking if the bot is sitting at the Main screen.") - return if (imageUtils.findImage("tazuna", tries = 1, region = imageUtils.regionTopHalf).first != null && - imageUtils.findImage("race_select_mandatory", tries = 1, region = imageUtils.regionBottomHalf, suppressError = true).first == null) { - printToLog("\n[INFO] Current bot location is at Main screen.") - - // Perform updates here if necessary. - updateDate() - if (preferredDistance == "") updatePreferredDistance() - true - } else if (!enablePopupCheck && imageUtils.findImage("cancel", tries = 1, region = imageUtils.regionBottomHalf).first != null && - imageUtils.findImage("race_confirm", tries = 1, region = imageUtils.regionBottomHalf).first != null) { - // This popup is most likely the insufficient fans popup. Force an extra race to catch up on the required fans. - printToLog("[INFO] There is a possible insufficient fans or maiden race popup.") - encounteredRacingPopup = true - skipRacing = false - true - } else { - false - } - } - - /** - * Checks if the bot is at the Training Event screen with an active event with options to select on screen. - * - * @return True if the bot is at the Training Event screen. Otherwise false. - */ - fun checkTrainingEventScreen(): Boolean { - printToLog("[INFO] Checking if the bot is sitting on the Training Event screen.") - return if (imageUtils.findImage("training_event_active", tries = 1, region = imageUtils.regionMiddle).first != null) { - printToLog("\n[INFO] Current bot location is at Training Event screen.") - true - } else { - false - } - } - - /** - * Checks if the bot is at the preparation screen with a mandatory race needing to be completed. - * - * @return True if the bot is at the Main screen with a mandatory race. Otherwise false. - */ - fun checkMandatoryRacePrepScreen(): Boolean { - printToLog("[INFO] Checking if the bot is sitting on the Race Preparation screen.") - return if (imageUtils.findImage("race_select_mandatory", tries = 1, region = imageUtils.regionBottomHalf).first != null) { - printToLog("\n[INFO] Current bot location is at the preparation screen with a mandatory race ready to be completed.") - true - } else if (imageUtils.findImage("race_select_mandatory_goal", tries = 1, region = imageUtils.regionMiddle).first != null) { - // Most likely the user started the bot here so a delay will need to be placed to allow the start banner of the Service to disappear. - wait(2.0) - printToLog("\n[INFO] Current bot location is at the Race Selection screen with a mandatory race needing to be selected.") - // Walk back to the preparation screen. - findAndTapImage("back", tries = 1, region = imageUtils.regionBottomHalf) - wait(1.0) - true - } else { - false - } - } - - /** - * Checks if the bot is at the Racing screen waiting to be skipped or done manually. - * - * @return True if the bot is at the Racing screen. Otherwise, false. - */ - fun checkRacingScreen(): Boolean { - printToLog("[INFO] Checking if the bot is sitting on the Racing screen.") - return if (imageUtils.findImage("race_change_strategy", tries = 1, region = imageUtils.regionBottomHalf).first != null) { - printToLog("\n[INFO] Current bot location is at the Racing screen waiting to be skipped or done manually.") - true - } else { - false - } - } - - /** - * Checks if the day number is odd to be eligible to run an extra race, excluding Summer where extra racing is not allowed. - * - * @return True if the day number is odd. Otherwise false. - */ - fun checkExtraRaceAvailability(): Boolean { - val dayNumber = imageUtils.determineDayForExtraRace() - printToLog("\n[INFO] Current remaining number of days before the next mandatory race: $dayNumber.") - - // If the setting to force racing extra races is enabled, always return true. - if (enableForceRacing) return true - - return enableFarmingFans && dayNumber % daysToRunExtraRaces == 0 && !raceRepeatWarningCheck && - imageUtils.findImage("race_select_extra_locked_uma_finals", tries = 1, region = imageUtils.regionBottomHalf).first == null && - imageUtils.findImage("race_select_extra_locked", tries = 1, region = imageUtils.regionBottomHalf).first == null && - imageUtils.findImage("recover_energy_summer", tries = 1, region = imageUtils.regionBottomHalf).first == null - } - - /** - * Checks if the bot is at the Ending screen detailing the overall results of the run. - * - * @return True if the bot is at the Ending screen. Otherwise false. - */ - fun checkEndScreen(): Boolean { - return if (imageUtils.findImage("complete_career", tries = 1, region = imageUtils.regionBottomHalf).first != null) { - printToLog("\n[END] Bot has reached the End screen.") - true - } else { - false - } - } - - /** - * Checks if the bot has a injury. - * - * @return True if the bot has a injury. Otherwise false. - */ - fun checkInjury(): Boolean { - val recoverInjuryLocation = imageUtils.findImage("recover_injury", tries = 1, region = imageUtils.regionBottomHalf).first - return if (recoverInjuryLocation != null && imageUtils.checkColorAtCoordinates( - recoverInjuryLocation.x.toInt(), - recoverInjuryLocation.y.toInt() + 15, - intArrayOf(151, 105, 243), - 10 - )) { - if (findAndTapImage("recover_injury", tries = 1, region = imageUtils.regionBottomHalf)) { - wait(0.3) - if (imageUtils.confirmLocation("recover_injury", tries = 1, region = imageUtils.regionMiddle)) { - printToLog("\n[INFO] Injury detected and attempted to heal.") - true - } else { - false - } - } else { - printToLog("\n[WARNING] Injury detected but attempt to rest failed.") - false - } - } else { - printToLog("\n[INFO] No injury detected.") - false - } - } - - /** - * Checks if the bot is at a "Now Loading..." screen or if the game is awaiting for a server response. This may cause significant delays in normal bot processes. - * - * @return True if the game is still loading or is awaiting for a server response. Otherwise, false. - */ - fun checkLoading(): Boolean { - printToLog("[INFO] Now checking if the game is still loading...") - return if (imageUtils.findImage("connecting", tries = 1, region = imageUtils.regionTopHalf, suppressError = true).first != null) { - printToLog("[INFO] Detected that the game is awaiting a response from the server from the \"Connecting\" text at the top of the screen. Waiting...") - true - } else if (imageUtils.findImage("now_loading", tries = 1, region = imageUtils.regionBottomHalf, suppressError = true).first != null) { - printToLog("[INFO] Detected that the game is still loading from the \"Now Loading\" text at the bottom of the screen. Waiting...") - true - } else { - false - } - } - - //////////////////////////////////////////////////////////////////////////////////////////////////// - //////////////////////////////////////////////////////////////////////////////////////////////////// - // Functions to execute Training by determining failure percentages, overall stat gains and stat weights. - - /** - * The entry point for handling Training. - */ - fun handleTraining() { - printToLog("\n[TRAINING] Starting Training process...") - - // Enter the Training screen. - if (findAndTapImage("training_option", region = imageUtils.regionBottomHalf)) { - // Acquire the percentages and stat gains for each training. - wait(0.5) - analyzeTrainings() - - if (trainingMap.isEmpty()) { - printToLog("[TRAINING] Backing out of Training and returning on the Main screen.") - findAndTapImage("back", region = imageUtils.regionBottomHalf) - wait(1.0) - - if (checkMainScreen()) { - printToLog("[TRAINING] Will recover energy due to either failure chance was high enough to do so or no failure chances were detected via OCR.") - recoverEnergy() - } else { - printToLog("[ERROR] Could not head back to the Main screen in order to recover energy.") - } - } else { - // Now select the training option with the highest weight. - executeTraining() - - firstTrainingCheck = false - } - - raceRepeatWarningCheck = false - printToLog("\n[TRAINING] Training process completed.") - } else { - printToLog("[ERROR] Cannot start the Training process. Moving on...", isError = true) - } - } - - /** - * Analyze all 5 Trainings for their details including stat gains, relationship bars, etc. - * - * @param test Flag that forces the failure chance through even if it is not in the acceptable range for testing purposes. - */ - private fun analyzeTrainings(test: Boolean = false) { - printToLog("\n[TRAINING] Now starting process to analyze all 5 Trainings.") - - // Acquire the position of the speed stat text. - val (speedStatTextLocation, _) = if (campaign == "Ao Haru") { - imageUtils.findImage("aoharu_stat_speed", tries = 1, region = imageUtils.regionBottomHalf) - } else { - imageUtils.findImage("stat_speed", tries = 1, region = imageUtils.regionBottomHalf) - } - - if (speedStatTextLocation != null) { - // Perform a percentage check of Speed training to see if the bot has enough energy to do training. As a result, Speed training will be the one selected for the rest of the algorithm. - if (!imageUtils.confirmLocation("speed_training", tries = 1, region = imageUtils.regionTopHalf, suppressError = true)) { - findAndTapImage("training_speed", region = imageUtils.regionBottomHalf) - wait(0.5) - } - - val failureChance: Int = imageUtils.findTrainingFailureChance() - if (failureChance == -1) { - printToLog("[WARNING] Skipping training due to not being able to confirm whether or not the bot is at the Training screen.") - return - } - - if (test || failureChance <= maximumFailureChance) { - printToLog("[TRAINING] $failureChance% within acceptable range of ${maximumFailureChance}%. Proceeding to acquire all other percentages and total stat increases...") - - // Iterate through every training that is not blacklisted. - trainings.forEachIndexed { index, training -> - if (blacklist.getOrElse(index) { "" } == training) { - printToLog("[TRAINING] Skipping $training training due to being blacklisted.") - return@forEachIndexed - } - - // Select the Training to make it active except Speed Training since that is already selected at the start. - val newX: Double = when (training) { - "Stamina" -> { - 280.0 - } - "Power" -> { - 402.0 - } - "Guts" -> { - 591.0 - } - "Wit" -> { - 779.0 - } - else -> { - 0.0 - } - } - - if (newX != 0.0) { - if (imageUtils.isTablet) { - if (training == "Stamina") { - tap( - speedStatTextLocation.x + imageUtils.relWidth((newX * 1.05).toInt()), - speedStatTextLocation.y + imageUtils.relHeight((319 * 1.50).toInt()), - "training_option_circular", - ignoreWaiting = true - ) - } else { - tap( - speedStatTextLocation.x + imageUtils.relWidth((newX * 1.36).toInt()), - speedStatTextLocation.y + imageUtils.relHeight((319 * 1.50).toInt()), - "training_option_circular", - ignoreWaiting = true - ) - } - } else { - tap( - speedStatTextLocation.x + imageUtils.relWidth(newX.toInt()), - speedStatTextLocation.y + imageUtils.relHeight(319), - "training_option_circular", - ignoreWaiting = true - ) - } - } - - // Update the object in the training map. - // Use CountDownLatch to run the 3 operations in parallel to cut down on processing time. - val latch = CountDownLatch(3) - - // Variables to store results from parallel threads. - var statGains: IntArray = intArrayOf() - var failureChance: Int = -1 - var relationshipBars: ArrayList = arrayListOf() - - // Get the Points and source Bitmap beforehand before starting the threads to make them safe for parallel processing. - val (skillPointsLocation, sourceBitmap) = imageUtils.findImage("skill_points", tries = 1, region = imageUtils.regionMiddle) - val (trainingSelectionLocation, _) = imageUtils.findImage("training_failure_chance", tries = 1, region = imageUtils.regionBottomHalf) - - // Thread 1: Determine stat gains. - Thread { - try { - statGains = imageUtils.determineStatGainFromTraining(training, sourceBitmap, skillPointsLocation!!) - } catch (e: Exception) { - printToLog("[ERROR] Error in determineStatGainFromTraining: ${e.stackTraceToString()}", isError = true) - statGains = intArrayOf(0, 0, 0, 0, 0) - } finally { - latch.countDown() - } - }.start() - - // Thread 2: Find failure chance. - Thread { - try { - failureChance = imageUtils.findTrainingFailureChance(sourceBitmap, trainingSelectionLocation!!) - } catch (e: Exception) { - printToLog("[ERROR] Error in findTrainingFailureChance: ${e.stackTraceToString()}", isError = true) - failureChance = -1 - } finally { - latch.countDown() - } - }.start() - - // Thread 3: Analyze relationship bars. - Thread { - try { - relationshipBars = imageUtils.analyzeRelationshipBars(sourceBitmap) - } catch (e: Exception) { - printToLog("[ERROR] Error in analyzeRelationshipBars: ${e.stackTraceToString()}", isError = true) - relationshipBars = arrayListOf() - } finally { - latch.countDown() - } - }.start() - - // Wait for all threads to complete. - try { - latch.await(10, TimeUnit.SECONDS) - } catch (_: InterruptedException) { - printToLog("[ERROR] Parallel training analysis timed out", isError = true) - } - - val newTraining = Training( - name = training, - statGains = statGains, - failureChance = failureChance, - relationshipBars = relationshipBars - ) - trainingMap.put(training, newTraining) - } - - printToLog("[TRAINING] Process to analyze all 5 Trainings complete.") - } else { - // Clear the Training map if the bot failed to have enough energy to conduct the training. - printToLog("[TRAINING] $failureChance% is not within acceptable range of ${maximumFailureChance}%. Proceeding to recover energy.") - trainingMap.clear() - } - } - } - - /** - * Recommends the best training option based on current game state and strategic priorities. - * - * This function implements a sophisticated training recommendation system that adapts to different - * phases of the game. It uses different scoring algorithms depending on the current game year: - * - * **Early Game (Pre-Debut/Year 1):** - * - Focuses on relationship building using `scoreFriendshipTraining()` - * - Prioritizes training options that build friendship bars, especially blue bars - * - Ignores stat gains in favor of relationship development - * - * **Mid/Late Game (Year 2+):** - * - Uses comprehensive scoring via `scoreStatTrainingEnhanced()` - * - Combines stat efficiency (60-70%), relationship building (10%), and context bonuses (30%) - * - Adapts weighting based on whether relationship bars are present - * - * The scoring system considers multiple factors: - * - **Stat Efficiency:** How well training helps achieve target stats for the preferred race distance - * - **Relationship Building:** Value of friendship bar progress with diminishing returns - * - **Context Bonuses:** Phase-specific bonuses and stat gain thresholds - * - **Blacklist Compliance:** Excludes blacklisted training options - * - **Stat Cap Respect:** Avoids training that would exceed stat caps when enabled - * - * @return The name of the recommended training option, or empty string if no suitable option found. - */ - private fun recommendTraining(): String { - /** - * Scores the currently selected training option during Junior Year based on friendship bar progress. - * - * This algorithm prefers training options with the least relationship progress (especially blue bars). - * It ignores stat gains unless all else is equal. - * - * @param training The training option to evaluate. - * - * @return A score representing relationship-building value. - */ - fun scoreFriendshipTraining(training: Training): Double { - // Ignore the blacklist in favor of making sure we build up the relationship bars as fast as possible. - printToLog("\n[TRAINING] Starting process to score ${training.name} Training with a focus on building relationship bars.") - - val barResults = training.relationshipBars - if (barResults.isEmpty()) return Double.NEGATIVE_INFINITY - - var score = 0.0 - for (bar in barResults) { - val contribution = when (bar.dominantColor) { - "orange" -> 0.0 - "green" -> 1.0 - "blue" -> 2.5 - else -> 0.0 - } - score += contribution - } - - printToLog("[TRAINING] ${training.name} Training has a score of ${decimalFormat.format(score)} with a focus on building relationship bars.") - return score - } - - /** - * Calculates the efficiency score for stat gains based on target achievement and priority weights. - * - * This function evaluates how well a training option helps achieve stat targets by considering: - * - The gap between current stats and target stats - * - Priority weights that vary by game year (higher priority in later years) - * - Efficiency bonuses for closing gaps vs diminishing returns for overage - * - Spark stat target focus when enabled (Speed, Stamina, Power to 600+) - * - Enhanced priority weighting for top 3 stats to prevent target completion from overriding large gains - * - * @param training The training option to evaluate. - * @param target Array of target stat values for the preferred race distance. - * - * @return A normalized score (0-100) representing stat efficiency. - */ - fun calculateStatEfficiencyScore(training: Training, target: IntArray): Double { - var score = 100.0 - - for ((index, stat) in trainings.withIndex()) { - val currentStat = currentStatsMap.getOrDefault(stat, 0) - val targetStat = target.getOrElse(index) { 0 } - val statGain = training.statGains.getOrElse(index) { 0 } - val remaining = targetStat - currentStat - - if (statGain > 0) { - // Priority weight based on the current state of the game. - val priorityIndex = statPrioritization.indexOf(stat) - val priorityWeight = if (priorityIndex != -1) { - // Enhanced priority weighting for top 3 stats - val top3Bonus = when (priorityIndex) { - 0 -> 2.0 - 1 -> 1.5 - 2 -> 1.1 - else -> 1.0 - } - - val baseWeight = when { - currentDate.year == 1 || currentDate.phase == "Pre-Debut" -> 1.0 + (0.1 * (statPrioritization.size - priorityIndex)) / statPrioritization.size - currentDate.year == 2 -> 1.0 + (0.3 * (statPrioritization.size - priorityIndex)) / statPrioritization.size - currentDate.year == 3 -> 1.0 + (0.5 * (statPrioritization.size - priorityIndex)) / statPrioritization.size - else -> 1.0 - } - - baseWeight * top3Bonus - } else { - 0.5 // Lower weight for non-prioritized stats. - } - - Log.d(tag, "[DEBUG] Priority Weight: $priorityWeight") - - // Calculate efficiency based on remaining gap between the current stat and the target. - var efficiency = if (remaining > 0) { - // Stat is below target, but reduce the bonus when very close to the target. - Log.d(tag, "[DEBUG] Giving bonus for remaining efficiency.") - val gapRatio = remaining.toDouble() / targetStat - val targetBonus = when { - gapRatio > 0.1 -> 1.5 - gapRatio > 0.05 -> 1.25 - else -> 1.1 - } - targetBonus + (statGain.toDouble() / remaining).coerceAtMost(1.0) - } else { - // Stat is above target, give a diminishing bonus based on how much over. - Log.d(tag, "[DEBUG] Stat is above target so giving diminishing bonus.") - val overageRatio = (statGain.toDouble() / (-remaining + statGain)) - 1.0 + overageRatio - } - - Log.d(tag, "[DEBUG] Efficiency: $efficiency") - - // Apply Spark stat target focus when enabled. - if (focusOnSparkStatTarget) { - val sparkTarget = 600 - val sparkRemaining = sparkTarget - currentStat - - // Check if this is a Spark stat (Speed, Stamina, Power) and it's below 600. - if ((stat == "Speed" || stat == "Stamina" || stat == "Power") && sparkRemaining > 0) { - // Boost efficiency for Spark stats that are below 600. - val sparkEfficiency = 2.0 + (statGain.toDouble() / sparkRemaining).coerceAtMost(1.0) - // Use the higher of the two efficiencies (original target vs spark target). - efficiency = maxOf(efficiency, sparkEfficiency) - } - } - - score += statGain * 2 - score += (statGain * 2) * (efficiency * priorityWeight) - Log.d(tag, "[DEBUG] Score: $score") - } - } - - return score.coerceAtMost(1000.0) - } - - /** - * Calculates relationship building score with diminishing returns. - * - * Evaluates the value of relationship bars based on their color and fill level: - * - Blue bars: 2.5 points (highest priority) - * - Green bars: 1.0 points (medium priority) - * - Orange bars: 0.0 points (no value) - * - * Applies diminishing returns as bars fill up and early game bonuses for relationship building. - * - * @param training The training option to evaluate. - * - * @return A normalized score (0-100) representing relationship building value. - */ - fun calculateRelationshipScore(training: Training): Double { - if (training.relationshipBars.isEmpty()) return 0.0 - - var score = 0.0 - var maxScore = 0.0 - - for (bar in training.relationshipBars) { - val baseValue = when (bar.dominantColor) { - "orange" -> 0.0 - "green" -> 1.0 - "blue" -> 2.5 - else -> 0.0 - } - - if (baseValue > 0) { - // Apply diminishing returns for relationship building. - val fillLevel = bar.fillPercent / 100.0 - val diminishingFactor = 1.0 - (fillLevel * 0.5) // Less valuable as bars fill up. - - // Early game bonus for relationship building. - val earlyGameBonus = if (currentDate.year == 1 || currentDate.phase == "Pre-Debut") 1.3 else 1.0 - - val contribution = baseValue * diminishingFactor * earlyGameBonus - score += contribution - maxScore += 2.5 * 1.3 - } - } - - return if (maxScore > 0) (score / maxScore * 100.0) else 0.0 - } - - /** - * Calculates context-aware bonuses and penalties based on game phase and training properties. - * - * Applies various bonuses including: - * - Phase-specific bonuses (relationship focus in early game, stat efficiency in later years) - * - Stat gain thresholds that provide additional bonuses - * - * @param training The training option to evaluate. - * - * @return A context score between 0-200 representing situational bonuses. - */ - fun calculateContextScore(training: Training): Double { - // Start with neutral score. - var score = 100.0 - - // Bonuses for each game phase. - when { - currentDate.year == 1 || currentDate.phase == "Pre-Debut" -> { - // Prefer relationship building and balanced stat gains. - if (training.relationshipBars.isNotEmpty()) score += 50.0 - if (training.statGains.sum() > 15) score += 50.0 - } - currentDate.year == 2 -> { - // Focus on stat efficiency. - score += 50.0 - if (training.statGains.sum() > 20) score += 100.0 - } - currentDate.year == 3 -> { - // Prioritize target achievement - score += 100.0 - if (training.statGains.sum() > 40) score += 200.0 - } - } - - // Bonuses for skill hints. - val skillHintLocations = imageUtils.findAll( - "stat_skill_hint", - region = intArrayOf( - MediaProjectionService.displayWidth - (MediaProjectionService.displayWidth / 3), - 0, - (MediaProjectionService.displayWidth / 3), - MediaProjectionService.displayHeight - (MediaProjectionService.displayHeight / 3) - ) - ) - score += 100.0 * skillHintLocations.size - - return score.coerceIn(0.0, 1000.0) - } - - /** - * Performs comprehensive scoring of training options using multiple weighted factors. - * - * This scoring system combines three main components: - * - Stat efficiency (60-70% weight): How well the training helps achieve stat targets - * - Relationship building (10% weight): Value of friendship bar progress - * - Context bonuses (30% weight): Phase-specific bonuses, etc. - * - * The weighting changes based on whether relationship bars are present: - * - With relationship bars: 60% stat, 10% relationship, 30% context - * - Without relationship bars: 70% stat, 0% relationship, 30% context - * - * @param training The training option to evaluate. - * - * @return A normalized score (1-1000) representing overall training value. - */ - fun scoreStatTraining(training: Training): Double { - if (training.name in blacklist) return 0.0 - - // Don't score for stats that are maxed or would be maxed. - if ((disableTrainingOnMaxedStat && currentStatsMap[training.name]!! >= currentStatCap) || - (currentStatsMap.getOrDefault(training.name, 0) + training.statGains[trainings.indexOf(training.name)] >= currentStatCap)) { - return 0.0 - } - - printToLog("\n[TRAINING] Starting scoring for ${training.name} Training.") - - val target = statTargetsByDistance[preferredDistance] ?: intArrayOf(600, 600, 600, 300, 300) - - var totalScore = 0.0 - var maxPossibleScore = 0.0 - - // 1. Stat Efficiency scoring - val statScore = calculateStatEfficiencyScore(training, target) - - // 2. Friendship scoring - val relationshipScore = calculateRelationshipScore(training) - - // 3. Context-aware scoring - val contextScore = calculateContextScore(training) - - if (training.relationshipBars.isNotEmpty()) { - totalScore += statScore * 0.6 - maxPossibleScore += 100.0 * 0.6 - - totalScore += relationshipScore * 0.1 - maxPossibleScore += 100.0 * 0.1 - - totalScore += contextScore * 0.3 - maxPossibleScore += 100.0 * 0.3 - } else { - totalScore += statScore * 0.7 - maxPossibleScore += 100.0 * 0.7 - - totalScore += contextScore * 0.3 - maxPossibleScore += 100.0 * 0.3 - } - - printToLog( - "[TRAINING] Scores | Current Stat: ${currentStatsMap[training.name]}, Target Stat: ${target[trainings.indexOf(training.name)]}, " + - "Stat Efficiency: ${decimalFormat.format(statScore)}, Relationship: ${decimalFormat.format(relationshipScore)}, " + - "Context: ${decimalFormat.format(contextScore)}" - ) - - // Normalize the score. - val normalizedScore = (totalScore / maxPossibleScore * 100.0).coerceIn(1.0, 1000.0) - - printToLog("[TRAINING] Enhanced final score for ${training.name} Training: ${decimalFormat.format(normalizedScore)}/1000.0") - - return normalizedScore - } - - // Decide which scoring function to use based on the current phase or year. - // Junior Year will focus on building relationship bars. - val best = if (currentDate.phase == "Pre-Debut" || currentDate.year == 1) { - trainingMap.values.maxByOrNull { scoreFriendshipTraining(it) } - } else trainingMap.values.maxByOrNull { scoreStatTraining(it) } - - return if (best != null) { - historicalTrainingCounts.put(best.name, historicalTrainingCounts.getOrDefault(best.name, 0) + 1) - best.name - } else { - trainingMap.keys.firstOrNull { it !in blacklist } ?: "" - } - } - - /** - * Execute the training with the highest stat weight. - */ - private fun executeTraining() { - printToLog("\n********************") - printToLog("[TRAINING] Now starting process to execute training...") - val trainingSelected = recommendTraining() - - if (trainingSelected != "") { - printTrainingMap() - printToLog("[TRAINING] Executing the $trainingSelected Training.") - findAndTapImage("training_${trainingSelected.lowercase()}", region = imageUtils.regionBottomHalf, taps = 3) - printToLog("[TRAINING] Process to execute training completed.") - } else { - printToLog("[TRAINING] Conditions have not been met so training will not be done.") - } - - printToLog("********************\n") - - // Now reset the Training map. - trainingMap.clear() - } - - //////////////////////////////////////////////////////////////////////////////////////////////////// - //////////////////////////////////////////////////////////////////////////////////////////////////// - // Functions to handle Training Events with the help of the TextDetection class. - - /** - * Start text detection to determine what Training Event it is and the event rewards for each option. - * It will then select the best option according to the user's preferences. By default, it will choose the first option. - */ - fun handleTrainingEvent() { - printToLog("\n[TRAINING-EVENT] Starting Training Event process...") - - val (eventRewards, confidence) = textDetection.start() - - val regex = Regex("[a-zA-Z]+") - var optionSelected = 0 - - // Double check if the bot is at the Main screen or not. - if (checkMainScreen()) { - return - } - - if (eventRewards.isNotEmpty() && eventRewards[0] != "") { - // Initialize the List. - val selectionWeight = List(eventRewards.size) { 0 }.toMutableList() - - // Sum up the stat gains with additional weight applied to stats that are prioritized. - eventRewards.forEach { reward -> - val formattedReward: List = reward.split("\n") - - formattedReward.forEach { line -> - val formattedLine: String = regex - .replace(line, "") - .replace("(", "") - .replace(")", "") - .trim() - .lowercase() - - printToLog("[TRAINING-EVENT] Original line is \"$line\".") - printToLog("[TRAINING-EVENT] Formatted line is \"$formattedLine\".") - - var priorityStatCheck = false - if (line.lowercase().contains("energy")) { - val finalEnergyValue = try { - val energyValue = if (formattedLine.contains("/")) { - val splits = formattedLine.split("/") - var sum = 0 - for (split in splits) { - sum += try { - split.trim().toInt() - } catch (_: NumberFormatException) { - printToLog("[WARNING] Could not convert $formattedLine to a number for energy with a forward slash.") - 20 - } - } - sum - } else { - formattedLine.toInt() - } - - if (enablePrioritizeEnergyOptions) { - energyValue * 100 - } else { - energyValue * 3 - } - } catch (_: NumberFormatException) { - printToLog("[WARNING] Could not convert $formattedLine to a number for energy.") - 20 - } - printToLog("[TRAINING-EVENT] Adding weight for option #${optionSelected + 1} of $finalEnergyValue for energy.") - selectionWeight[optionSelected] += finalEnergyValue - } else if (line.lowercase().contains("mood")) { - val moodWeight = if (formattedLine.contains("-")) -50 else 50 - printToLog("[TRAINING-EVENT Adding weight for option#${optionSelected + 1} of $moodWeight for ${if (moodWeight > 0) "positive" else "negative"} mood gain.") - selectionWeight[optionSelected] += moodWeight - } else if (line.lowercase().contains("bond")) { - printToLog("[TRAINING-EVENT] Adding weight for option #${optionSelected + 1} of 20 for bond.") - selectionWeight[optionSelected] += 20 - } else if (line.lowercase().contains("event chain ended")) { - printToLog("[TRAINING-EVENT] Adding weight for option #${optionSelected + 1} of -50 for event chain ending.") - selectionWeight[optionSelected] += -50 - } else if (line.lowercase().contains("(random)")) { - printToLog("[TRAINING-EVENT] Adding weight for option #${optionSelected + 1} of -10 for random reward.") - selectionWeight[optionSelected] += -10 - } else if (line.lowercase().contains("randomly")) { - printToLog("[TRAINING-EVENT] Adding weight for option #${optionSelected + 1} of 50 for random options.") - selectionWeight[optionSelected] += 50 - } else if (line.lowercase().contains("hint")) { - printToLog("[TRAINING-EVENT] Adding weight for option #${optionSelected + 1} of 25 for skill hint(s).") - selectionWeight[optionSelected] += 25 - } else if (line.lowercase().contains("skill")) { - val finalSkillPoints = if (formattedLine.contains("/")) { - val splits = formattedLine.split("/") - var sum = 0 - for (split in splits) { - sum += try { - split.trim().toInt() - } catch (_: NumberFormatException) { - printToLog("[WARNING] Could not convert $formattedLine to a number for skill points with a forward slash.") - 10 - } - } - sum - } else { - formattedLine.toInt() - } - printToLog("[TRAINING-EVENT] Adding weight for option #${optionSelected + 1} of $finalSkillPoints for skill points.") - selectionWeight[optionSelected] += finalSkillPoints - } else { - // Apply inflated weights to the prioritized stats based on their order. - statPrioritization.forEachIndexed { index, stat -> - if (line.contains(stat)) { - // Calculate weight bonus based on position (higher priority = higher bonus). - val priorityBonus = when (index) { - 0 -> 50 - 1 -> 40 - 2 -> 30 - 3 -> 20 - else -> 10 - } - - val finalStatValue = try { - priorityStatCheck = true - if (formattedLine.contains("/")) { - val splits = formattedLine.split("/") - var sum = 0 - for (split in splits) { - sum += try { - split.trim().toInt() - } catch (_: NumberFormatException) { - printToLog("[WARNING] Could not convert $formattedLine to a number for a priority stat with a forward slash.") - 10 - } - } - sum + priorityBonus - } else { - formattedLine.toInt() + priorityBonus - } - } catch (_: NumberFormatException) { - printToLog("[WARNING] Could not convert $formattedLine to a number for a priority stat.") - priorityStatCheck = false - 10 - } - printToLog("[TRAINING-EVENT] Adding weight for option #${optionSelected + 1} of $finalStatValue for prioritized stat.") - selectionWeight[optionSelected] += finalStatValue - } - } - - // Apply normal weights to the rest of the stats. - if (!priorityStatCheck) { - val finalStatValue = try { - if (formattedLine.contains("/")) { - val splits = formattedLine.split("/") - var sum = 0 - for (split in splits) { - sum += try { - split.trim().toInt() - } catch (_: NumberFormatException) { - printToLog("[WARNING] Could not convert $formattedLine to a number for non-prioritized stat with a forward slash.") - 10 - } - } - sum - } else { - formattedLine.toInt() - } - } catch (_: NumberFormatException) { - printToLog("[WARNING] Could not convert $formattedLine to a number for non-prioritized stat.") - 10 - } - printToLog("[TRAINING-EVENT] Adding weight for option #${optionSelected + 1} of $finalStatValue for non-prioritized stat.") - selectionWeight[optionSelected] += finalStatValue - } - } - - printToLog("[TRAINING-EVENT] Final weight for option #${optionSelected + 1} is: ${selectionWeight[optionSelected]}.") - } - - optionSelected++ - } - - // Select the best option that aligns with the stat prioritization made in the Training options. - var max: Int? = selectionWeight.maxOrNull() - if (max == null) { - max = 0 - optionSelected = 0 - } else { - optionSelected = selectionWeight.indexOf(max) - } - - // Print the selection weights. - printToLog("[TRAINING-EVENT] Selection weights for each option:") - selectionWeight.forEachIndexed { index, weight -> - printToLog("Option ${index + 1}: $weight") - } - - // Format the string to display each option's rewards. - var eventRewardsString = "" - var optionNumber = 1 - eventRewards.forEach { reward -> - eventRewardsString += "Option $optionNumber: \"$reward\"\n" - optionNumber += 1 - } - - val minimumConfidence = sharedPreferences.getInt("confidence", 80).toDouble() / 100.0 - val resultString = if (confidence >= minimumConfidence) { - "[TRAINING-EVENT] For this Training Event consisting of:\n$eventRewardsString\nThe bot will select Option ${optionSelected + 1}: \"${eventRewards[optionSelected]}\" with a " + - "selection weight of $max." - } else { - "[TRAINING-EVENT] Since the confidence was less than the set minimum, first option will be selected." - } - - printToLog(resultString) - } else { - printToLog("[TRAINING-EVENT] First option will be selected since OCR failed to detect anything.") - optionSelected = 0 - } - - val trainingOptionLocations: ArrayList = imageUtils.findAll("training_event_active") - val selectedLocation: Point? = if (trainingOptionLocations.isNotEmpty()) { - // Account for the situation where it could go out of bounds if the detected event options is incorrect and gives too many results. - try { - trainingOptionLocations[optionSelected] - } catch (_: IndexOutOfBoundsException) { - // Default to the first option. - trainingOptionLocations[0] - } - } else { - imageUtils.findImage("training_event_active", tries = 5, region = imageUtils.regionMiddle).first - } - - if (selectedLocation != null) { - tap(selectedLocation.x + imageUtils.relWidth(100), selectedLocation.y, "training_event_active") - } - - printToLog("[TRAINING-EVENT] Process to handle detected Training Event completed.") - } - - //////////////////////////////////////////////////////////////////////////////////////////////////// - //////////////////////////////////////////////////////////////////////////////////////////////////// - // Functions to handle Race Events. - - /** - * The entry point for handling mandatory or extra races. - * - * @return True if the mandatory/extra race was completed successfully. Otherwise false. - */ - fun handleRaceEvents(): Boolean { - printToLog("\n[RACE] Starting Racing process...") - if (encounteredRacingPopup) { - // Dismiss the insufficient fans popup here and head to the Race Selection screen. - findAndTapImage("race_confirm", tries = 1, region = imageUtils.regionBottomHalf) - encounteredRacingPopup = false - wait(1.0) - } - - // If there are no races available, cancel the racing process. - if (imageUtils.findImage("race_none_available", tries = 1, region = imageUtils.regionMiddle, suppressError = true).first != null) { - printToLog("[RACE] There are no races to compete in. Canceling the racing process and doing something else.") - return false - } - - skipRacing = false - - // First, check if there is a mandatory or a extra race available. If so, head into the Race Selection screen. - // Note: If there is a mandatory race, the bot would be on the Home screen. - // Otherwise, it would have found itself at the Race Selection screen already (by way of the insufficient fans popup). - if (findAndTapImage("race_select_mandatory", tries = 1, region = imageUtils.regionBottomHalf)) { - printToLog("\n[RACE] Starting process for handling a mandatory race.") - - if (enableStopOnMandatoryRace) { - detectedMandatoryRaceCheck = true - return false - } else if (enableForceRacing) { - findAndTapImage("ok", tries = 1, region = imageUtils.regionMiddle) - wait(1.0) - } - - // There is a mandatory race. Now confirm the selection and the resultant popup and then wait for the game to load. - wait(2.0) - printToLog("[RACE] Confirming the mandatory race selection.") - findAndTapImage("race_confirm", tries = 3, region = imageUtils.regionBottomHalf) - wait(1.0) - printToLog("[RACE] Confirming any popup from the mandatory race selection.") - findAndTapImage("race_confirm", tries = 3, region = imageUtils.regionBottomHalf) - wait(2.0) - - waitForLoading() - - // Skip the race if possible, otherwise run it manually. - val resultCheck: Boolean = if (imageUtils.findImage("race_skip_locked", tries = 5, region = imageUtils.regionBottomHalf).first == null) { - skipRace() - } else { - manualRace() - } - - finishRace(resultCheck) - - printToLog("[RACE] Racing process for Mandatory Race is completed.") - return true - } else if (currentDate.phase != "Pre-Debut" && findAndTapImage("race_select_extra", tries = 1, region = imageUtils.regionBottomHalf)) { - printToLog("\n[RACE] Starting process for handling a extra race.") - - // If there is a popup warning about repeating races 3+ times, stop the process and do something else other than racing. - if (imageUtils.findImage("race_repeat_warning").first != null) { - if (!enableForceRacing) { - raceRepeatWarningCheck = true - printToLog("\n[RACE] Closing popup warning of doing more than 3+ races and setting flag to prevent racing for now. Canceling the racing process and doing something else.") - findAndTapImage("cancel", region = imageUtils.regionBottomHalf) - return false - } else { - findAndTapImage("ok", tries = 1, region = imageUtils.regionMiddle) - wait(1.0) - } - } - - // There is a extra race. - // Swipe up the list to get to the top and then select the first option. - val statusLocation = imageUtils.findImage("race_status").first - if (statusLocation == null) { - printToLog("[ERROR] Unable to determine existence of list of extra races. Canceling the racing process and doing something else.", isError = true) - return false - } - gestureUtils.swipe(statusLocation.x.toFloat(), statusLocation.y.toFloat() + 300, statusLocation.x.toFloat(), statusLocation.y.toFloat() + 888) - wait(1.0) - - // Now determine the best extra race with the following parameters: highest fans and double star prediction. - // First find the fans of only the extra races on the screen that match the double star prediction. Read only 3 extra races. - var count = 0 - val maxCount = imageUtils.findAll("race_selection_fans", region = imageUtils.regionBottomHalf).size - if (maxCount == 0) { - printToLog("[WARNING] Was unable to find any extra races to select. Canceling the racing process and doing something else.", isError = true) - return false - } else { - printToLog("[RACE] There are $maxCount extra race options currently on screen.") - } - val listOfFans = mutableListOf() - val extraRaceLocation = mutableListOf() - val doublePredictionLocations = imageUtils.findAll("race_extra_double_prediction") - if (doublePredictionLocations.size == 1) { - printToLog("[RACE] There is only one race with double predictions so selecting that one.") - tap( - doublePredictionLocations[0].x, - doublePredictionLocations[0].y, - "race_extra_double_prediction", - ignoreWaiting = true - ) - } else { - val (sourceBitmap, templateBitmap) = imageUtils.getBitmaps("race_extra_double_prediction") - val listOfRaces: ArrayList = arrayListOf() - while (count < maxCount) { - // Save the location of the selected extra race. - val selectedExtraRace = imageUtils.findImage("race_extra_selection", region = imageUtils.regionBottomHalf).first - if (selectedExtraRace == null) { - printToLog("[ERROR] Unable to find the location of the selected extra race. Canceling the racing process and doing something else.", isError = true) - break - } - extraRaceLocation.add(selectedExtraRace) - - // Determine its fan gain and save it. - val raceDetails: ImageUtils.RaceDetails = imageUtils.determineExtraRaceFans(extraRaceLocation[count], sourceBitmap, templateBitmap!!, forceRacing = enableForceRacing) - listOfRaces.add(raceDetails) - if (count == 0 && raceDetails.fans == -1) { - // If the fans were unable to be fetched or the race does not have double predictions for the first attempt, skip racing altogether. - listOfFans.add(raceDetails.fans) - break - } - listOfFans.add(raceDetails.fans) - - // Select the next extra race. - if (count + 1 < maxCount) { - if (imageUtils.isTablet) { - tap( - imageUtils.relX(extraRaceLocation[count].x, (-100 * 1.36).toInt()).toDouble(), - imageUtils.relY(extraRaceLocation[count].y, (150 * 1.50).toInt()).toDouble(), - "race_extra_selection", - ignoreWaiting = true - ) - } else { - tap( - imageUtils.relX(extraRaceLocation[count].x, -100).toDouble(), - imageUtils.relY(extraRaceLocation[count].y, 150).toDouble(), - "race_extra_selection", - ignoreWaiting = true - ) - } - } - - wait(0.5) - - count++ - } - - val fansList = listOfRaces.joinToString(", ") { it.fans.toString() } - printToLog("[RACE] Number of fans detected for each extra race are: $fansList") - - // Next determine the maximum fans and select the extra race. - val maxFans: Int? = listOfFans.maxOrNull() - if (maxFans != null) { - if (maxFans == -1) { - printToLog("[WARNING] Max fans was returned as -1. Canceling the racing process and doing something else.") - return false - } - - // Get the index of the maximum fans or the one with the double predictions if available when force racing is enabled. - val index = if (!enableForceRacing) { - listOfFans.indexOf(maxFans) - } else { - // When force racing is enabled, prioritize races with double predictions. - val doublePredictionIndex = listOfRaces.indexOfFirst { it.hasDoublePredictions } - if (doublePredictionIndex != -1) { - printToLog("[RACE] Force racing enabled - selecting race with double predictions.") - doublePredictionIndex - } else { - // Fall back to the race with maximum fans if no double predictions found - printToLog("[RACE] Force racing enabled but no double predictions found - falling back to race with maximum fans.") - listOfFans.indexOf(maxFans) - } - } - - printToLog("[RACE] Selecting the extra race at option #${index + 1}.") - - // Select the extra race that matches the double star prediction and the most fan gain. - tap( - extraRaceLocation[index].x - imageUtils.relWidth((100 * 1.36).toInt()), - extraRaceLocation[index].y - imageUtils.relHeight(70), - "race_extra_selection", - ignoreWaiting = true - ) - } else if (extraRaceLocation.isNotEmpty()) { - // If no maximum is determined, select the very first extra race. - printToLog("[RACE] Selecting the first extra race on the list by default.") - tap( - extraRaceLocation[0].x - imageUtils.relWidth((100 * 1.36).toInt()), - extraRaceLocation[0].y - imageUtils.relHeight(70), - "race_extra_selection", - ignoreWaiting = true - ) - } else { - printToLog("[WARNING] No extra races detected and thus no fan maximums were calculated. Canceling the racing process and doing something else.") - return false - } - } - - // Confirm the selection and the resultant popup and then wait for the game to load. - findAndTapImage("race_confirm", tries = 30, region = imageUtils.regionBottomHalf) - findAndTapImage("race_confirm", tries = 10, region = imageUtils.regionBottomHalf) - wait(2.0) - - // Skip the race if possible, otherwise run it manually. - val resultCheck: Boolean = if (imageUtils.findImage("race_skip_locked", tries = 5, region = imageUtils.regionBottomHalf).first == null) { - skipRace() - } else { - manualRace() - } - - finishRace(resultCheck, isExtra = true) - - printToLog("[RACE] Racing process for Extra Race is completed.") - return true - } - - return false - } - - /** - * The entry point for handling standalone races if the user started the bot on the Racing screen. - */ - fun handleStandaloneRace() { - printToLog("\n[RACE] Starting Standalone Racing process...") - - // Skip the race if possible, otherwise run it manually. - val resultCheck: Boolean = if (imageUtils.findImage("race_skip_locked", tries = 5, region = imageUtils.regionBottomHalf).first == null) { - skipRace() - } else { - manualRace() - } - - finishRace(resultCheck) - - printToLog("[RACE] Racing process for Standalone Race is completed.") - } - - /** - * Skips the current race to get to the results screen. - * - * @return True if the bot completed the race with retry attempts remaining. Otherwise false. - */ - private fun skipRace(): Boolean { - while (raceRetries >= 0) { - printToLog("[RACE] Skipping race...") - - // Press the skip button and then wait for your result of the race to show. - if (findAndTapImage("race_skip", tries = 30, region = imageUtils.regionBottomHalf)) { - printToLog("[RACE] Race was able to be skipped.") - } - wait(2.0) - - // Now tap on the screen to get past the Race Result screen. - tap(350.0, 450.0, "ok", taps = 3) - - // Check if the race needed to be retried. - if (imageUtils.findImage("race_retry", tries = 5, region = imageUtils.regionBottomHalf, suppressError = true).first != null) { - if (disableRaceRetries) { - printToLog("\n[END] Stopping the bot due to failing a mandatory race.") - notificationMessage = "Stopping the bot due to failing a mandatory race." - throw IllegalStateException() - } - findAndTapImage("race_retry", tries = 1, region = imageUtils.regionBottomHalf, suppressError = true) - printToLog("[RACE] The skipped race failed and needs to be run again. Attempting to retry...") - wait(3.0) - raceRetries-- - } else { - return true - } - } - - return false - } - - /** - * Manually runs the current race to get to the results screen. - * - * @return True if the bot completed the race with retry attempts remaining. Otherwise false. - */ - private fun manualRace(): Boolean { - while (raceRetries >= 0) { - printToLog("[RACE] Skipping manual race...") - - // Press the manual button. - if (findAndTapImage("race_manual", tries = 30, region = imageUtils.regionBottomHalf)) { - printToLog("[RACE] Started the manual race.") - } - wait(2.0) - - // Confirm the Race Playback popup if it appears. - if (findAndTapImage("ok", tries = 1, region = imageUtils.regionMiddle, suppressError = true)) { - printToLog("[RACE] Confirmed the Race Playback popup.") - wait(5.0) - } - - waitForLoading() - - // Now press the confirm button to get past the list of participants. - if (findAndTapImage("race_confirm", tries = 30, region = imageUtils.regionBottomHalf)) { - printToLog("[RACE] Dismissed the list of participants.") - } - waitForLoading() - wait(1.0) - waitForLoading() - wait(1.0) - - // Skip the part where it reveals the name of the race. - if (findAndTapImage("race_skip_manual", tries = 30, region = imageUtils.regionBottomHalf)) { - printToLog("[RACE] Skipped the name reveal of the race.") - } - // Skip the walkthrough of the starting gate. - if (findAndTapImage("race_skip_manual", tries = 30, region = imageUtils.regionBottomHalf)) { - printToLog("[RACE] Skipped the walkthrough of the starting gate.") - } - wait(3.0) - // Skip the start of the race. - if (findAndTapImage("race_skip_manual", tries = 30, region = imageUtils.regionBottomHalf)) { - printToLog("[RACE] Skipped the start of the race.") - } - // Skip the lead up to the finish line. - if (findAndTapImage("race_skip_manual", tries = 30, region = imageUtils.regionBottomHalf)) { - printToLog("[RACE] Skipped the lead up to the finish line.") - } - wait(2.0) - // Skip the result screen. - if (findAndTapImage("race_skip_manual", tries = 30, region = imageUtils.regionBottomHalf)) { - printToLog("[RACE] Skipped the results screen.") - } - wait(2.0) - - waitForLoading() - wait(1.0) - - // Check if the race needed to be retried. - if (imageUtils.findImage("race_retry", tries = 5, region = imageUtils.regionBottomHalf, suppressError = true).first != null) { - if (disableRaceRetries) { - printToLog("\n[END] Stopping the bot due to failing a mandatory race.") - notificationMessage = "Stopping the bot due to failing a mandatory race." - throw IllegalStateException() - } - findAndTapImage("race_retry", tries = 1, region = imageUtils.regionBottomHalf, suppressError = true) - printToLog("[RACE] Manual race failed and needs to be run again. Attempting to retry...") - wait(5.0) - raceRetries-- - } else { - // Check if a Trophy was acquired. - if (findAndTapImage("race_accept_trophy", tries = 5, region = imageUtils.regionBottomHalf)) { - printToLog("[RACE] Closing popup to claim trophy...") - } - - return true - } - } - - return false - } - - /** - * Finishes up and confirms the results of the race and its success. - * - * @param resultCheck Flag to see if the race was completed successfully. Throws an IllegalStateException if it did not. - * @param isExtra Flag to determine the following actions to finish up this mandatory or extra race. - */ - private fun finishRace(resultCheck: Boolean, isExtra: Boolean = false) { - printToLog("\n[RACE] Now performing cleanup and finishing the race.") - if (!resultCheck) { - notificationMessage = "Bot has run out of retry attempts for racing. Stopping the bot now..." - throw IllegalStateException() - } - - // Bot will be at the screen where it shows the final positions of all participants. - // Press the confirm button and wait to see the triangle of fans. - printToLog("[RACE] Now attempting to confirm the final positions of all participants and number of gained fans") - if (findAndTapImage("next", tries = 30, region = imageUtils.regionBottomHalf)) { - wait(0.5) - - // Now tap on the screen to get to the next screen. - tap(350.0, 750.0, "ok", taps = 3) - - // Now press the end button to finish the race. - findAndTapImage("race_end", tries = 30, region = imageUtils.regionBottomHalf) - - if (!isExtra) { - printToLog("[RACE] Seeing if a Training Goal popup will appear.") - // Wait until the popup showing the completion of a Training Goal appears and confirm it. - // There will be dialog before it so the delay should be longer. - wait(5.0) - if (findAndTapImage("next", tries = 10, region = imageUtils.regionBottomHalf)) { - wait(2.0) - - // Now confirm the completion of a Training Goal popup. - printToLog("[RACE] There was a Training Goal popup. Confirming it now.") - findAndTapImage("next", tries = 10, region = imageUtils.regionBottomHalf) - } - } else if (findAndTapImage("next", tries = 10, region = imageUtils.regionBottomHalf)) { - // Same as above but without the longer delay. - wait(2.0) - findAndTapImage("race_end", tries = 10, region = imageUtils.regionBottomHalf) - } - } else { - printToLog("[ERROR] Cannot start the cleanup process for finishing the race. Moving on...", isError = true) - } - } - - //////////////////////////////////////////////////////////////////////////////////////////////////// - //////////////////////////////////////////////////////////////////////////////////////////////////// - // Helper Functions - - fun updatePreferredDistance() { - printToLog("\n[STATS] Updating preferred distance.") - if (findAndTapImage("main_status", tries = 1, region = imageUtils.regionMiddle)) { - preferredDistance = imageUtils.determinePreferredDistance() - findAndTapImage("race_accept_trophy", tries = 1, region = imageUtils.regionBottomHalf) - printToLog("[STATS] Preferred distance set to $preferredDistance.") - } - } - - /** - * Updates the current stat value mapping by reading the character's current stats from the Main screen. - */ - fun updateStatValueMapping() { - printToLog("\n[STATS] Updating stat value mapping.") - currentStatsMap = imageUtils.determineStatValues(currentStatsMap) - // Print the updated stat value mapping here. - currentStatsMap.forEach { it -> - printToLog("[STATS] ${it.key}: ${it.value}") - } - printToLog("[STATS] Stat value mapping updated.\n") - } - - /** - * Updates the stored date in memory by keeping track of the current year, phase, month and current turn number. - */ - fun updateDate() { - printToLog("\n[DATE] Updating the current turn number.") - val dateString = imageUtils.determineDayNumber() - currentDate = textDetection.determineDateFromString(dateString) - printToLog("\n[DATE] It is currently $currentDate.") - } - - /** - * Handles the Inheritance event if detected on the screen. - * - * @return True if the Inheritance event happened and was accepted. Otherwise false. - */ - fun handleInheritanceEvent(): Boolean { - return if (inheritancesDone < 2) { - if (findAndTapImage("inheritance", tries = 1, region = imageUtils.regionBottomHalf)) { - inheritancesDone++ - true - } else { - false - } - } else { - false - } - } - - /** - * Attempt to recover energy. - * - * @return True if the bot successfully recovered energy. Otherwise false. - */ - private fun recoverEnergy(): Boolean { - printToLog("\n[ENERGY] Now starting attempt to recover energy.") - return when { - findAndTapImage("recover_energy", tries = 1, imageUtils.regionBottomHalf) -> { - findAndTapImage("ok") - printToLog("[ENERGY] Successfully recovered energy.") - raceRepeatWarningCheck = false - true - } - findAndTapImage("recover_energy_summer", tries = 1, imageUtils.regionBottomHalf) -> { - findAndTapImage("ok") - printToLog("[ENERGY] Successfully recovered energy for the Summer.") - raceRepeatWarningCheck = false - true - } - else -> { - printToLog("[ENERGY] Failed to recover energy. Moving on...") - false - } - } - } - - /** - * Attempt to recover mood to always maintain at least Above Normal mood. - * - * @return True if the bot successfully recovered mood. Otherwise false. - */ - fun recoverMood(): Boolean { - printToLog("\n[MOOD] Detecting current mood.") - - // Detect what Mood the bot is at. - val currentMood: String = when { - imageUtils.findImage("mood_normal", tries = 1, region = imageUtils.regionTopHalf, suppressError = true).first != null -> { - "Normal" - } - imageUtils.findImage("mood_good", tries = 1, region = imageUtils.regionTopHalf, suppressError = true).first != null -> { - "Good" - } - imageUtils.findImage("mood_great", tries = 1, region = imageUtils.regionTopHalf, suppressError = true).first != null -> { - "Great" - } - else -> { - "Bad/Awful" - } - } - - printToLog("[MOOD] Detected mood to be $currentMood.") - - // Only recover mood if its below Good mood and its not Summer. - return if (firstTrainingCheck && currentMood == "Normal" && imageUtils.findImage("recover_energy_summer", tries = 1, region = imageUtils.regionBottomHalf, suppressError = true).first == null) { - printToLog("[MOOD] Current mood is Normal. Not recovering mood due to firstTrainingCheck flag being active. Will need to complete a training first before being allowed to recover mood.") - false - } else if ((currentMood == "Bad/Awful" || currentMood == "Normal") && imageUtils.findImage("recover_energy_summer", tries = 1, region = imageUtils.regionBottomHalf, suppressError = true).first == null) { - printToLog("[MOOD] Current mood is not good. Recovering mood now.") - if (!findAndTapImage("recover_mood", tries = 1, region = imageUtils.regionBottomHalf, suppressError = true)) { - findAndTapImage("recover_energy_summer", tries = 1, region = imageUtils.regionBottomHalf, suppressError = true) - } - - // Do the date if it is unlocked. - if (findAndTapImage("recover_mood_date", tries = 1, region = imageUtils.regionMiddle, suppressError = true)) { - wait(1.0) - } - - findAndTapImage("ok", region = imageUtils.regionMiddle, suppressError = true) - raceRepeatWarningCheck = false - true - } else { - printToLog("[MOOD] Current mood is good enough or its the Summer event. Moving on...") - false - } - } - - /** - * Prints the training map object for informational purposes. - */ - private fun printTrainingMap() { - printToLog("\n[INFO] Stat Gains by Training:") - trainingMap.forEach { name, training -> - printToLog("[TRAINING] $name Training stat gains: ${training.statGains.contentToString()}, failure chance: ${training.failureChance}%.") - } - } - - /** - * Perform misc checks to potentially fix instances where the bot is stuck. - * - * @return True if the checks passed. Otherwise false if the bot encountered a warning popup and needs to exit. - */ - fun performMiscChecks(): Boolean { - printToLog("\n[INFO] Beginning check for misc cases...") - - if (enablePopupCheck && imageUtils.findImage("cancel", tries = 1, region = imageUtils.regionBottomHalf).first != null && - imageUtils.findImage("recover_mood_date", tries = 1, region = imageUtils.regionMiddle).first == null) { - printToLog("\n[END] Bot may have encountered a warning popup. Exiting now...") - notificationMessage = "Bot may have encountered a warning popup" - return false - } else if (findAndTapImage("next", tries = 1, region = imageUtils.regionBottomHalf)) { - // Now confirm the completion of a Training Goal popup. - wait(2.0) - findAndTapImage("next", tries = 1, region = imageUtils.regionBottomHalf) - wait(1.0) - } else if (imageUtils.findImage("crane_game", tries = 1, region = imageUtils.regionBottomHalf).first != null) { - // Stop when the bot has reached the Crane Game Event. - printToLog("\n[END] Bot will stop due to the detection of the Crane Game Event. Please complete it and restart the bot.") - notificationMessage = "Bot will stop due to the detection of the Crane Game Event. Please complete it and restart the bot." - return false - } else if (findAndTapImage("race_retry", tries = 1, region = imageUtils.regionBottomHalf, suppressError = true)) { - printToLog("[INFO] There is a race retry popup.") - wait(5.0) - } else if (findAndTapImage("race_accept_trophy", tries = 1, region = imageUtils.regionBottomHalf, suppressError = true)) { - printToLog("[INFO] There is a possible popup to accept a trophy.") - finishRace(true, isExtra = true) - } else if (findAndTapImage("race_end", tries = 1, region = imageUtils.regionBottomHalf, suppressError = true)) { - printToLog("[INFO] Ended a leftover race.") - } else if (imageUtils.findImage("connection_error", tries = 1, region = imageUtils.regionMiddle, suppressError = true).first != null) { - printToLog("\n[END] Bot will stop due to detecting a connection error.") - notificationMessage = "Bot will stop due to detecting a connection error." - return false - } else if (imageUtils.findImage("race_not_enough_fans", tries = 1, region = imageUtils.regionMiddle, suppressError = true).first != null) { - printToLog("[INFO] There was a popup about insufficient fans.") - encounteredRacingPopup = true - findAndTapImage("cancel", region = imageUtils.regionBottomHalf) - } else if (findAndTapImage("back", tries = 1, region = imageUtils.regionBottomHalf, suppressError = true)) { - wait(1.0) - } else if (!BotService.isRunning) { - throw InterruptedException() - } else { - printToLog("[INFO] Did not detect any popups or the Crane Game on the screen. Moving on...") - } - - return true - } - - /** - * Bot will begin automation here. - * - * @return True if all automation goals have been met. False otherwise. - */ - fun start(): Boolean { - // Print current app settings at the start of the run. - SettingsPrinter.printCurrentSettings(myContext) { message -> - printToLog(message) - } - - // Update the stat targets by distances. - setStatTargetsByDistances() - - // If debug mode is off, then it is necessary to wait a few seconds for the Toast message to disappear from the screen to prevent it obstructing anything beneath it. - if (!debugMode) { - wait(5.0) - } - - // Print device and version information. - printToLog("[INFO] Device Information: ${MediaProjectionService.displayWidth}x${MediaProjectionService.displayHeight}, DPI ${MediaProjectionService.displayDPI}") - if (MediaProjectionService.displayWidth != 1080) printToLog("[WARNING] ⚠️ Bot performance will be severely degraded since display width is not 1080p unless an appropriate scale is set for your device.") - if (debugMode) printToLog("[WARNING] ⚠️ Debug Mode is enabled. All bot operations will be significantly slower as a result.") - if (sharedPreferences.getInt("customScale", 100).toDouble() / 100.0 != 1.0) printToLog("[INFO] Manual scale has been set to ${sharedPreferences.getInt("customScale", 100).toDouble() / 100.0}") - printToLog("[WARNING] ⚠️ Note that certain Android notification styles (like banners) are big enough that they cover the area that contains the Mood which will interfere with mood recovery logic in the beginning.") - val packageInfo = myContext.packageManager.getPackageInfo(myContext.packageName, 0) - printToLog("[INFO] Bot version: ${packageInfo.versionName} (${packageInfo.versionCode})\n\n") - - val startTime: Long = System.currentTimeMillis() - - // Start debug tests here if enabled. - if (sharedPreferences.getBoolean("debugMode_startTemplateMatchingTest", false)) { - startTemplateMatchingTest() - } else if (sharedPreferences.getBoolean("debugMode_startSingleTrainingFailureOCRTest", false)) { - startSingleTrainingFailureOCRTest() - } else if (sharedPreferences.getBoolean("debugMode_startComprehensiveTrainingFailureOCRTest", false)) { - startComprehensiveTrainingFailureOCRTest() - } - // Otherwise, proceed with regular bot operations. - else if (campaign == "Ao Haru") { - val aoHaruCampaign = AoHaru(this) - aoHaruCampaign.start() - } else { - val uraFinaleCampaign = Campaign(this) - uraFinaleCampaign.start() - } - - val endTime: Long = System.currentTimeMillis() - Log.d(tag, "Total Runtime: ${endTime - startTime}ms") - - return true - } -} \ No newline at end of file diff --git a/app/src/main/java/com/steve1316/uma_android_automation/bot/TextDetection.kt b/app/src/main/java/com/steve1316/uma_android_automation/bot/TextDetection.kt deleted file mode 100644 index a4c3b374..00000000 --- a/app/src/main/java/com/steve1316/uma_android_automation/bot/TextDetection.kt +++ /dev/null @@ -1,338 +0,0 @@ -package com.steve1316.uma_android_automation.bot - -import android.content.SharedPreferences -import android.util.Log -import androidx.preference.PreferenceManager -import com.steve1316.uma_android_automation.MainActivity -import com.steve1316.uma_android_automation.data.CharacterData -import com.steve1316.uma_android_automation.data.SupportData -import com.steve1316.uma_android_automation.utils.ImageUtils -import net.ricecode.similarity.JaroWinklerStrategy -import net.ricecode.similarity.StringSimilarityServiceImpl - -class TextDetection(private val game: Game, private val imageUtils: ImageUtils) { - private val tag: String = "[${MainActivity.loggerTag}]TextDetection" - - private var sharedPreferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(game.myContext) - - private var result = "" - private var confidence = 0.0 - private var category = "" - private var eventTitle = "" - private var supportCardTitle = "" - private var eventOptionRewards: ArrayList = arrayListOf() - - private var character = sharedPreferences.getString("character", "")!! - private val supportCards: List = sharedPreferences.getString("supportList", "")!!.split("|") - private val hideComparisonResults: Boolean = sharedPreferences.getBoolean("hideComparisonResults", false) - private val selectAllCharacters: Boolean = sharedPreferences.getBoolean("selectAllCharacters", true) - private val selectAllSupportCards: Boolean = sharedPreferences.getBoolean("selectAllSupportCards", true) - private var minimumConfidence = sharedPreferences.getInt("ocrConfidence", 80).toDouble() / 100.0 - private val threshold = sharedPreferences.getInt("threshold", 230).toDouble() - private val enableAutomaticRetry = sharedPreferences.getBoolean("enableAutomaticRetry", false) - - /** - * Fix incorrect characters determined by OCR by replacing them with their Japanese equivalents. - */ - private fun fixIncorrectCharacters() { - game.printToLog("\n[TEXT-DETECTION] Now attempting to fix incorrect characters in: $result", tag = tag) - - if (result.last() == '/') { - result = result.replace("/", "!") - } - - result = result.replace("(", "(").replace(")", ")") - game.printToLog("[TEXT-DETECTION] Finished attempting to fix incorrect characters: $result", tag = tag) - } - - /** - * Attempt to find the most similar string from data compared to the string returned by OCR. - */ - private fun findMostSimilarString() { - if (!hideComparisonResults) { - game.printToLog("\n[TEXT-DETECTION] Now starting process to find most similar string to: $result\n", tag = tag) - } else { - game.printToLog("\n[TEXT-DETECTION] Now starting process to find most similar string to: $result", tag = tag) - } - - // Remove any detected whitespaces. - result = result.replace(" ", "") - - // Use the Jaro Winkler algorithm to compare similarities the OCR detected string and the rest of the strings inside the data classes. - val service = StringSimilarityServiceImpl(JaroWinklerStrategy()) - - // Attempt to find the most similar string inside the data classes starting with the Character-specific events. - if (selectAllCharacters) { - CharacterData.characters.keys.forEach { characterKey -> - CharacterData.characters[characterKey]?.forEach { (eventName, eventOptions) -> - val score = service.score(result, eventName) - if (!hideComparisonResults) { - game.printToLog("[CHARA] $characterKey \"${result}\" vs. \"${eventName}\" confidence: $score", tag = tag) - } - - if (score >= confidence) { - confidence = score - eventTitle = eventName - eventOptionRewards = eventOptions - category = "character" - character = characterKey - } - } - } - } else { - CharacterData.characters[character]?.forEach { (eventName, eventOptions) -> - val score = service.score(result, eventName) - if (!hideComparisonResults) { - game.printToLog("[CHARA] $character \"${result}\" vs. \"${eventName}\" confidence: $score", tag = tag) - } - - if (score >= confidence) { - confidence = score - eventTitle = eventName - eventOptionRewards = eventOptions - category = "character" - } - } - } - - // Now move on to the Character-shared events. - CharacterData.characters["Shared"]?.forEach { (eventName, eventOptions) -> - val score = service.score(result, eventName) - if (!hideComparisonResults) { - game.printToLog("[CHARA-SHARED] \"${result}\" vs. \"${eventName}\" confidence: $score", tag = tag) - } - - if (score >= confidence) { - confidence = score - eventTitle = eventName - eventOptionRewards = eventOptions - category = "character-shared" - } - } - - // Finally, do the same with the user-selected Support Cards. - if (!selectAllSupportCards) { - supportCards.forEach { supportCardName -> - SupportData.supports[supportCardName]?.forEach { (eventName, eventOptions) -> - val score = service.score(result, eventName) - if (!hideComparisonResults) { - game.printToLog("[SUPPORT] $supportCardName \"${result}\" vs. \"${eventName}\" confidence: $score", tag = tag) - } - - if (score >= confidence) { - confidence = score - eventTitle = eventName - supportCardTitle = supportCardName - eventOptionRewards = eventOptions - category = "support" - } - } - } - } else { - SupportData.supports.forEach { (supportName, support) -> - support.forEach { (eventName, eventOptions) -> - val score = service.score(result, eventName) - if (!hideComparisonResults) { - game.printToLog("[SUPPORT] $supportName \"${result}\" vs. \"${eventName}\" confidence: $score", tag = tag) - } - - if (score >= confidence) { - confidence = score - eventTitle = eventName - supportCardTitle = supportName - eventOptionRewards = eventOptions - category = "support" - } - } - } - } - - if (!hideComparisonResults) { - game.printToLog("\n[TEXT-DETECTION] Finished process to find similar string.", tag = tag) - } else { - game.printToLog("[TEXT-DETECTION] Finished process to find similar string.", tag = tag) - } - game.printToLog("[TEXT-DETECTION] Event data fetched for \"${eventTitle}\".") - } - - /** - * Parses a date string from the game and converts it to a structured Game.Date object. - * - * This function handles two types of date formats: Pre-Debut and regular date strings. - * - * For Pre-Debut dates, the function calculates the current turn based on remaining turns - * and determines the month within the Pre-Debut phase (which spans 12 turns). - * - * For regular dates, the function parses the year (Junior/Classic/Senior), phase (Early/Late), - * and month (Jan-Dec) components. If exact matches aren't found in the predefined mappings, - * it uses Jaro Winkler similarity scoring to find the best match. - * - * @param dateString The date string to parse (e.g., "Classic Year Early Jan" or "Pre-Debut") - * - * @return A Game.Date object containing the parsed year, phase, month, and calculated turn number. - */ - fun determineDateFromString(dateString: String): Game.Date { - if (dateString == "") { - game.printToLog("[ERROR] Received date string from OCR was empty. Defaulting to \"Senior Year Early Jan\" at turn number 49.", tag = tag) - return Game.Date(3, "Early", 1, 49) - } else if (dateString.lowercase().contains("debut")) { - // Special handling for the Pre-Debut phase. - val turnsRemaining = imageUtils.determineDayForExtraRace() - - // Pre-Debut ends on Early July (turn 13), so we calculate backwards. - // This includes the Race day. - val totalTurnsInPreDebut = 12 - val currentTurnInPreDebut = totalTurnsInPreDebut - turnsRemaining + 1 - - val month = ((currentTurnInPreDebut - 1) / 2) + 1 - return Game.Date(1, "Pre-Debut", month, currentTurnInPreDebut) - } - - // Example input is "Classic Year Early Jan". - val years = mapOf( - "Junior Year" to 1, - "Classic Year" to 2, - "Senior Year" to 3 - ) - val months = mapOf( - "Jan" to 1, - "Feb" to 2, - "Mar" to 3, - "Apr" to 4, - "May" to 5, - "Jun" to 6, - "Jul" to 7, - "Aug" to 8, - "Sep" to 9, - "Oct" to 10, - "Nov" to 11, - "Dec" to 12 - ) - - // Split the input string by whitespace. - val parts = dateString.trim().split(" ") - if (parts.size < 3) { - game.printToLog("[TEXT-DETECTION] Invalid date string format: $dateString", tag = tag) - return Game.Date(3, "Early", 1, 49) - } - - // Extract the parts with safe indexing using default values. - val yearPart = parts.getOrNull(0)?.let { first -> - parts.getOrNull(1)?.let { second -> "$first $second" } - } ?: "Senior Year" - val phase = parts.getOrNull(2) ?: "Early" - val monthPart = parts.getOrNull(3) ?: "Jan" - - // Find the best match for year using Jaro Winkler if not found in mapping. - var year = years[yearPart] - if (year == null) { - val service = StringSimilarityServiceImpl(JaroWinklerStrategy()) - var bestYearScore = 0.0 - var bestYear = 3 - - years.keys.forEach { yearKey -> - val score = service.score(yearPart, yearKey) - if (score > bestYearScore) { - bestYearScore = score - bestYear = years[yearKey]!! - } - } - year = bestYear - game.printToLog("[TEXT-DETECTION] Year not found in mapping, using best match: $yearPart -> $year", tag = tag) - } - - // Find the best match for month using Jaro Winkler if not found in mapping. - var month = months[monthPart] - if (month == null) { - val service = StringSimilarityServiceImpl(JaroWinklerStrategy()) - var bestMonthScore = 0.0 - var bestMonth = 1 - - months.keys.forEach { monthKey -> - val score = service.score(monthPart, monthKey) - if (score > bestMonthScore) { - bestMonthScore = score - bestMonth = months[monthKey]!! - } - } - month = bestMonth - game.printToLog("[TEXT-DETECTION] Month not found in mapping, using best match: $monthPart -> $month", tag = tag) - } - - // Calculate the turn number. - // Each year has 24 turns (12 months x 2 phases each). - // Each month has 2 turns (Early and Late). - val turnNumber = ((year - 1) * 24) + ((month - 1) * 2) + (if (phase == "Early") 1 else 2) - - return Game.Date(year, phase, month, turnNumber) - } - - fun start(): Pair, Double> { - if (minimumConfidence > 1.0) { - minimumConfidence = 0.8 - } - - // Reset to default values. - result = "" - confidence = 0.0 - category = "" - eventTitle = "" - supportCardTitle = "" - eventOptionRewards.clear() - - var increment = 0.0 - - val startTime: Long = System.currentTimeMillis() - while (true) { - // Perform Tesseract OCR detection. - if ((255.0 - threshold - increment) > 0.0) { - result = imageUtils.findText(increment) - } else { - break - } - - if (result.isNotEmpty() && result != "empty!") { - // Make some minor improvements by replacing certain incorrect characters with their Japanese equivalents. - fixIncorrectCharacters() - - // Now attempt to find the most similar string compared to the one from OCR. - findMostSimilarString() - - when (category) { - "character" -> { - if (!hideComparisonResults) { - game.printToLog("\n[RESULT] Character $character Event Name = $eventTitle with confidence = $confidence", tag = tag) - } - } - "character-shared" -> { - if (!hideComparisonResults) { - game.printToLog("\n[RESULT] Character Shared Event Name = $eventTitle with confidence = $confidence", tag = tag) - } - } - "support" -> { - if (!hideComparisonResults) { - game.printToLog("\n[RESULT] Support $supportCardTitle Event Name = $eventTitle with confidence = $confidence", tag = tag) - } - } - } - - if (enableAutomaticRetry && !hideComparisonResults) { - game.printToLog("\n[RESULT] Threshold incremented by $increment", tag = tag) - } - - if (confidence < minimumConfidence && enableAutomaticRetry) { - increment += 5.0 - } else { - break - } - } else { - increment += 5.0 - } - } - - val endTime: Long = System.currentTimeMillis() - Log.d(tag, "Total Runtime for detecting Text: ${endTime - startTime}ms") - - return Pair(eventOptionRewards, confidence) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/steve1316/uma_android_automation/data/CharacterData.kt b/app/src/main/java/com/steve1316/uma_android_automation/data/CharacterData.kt deleted file mode 100644 index aa90f0e8..00000000 --- a/app/src/main/java/com/steve1316/uma_android_automation/data/CharacterData.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.steve1316.uma_android_automation.data - -class CharacterData { - companion object { - var characters: MutableMap>> = mutableMapOf() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/steve1316/uma_android_automation/data/SkillData.kt b/app/src/main/java/com/steve1316/uma_android_automation/data/SkillData.kt deleted file mode 100644 index df4de99f..00000000 --- a/app/src/main/java/com/steve1316/uma_android_automation/data/SkillData.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.steve1316.uma_android_automation.data - -class SkillData { - companion object { - var skills: MutableMap> = mutableMapOf() - } -} diff --git a/app/src/main/java/com/steve1316/uma_android_automation/data/StatusData.kt b/app/src/main/java/com/steve1316/uma_android_automation/data/StatusData.kt deleted file mode 100644 index 760f0499..00000000 --- a/app/src/main/java/com/steve1316/uma_android_automation/data/StatusData.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.steve1316.uma_android_automation.data - -class StatusData { - companion object { - val status = mapOf( - "Bad Practice" to "Whoops! Watch your step! Increased training fail rate.", - "Charming" to "It's fun exercising together! Increased bond gain with your training partners.", - "Good Practice" to "Be prepared even in practice. Decreased training fail rate.", - "Insomnia" to "Tends to stay up late at night. May lose energy due to lack of sleep.", - "Lazy Habit" to "Eh, who cares about training? Sometimes just skips training.", - "Overweight" to "Ate too much and got a little fat. Cannot train speed.", - "Rising Star" to "Outstanding! The academy is proud of you! Journalists and the Principal will appreciate you more.", - "Sharp" to "Such a fast learner! All skills are discounted.", - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/steve1316/uma_android_automation/data/SupportData.kt b/app/src/main/java/com/steve1316/uma_android_automation/data/SupportData.kt deleted file mode 100644 index 82739ce7..00000000 --- a/app/src/main/java/com/steve1316/uma_android_automation/data/SupportData.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.steve1316.uma_android_automation.data - -// Note that different Support Cards of different rarities of the same Character share the same events except for their 3-part progressing events. -// Their secondary names that differentiate between each Support card of the same Character is from the GameWith website, https://gamewith.jp/uma-musume/article/show/255037 - -class SupportData { - companion object { - var supports: MutableMap>> = mutableMapOf() - } -} diff --git a/app/src/main/java/com/steve1316/uma_android_automation/ui/home/HomeFragment.kt b/app/src/main/java/com/steve1316/uma_android_automation/ui/home/HomeFragment.kt deleted file mode 100644 index a0cb6d06..00000000 --- a/app/src/main/java/com/steve1316/uma_android_automation/ui/home/HomeFragment.kt +++ /dev/null @@ -1,398 +0,0 @@ -package com.steve1316.uma_android_automation.ui.home - -import android.annotation.SuppressLint -import android.app.Activity -import android.app.AlertDialog -import android.content.Context -import android.content.Intent -import android.media.projection.MediaProjectionManager -import android.os.Bundle -import android.provider.Settings -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Button -import android.widget.TextView -import androidx.core.content.edit -import androidx.fragment.app.Fragment -import androidx.preference.PreferenceManager -import com.beust.klaxon.JsonReader -import com.github.javiersantos.appupdater.AppUpdater -import com.github.javiersantos.appupdater.enums.UpdateFrom -import com.steve1316.uma_android_automation.MainActivity -import com.steve1316.uma_android_automation.R -import com.steve1316.uma_android_automation.data.CharacterData -import com.steve1316.uma_android_automation.data.SkillData -import com.steve1316.uma_android_automation.data.SupportData -import com.steve1316.uma_android_automation.utils.MediaProjectionService -import com.steve1316.uma_android_automation.utils.MessageLog -import com.steve1316.uma_android_automation.utils.MyAccessibilityService -import java.io.StringReader -import androidx.core.net.toUri -import com.steve1316.uma_android_automation.utils.SettingsPrinter - -class HomeFragment : Fragment() { - private val logTag: String = "[${MainActivity.loggerTag}]HomeFragment" - private var firstBoot = false - private var firstRun = true - - private lateinit var myContext: Context - private lateinit var homeFragmentView: View - private lateinit var startButton: Button - - @SuppressLint("SetTextI18n") - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - myContext = requireContext() - - homeFragmentView = inflater.inflate(R.layout.fragment_home, container, false) - - // Start or stop the MediaProjection service via this button. - startButton = homeFragmentView.findViewById(R.id.start_button) - startButton.setOnClickListener { - val readyCheck = startReadyCheck() - if (readyCheck && !MediaProjectionService.isRunning) { - startProjection() - startButton.text = getString(R.string.stop) - - // This is needed because onResume() is immediately called right after accepting the MediaProjection and it has not been properly - // initialized yet so it would cause the button's text to revert back to "Start". - firstBoot = true - } else if (MediaProjectionService.isRunning) { - stopProjection() - startButton.text = getString(R.string.start) - } - } - - //////////////////////////////////////////////////////////////////////////////////////////////////// - //////////////////////////////////////////////////////////////////////////////////////////////////// - - val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) - - // Main Settings page - val campaign: String = sharedPreferences.getString("campaign", "")!! - - // Training Settings page - val statPrioritization: String = sharedPreferences.getString("statPrioritization", "Speed|Stamina|Power|Guts|Wit")!! - - // Training Stat Targets Settings page - val sprintSpeedTarget = sharedPreferences.getInt("trainingSprintStatTarget_speedStatTarget", 900) - val sprintStaminaTarget = sharedPreferences.getInt("trainingSprintStatTarget_staminaStatTarget", 300) - val sprintPowerTarget = sharedPreferences.getInt("trainingSprintStatTarget_powerStatTarget", 600) - val sprintGutsTarget = sharedPreferences.getInt("trainingSprintStatTarget_gutsStatTarget", 300) - val sprintWitTarget = sharedPreferences.getInt("trainingSprintStatTarget_witStatTarget", 300) - - val mileSpeedTarget = sharedPreferences.getInt("trainingMileStatTarget_speedStatTarget", 900) - val mileStaminaTarget = sharedPreferences.getInt("trainingMileStatTarget_staminaStatTarget", 300) - val milePowerTarget = sharedPreferences.getInt("trainingMileStatTarget_powerStatTarget", 600) - val mileGutsTarget = sharedPreferences.getInt("trainingMileStatTarget_gutsStatTarget", 300) - val mileWitTarget = sharedPreferences.getInt("trainingMileStatTarget_witStatTarget", 300) - - val mediumSpeedTarget = sharedPreferences.getInt("trainingMediumStatTarget_speedStatTarget", 800) - val mediumStaminaTarget = sharedPreferences.getInt("trainingMediumStatTarget_staminaStatTarget", 450) - val mediumPowerTarget = sharedPreferences.getInt("trainingMediumStatTarget_powerStatTarget", 550) - val mediumGutsTarget = sharedPreferences.getInt("trainingMediumStatTarget_gutsStatTarget", 300) - val mediumWitTarget = sharedPreferences.getInt("trainingMediumStatTarget_witStatTarget", 300) - - val longSpeedTarget = sharedPreferences.getInt("trainingLongStatTarget_speedStatTarget", 700) - val longStaminaTarget = sharedPreferences.getInt("trainingLongStatTarget_staminaStatTarget", 600) - val longPowerTarget = sharedPreferences.getInt("trainingLongStatTarget_powerStatTarget", 450) - val longGutsTarget = sharedPreferences.getInt("trainingLongStatTarget_gutsStatTarget", 300) - val longWitTarget = sharedPreferences.getInt("trainingLongStatTarget_witStatTarget", 300) - - // Training Event Settings page - val character = sharedPreferences.getString("character", "Please select one in the Training Event Settings")!! - val selectAllCharacters = sharedPreferences.getBoolean("selectAllCharacters", true) - - //////////////////////////////////////////////////////////////////////////////////////////////////// - //////////////////////////////////////////////////////////////////////////////////////////////////// - // Set default values in SharedPreferences just in case these keys do not exist yet. - - sharedPreferences.edit { - // Set the default stat prioritization order if it does not exist. - putString("statPrioritization", statPrioritization) - - // Set default stat targets for each distance type if they do not exist. - putInt("trainingSprintStatTarget_speedStatTarget", sprintSpeedTarget) - putInt("trainingSprintStatTarget_staminaStatTarget", sprintStaminaTarget) - putInt("trainingSprintStatTarget_powerStatTarget", sprintPowerTarget) - putInt("trainingSprintStatTarget_gutsStatTarget", sprintGutsTarget) - putInt("trainingSprintStatTarget_witStatTarget", sprintWitTarget) - - putInt("trainingMileStatTarget_speedStatTarget", mileSpeedTarget) - putInt("trainingMileStatTarget_staminaStatTarget", mileStaminaTarget) - putInt("trainingMileStatTarget_powerStatTarget", milePowerTarget) - putInt("trainingMileStatTarget_gutsStatTarget", mileGutsTarget) - putInt("trainingMileStatTarget_witStatTarget", mileWitTarget) - - putInt("trainingMediumStatTarget_speedStatTarget", mediumSpeedTarget) - putInt("trainingMediumStatTarget_staminaStatTarget", mediumStaminaTarget) - putInt("trainingMediumStatTarget_powerStatTarget", mediumPowerTarget) - putInt("trainingMediumStatTarget_gutsStatTarget", mediumGutsTarget) - putInt("trainingMediumStatTarget_witStatTarget", mediumWitTarget) - - putInt("trainingLongStatTarget_speedStatTarget", longSpeedTarget) - putInt("trainingLongStatTarget_staminaStatTarget", longStaminaTarget) - putInt("trainingLongStatTarget_powerStatTarget", longPowerTarget) - putInt("trainingLongStatTarget_gutsStatTarget", longGutsTarget) - putInt("trainingLongStatTarget_witStatTarget", longWitTarget) - - commit() - } - - //////////////////////////////////////////////////////////////////////////////////////////////////// - //////////////////////////////////////////////////////////////////////////////////////////////////// - // Update the TextView here based on the information of the SharedPreferences. - - // Add visual indicators for character and support card selections - val characterString: String = if (selectAllCharacters) { - "👥 All Characters Selected" - } else if (character == "" || character.contains("Please select")) { - "⚠️ Please select one in the Training Event Settings" - } else { - "👤 $character" - } - - // Add visual indicator for campaign selection - val campaignString: String = if (campaign != "") { - "🎯 $campaign" - } else { - "⚠️ Please select one in the Select Campaign option" - } - - val settingsStatusTextView: TextView = homeFragmentView.findViewById(R.id.settings_status) - settingsStatusTextView.text = SettingsPrinter.getSettingsString(requireContext()) - - // Now construct the data files if this is the first time. - if (firstRun) { - constructDataClasses() - firstRun = false - } - - // Force the user to go through the Settings in order to set this required setting. - startButton.isEnabled = !campaignString.contains("Please select") && !characterString.contains("Please select") - - return homeFragmentView - } - - override fun onResume() { - super.onResume() - - // Update the button's text depending on if the MediaProjection service is running. - if (!firstBoot) { - if (MediaProjectionService.isRunning) { - startButton.text = getString(R.string.stop) - } else { - startButton.text = getString(R.string.start) - } - } - - // Setting this false here will ensure that stopping the MediaProjection Service outside of this application will update this button's text. - firstBoot = false - - // Now update the Message Log inside the ScrollView with the latest logging messages from the bot. - Log.d(logTag, "Now updating the Message Log TextView...") - val messageLogTextView = homeFragmentView.findViewById(R.id.message_log) - messageLogTextView.text = "" - - // Get a thread-safe copy of the message log. - val messageLog = MessageLog.getMessageLogCopy() - messageLog.forEach { message -> - messageLogTextView.append("\n$message") - } - - // Set up the app updater to check for the latest update from GitHub. - AppUpdater(myContext) - .setUpdateFrom(UpdateFrom.XML) - .setUpdateXML("https://raw.githubusercontent.com/steve1316/uma-android-automation/master/app/update.xml") - .start() - } - - @Deprecated("Deprecated in Java") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - if (requestCode == 100 && resultCode == Activity.RESULT_OK) { - // Start up the MediaProjection service after the user accepts the onscreen prompt. - myContext.startService(data?.let { MediaProjectionService.getStartIntent(myContext, resultCode, data) }) - } - } - - /** - * Checks to see if the application is ready to start. - * - * @return True if the application has overlay permission and has enabled the Accessibility Service for it. Otherwise, return False. - */ - private fun startReadyCheck(): Boolean { - return !(!checkForOverlayPermission() || !checkForAccessibilityPermission()) - } - - /** - * Starts the MediaProjection Service. - */ - private fun startProjection() { - val mediaProjectionManager = context?.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager - startActivityForResult(mediaProjectionManager.createScreenCaptureIntent(), 100) - } - - /** - * Stops the MediaProjection Service. - */ - private fun stopProjection() { - context?.startService(MediaProjectionService.getStopIntent(requireContext())) - } - - /** - * Checks if the application has permission to draw overlays. If not, it will direct the user to enable it. - * - * Source is from https://github.com/Fate-Grand-Automata/FGA/blob/master/app/src/main/java/com/mathewsachin/fategrandautomata/ui/MainFragment.kt - * - * @return True if it has permission. False otherwise. - */ - private fun checkForOverlayPermission(): Boolean { - if (!Settings.canDrawOverlays(requireContext())) { - Log.d(logTag, "Application is missing overlay permission.") - - AlertDialog.Builder(requireContext()).apply { - setTitle(R.string.overlay_disabled) - setMessage(R.string.overlay_disabled_message) - setPositiveButton(R.string.go_to_settings) { _, _ -> - // Send the user to the Overlay Settings. - val intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, "package:${requireContext().packageName}".toUri()) - startActivity(intent) - } - setNegativeButton(android.R.string.cancel, null) - }.show() - - return false - } - - Log.d(logTag, "Application has permission to draw overlay.") - return true - } - - /** - * Checks if the Accessibility Service for this application is enabled. If not, it will direct the user to enable it. - * - * Source is from https://stackoverflow.com/questions/18094982/detect-if-my-accessibility-service-is-enabled/18095283#18095283 - * - * @return True if it is enabled. False otherwise. - */ - private fun checkForAccessibilityPermission(): Boolean { - val prefString = Settings.Secure.getString(myContext.contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES) - - if (prefString != null && prefString.isNotEmpty()) { - // Check the string of enabled accessibility services to see if this application's accessibility service is there. - val enabled = prefString.contains(myContext.packageName.toString() + "/" + MyAccessibilityService::class.java.name) - - if (enabled) { - Log.d(logTag, "This application's Accessibility Service is currently turned on.") - return true - } - } - - // Shows a dialog explaining how to enable Accessibility Service when restricted settings are detected. - // The dialog provides options to navigate to App Info or Accessibility Settings to complete the setup. - AlertDialog.Builder(myContext).apply { - setTitle(R.string.accessibility_disabled) - setMessage( - """ - To enable Accessibility Service: - - 1. Tap "Go to App Info". - 2. Tap the 3-dot menu in the top right. If not available, you can skip to step 4. - 3. Tap "Allow restricted settings". - 4. Return to Accessibility Settings and enable the service. - """.trimIndent() - ) - setPositiveButton("Go to App Info") { _, _ -> - val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { - data = "package:${myContext.packageName}".toUri() - } - startActivity(intent) - } - setNeutralButton("Accessibility Settings") { _, _ -> - val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) - startActivity(intent) - } - setNegativeButton(android.R.string.cancel, null) - }.show() - - return false - } - - /** - * Construct the data classes associated with Characters, Support Cards and Skills from the provided JSON data files. - */ - private fun constructDataClasses() { - // Construct the data class for Characters and Support Cards. - val fileList = arrayListOf("characters.json", "supports.json") - while (fileList.isNotEmpty()) { - val fileName = fileList[0] - fileList.removeAt(0) - val objectString = myContext.assets.open("data/$fileName").bufferedReader().use { it.readText() } - - JsonReader(StringReader(objectString)).use { reader -> - reader.beginObject { - while (reader.hasNext()) { - // Grab the name. - val name = reader.nextName() - - // Now iterate through each event and collect all of them and their option rewards into a map. - val eventOptionRewards = mutableMapOf>() - reader.beginObject { - while (reader.hasNext()) { - // Grab the event name. - val eventName = reader.nextName() - eventOptionRewards.putIfAbsent(eventName, arrayListOf()) - - reader.beginArray { - // Grab all of the event option rewards for this event and add them to the map. - while (reader.hasNext()) { - val optionReward = reader.nextString() - eventOptionRewards[eventName]?.add(optionReward) - } - } - } - } - - // Finally, put into the MutableMap the key value pair depending on the current category. - if (fileName == "characters.json") { - CharacterData.characters[name] = eventOptionRewards - } else { - SupportData.supports[name] = eventOptionRewards - } - } - } - } - } - - // Now construct the data class for Skills. - val objectString = myContext.assets.open("data/skills.json").bufferedReader().use { it.readText() } - JsonReader(StringReader(objectString)).use { reader -> - reader.beginObject { - while (reader.hasNext()) { - // Grab the name. - val skillName = reader.nextName() - SkillData.skills.putIfAbsent(skillName, mutableMapOf()) - - reader.beginObject { - // Skip the id. - reader.nextName() - reader.nextInt() - - // Grab the English name and description. - reader.nextName() - val skillEnglishName = reader.nextString() - reader.nextName() - val skillEnglishDescription = reader.nextString() - - // Finally, collect them into a map and put them into the data class. - val tempMap = mutableMapOf() - tempMap["englishName"] = skillEnglishName - tempMap["englishDescription"] = skillEnglishDescription - SkillData.skills[skillName] = tempMap - } - } - } - } - } -} diff --git a/app/src/main/java/com/steve1316/uma_android_automation/ui/settings/OCRFragment.kt b/app/src/main/java/com/steve1316/uma_android_automation/ui/settings/OCRFragment.kt deleted file mode 100644 index d278c045..00000000 --- a/app/src/main/java/com/steve1316/uma_android_automation/ui/settings/OCRFragment.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.steve1316.uma_android_automation.ui.settings - -import android.content.SharedPreferences -import android.os.Bundle -import android.util.Log -import androidx.core.content.edit -import androidx.preference.CheckBoxPreference -import androidx.preference.PreferenceFragmentCompat -import androidx.preference.PreferenceManager -import androidx.preference.SeekBarPreference -import com.steve1316.uma_android_automation.MainActivity -import com.steve1316.uma_android_automation.R - -class OCRFragment : PreferenceFragmentCompat() { - private val logTag: String = "[${MainActivity.loggerTag}]OCRFragment" - - private lateinit var sharedPreferences: SharedPreferences - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - // Display the layout using the preferences xml. - setPreferencesFromResource(R.xml.preferences_ocr, rootKey) - - // Get the SharedPreferences. - sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) - - // Grab the saved preferences from the previous time the user used the app. - val threshold: Int = sharedPreferences.getInt("threshold", 230) - val enableAutomaticRetry: Boolean = sharedPreferences.getBoolean("enableAutomaticRetry", true) - val ocrConfidence: Int = sharedPreferences.getInt("ocrConfidence", 80) - - // Get references to the Preference components. - val thresholdPreference = findPreference("threshold")!! - val enableAutomaticRetryPreference = findPreference("enableAutomaticRetry")!! - val ocrConfidencePreference = findPreference("ocrConfidence")!! - - // Now set the following values from the SharedPreferences. - thresholdPreference.value = threshold - enableAutomaticRetryPreference.isChecked = enableAutomaticRetry - ocrConfidencePreference.value = ocrConfidence - - Log.d(logTag, "OCR Preferences created successfully.") - } - - // This listener is triggered whenever the user changes a Preference setting in the Settings Page. - private val onSharedPreferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key -> - if (key != null) { - when (key) { - "threshold" -> { - val thresholdPreference = findPreference("threshold")!! - - sharedPreferences.edit { - putInt("threshold", thresholdPreference.value) - commit() - } - } - "enableAutomaticRetry" -> { - val enableAutomaticRetryPreference = findPreference("enableAutomaticRetry")!! - - sharedPreferences.edit { - putBoolean("enableAutomaticRetry", enableAutomaticRetryPreference.isChecked) - commit() - } - } - "ocrConfidence" -> { - val ocrConfidencePreference = findPreference("ocrConfidence")!! - - sharedPreferences.edit { - putInt("ocrConfidence", ocrConfidencePreference.value) - commit() - } - } - } - } - } - - override fun onResume() { - super.onResume() - - // Makes sure that OnSharedPreferenceChangeListener works properly and avoids the situation where the app suddenly stops triggering the listener. - preferenceScreen.sharedPreferences?.registerOnSharedPreferenceChangeListener(onSharedPreferenceChangeListener) - } - - override fun onPause() { - super.onPause() - preferenceScreen.sharedPreferences?.unregisterOnSharedPreferenceChangeListener(onSharedPreferenceChangeListener) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/steve1316/uma_android_automation/ui/settings/SettingsFragment.kt b/app/src/main/java/com/steve1316/uma_android_automation/ui/settings/SettingsFragment.kt deleted file mode 100644 index ad29a702..00000000 --- a/app/src/main/java/com/steve1316/uma_android_automation/ui/settings/SettingsFragment.kt +++ /dev/null @@ -1,269 +0,0 @@ -package com.steve1316.uma_android_automation.ui.settings - -import android.content.SharedPreferences -import android.os.Bundle -import android.util.Log -import androidx.core.content.edit -import androidx.navigation.fragment.findNavController -import androidx.preference.* -import com.steve1316.uma_android_automation.MainActivity -import com.steve1316.uma_android_automation.R - -class SettingsFragment : PreferenceFragmentCompat() { - private val logTag: String = "[${MainActivity.loggerTag}]SettingsFragment" - - private lateinit var sharedPreferences: SharedPreferences - - // This listener is triggered whenever the user changes a Preference setting in the Settings Page. - private val onSharedPreferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key -> - if (key != null) { - when (key) { - "campaign" -> { - val campaignListPreference = findPreference("campaign")!! - campaignListPreference.summary = "Selected: ${campaignListPreference.value}" - sharedPreferences.edit { - putString("campaign", campaignListPreference.value) - commit() - } - } - "enableFarmingFans" -> { - val enableFarmingFansPreference = findPreference("enableFarmingFans")!! - val daysToRunExtraRacesPreference = findPreference("daysToRunExtraRaces")!! - - daysToRunExtraRacesPreference.isEnabled = enableFarmingFansPreference.isChecked - - sharedPreferences.edit { - putBoolean("enableFarmingFans", enableFarmingFansPreference.isChecked) - commit() - } - } - "daysToRunExtraRaces" -> { - val daysToRunExtraRacesPreference = findPreference("daysToRunExtraRaces")!! - - sharedPreferences.edit { - putInt("daysToRunExtraRaces", daysToRunExtraRacesPreference.value) - commit() - } - } - "enableSkillPointCheck" -> { - val enableSkillPointCheckPreference = findPreference("enableSkillPointCheck")!! - val skillPointCheckPreference = findPreference("skillPointCheck")!! - skillPointCheckPreference.isEnabled = enableSkillPointCheckPreference.isChecked - - sharedPreferences.edit { - putBoolean("enableSkillPointCheck", enableSkillPointCheckPreference.isChecked) - commit() - } - } - "skillPointCheck" -> { - val skillPointCheckPreference = findPreference("skillPointCheck")!! - - sharedPreferences.edit { - putInt("skillPointCheck", skillPointCheckPreference.value) - commit() - } - } - "enablePopupCheck" -> { - val enablePopupCheckPreference = findPreference("enablePopupCheck")!! - - sharedPreferences.edit { - putBoolean("enablePopupCheck", enablePopupCheckPreference.isChecked) - commit() - } - } - "disableRaceRetries" -> { - val disableRaceRetriesPreference = findPreference("disableRaceRetries")!! - - sharedPreferences.edit { - putBoolean("disableRaceRetries", disableRaceRetriesPreference.isChecked) - commit() - } - } - "enableStopOnMandatoryRace" -> { - val enableStopOnMandatoryRacePreference = findPreference("enableStopOnMandatoryRace")!! - - sharedPreferences.edit { - putBoolean("enableStopOnMandatoryRace", enableStopOnMandatoryRacePreference.isChecked) - commit() - } - } - "enablePrioritizeEnergyOptions" -> { - val enablePrioritizeEnergyOptionsPreference = findPreference("enablePrioritizeEnergyOptions")!! - - sharedPreferences.edit { - putBoolean("enablePrioritizeEnergyOptions", enablePrioritizeEnergyOptionsPreference.isChecked) - commit() - } - } - "enableForceRacing" -> { - val enableForceRacingPreference = findPreference("enableForceRacing")!! - - sharedPreferences.edit { - putBoolean("enableForceRacing", enableForceRacingPreference.isChecked) - commit() - } - } - "debugMode" -> { - val debugModePreference = findPreference("debugMode")!! - - sharedPreferences.edit { - putBoolean("debugMode", debugModePreference.isChecked) - commit() - } - } - "confidence" -> { - val confidencePreference = findPreference("confidence")!! - - sharedPreferences.edit { - putInt("confidence", confidencePreference.value) - commit() - } - } - "customScale" -> { - val customScalePreference = findPreference("customScale")!! - - sharedPreferences.edit { - putInt("customScale", customScalePreference.value) - commit() - } - } - "debugMode_startTemplateMatchingTest" -> { - val debugModeStartTemplateMatchingTestPreference = findPreference("debugMode_startTemplateMatchingTest")!! - - sharedPreferences.edit { - putBoolean("debugMode_startTemplateMatchingTest", debugModeStartTemplateMatchingTestPreference.isChecked) - commit() - } - } - "debugMode_startSingleTrainingFailureOCRTest" -> { - val debugModeStartSingleTrainingFailureOCRTestPreference = findPreference("debugMode_startSingleTrainingFailureOCRTest")!! - - sharedPreferences.edit { - putBoolean("debugMode_startSingleTrainingFailureOCRTest", debugModeStartSingleTrainingFailureOCRTestPreference.isChecked) - commit() - } - } - "debugMode_startComprehensiveTrainingFailureOCRTest" -> { - val debugModeStartComprehensiveTrainingFailureOCRTestPreference = findPreference("debugMode_startComprehensiveTrainingFailureOCRTest")!! - - sharedPreferences.edit { - putBoolean("debugMode_startComprehensiveTrainingFailureOCRTest", debugModeStartComprehensiveTrainingFailureOCRTestPreference.isChecked) - commit() - } - } - "hideComparisonResults" -> { - val hideComparisonResultsPreference = findPreference("hideComparisonResults")!! - - sharedPreferences.edit { - putBoolean("hideComparisonResults", hideComparisonResultsPreference.isChecked) - commit() - } - } - } - } - } - - override fun onResume() { - super.onResume() - - // Makes sure that OnSharedPreferenceChangeListener works properly and avoids the situation where the app suddenly stops triggering the listener. - preferenceScreen.sharedPreferences?.registerOnSharedPreferenceChangeListener(onSharedPreferenceChangeListener) - } - - override fun onPause() { - super.onPause() - preferenceScreen.sharedPreferences?.unregisterOnSharedPreferenceChangeListener(onSharedPreferenceChangeListener) - } - - // This function is called right after the user navigates to the SettingsFragment. - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - // Display the layout using the preferences xml. - setPreferencesFromResource(R.xml.preferences, rootKey) - - // Get the SharedPreferences. - sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) - - // Grab the saved preferences from the previous time the user used the app. - val campaign: String = sharedPreferences.getString("campaign", "")!! - val enableFarmingFans: Boolean = sharedPreferences.getBoolean("enableFarmingFans", false) - val daysToRunExtraRaces: Int = sharedPreferences.getInt("daysToRunExtraRaces", 4) - val enableSkillPointCheck: Boolean = sharedPreferences.getBoolean("enableSkillPointCheck", false) - val skillPointCheck: Int = sharedPreferences.getInt("skillPointCheck", 750) - val enablePopupCheck: Boolean = sharedPreferences.getBoolean("enablePopupCheck", false) - val disableRaceRetries: Boolean = sharedPreferences.getBoolean("disableRaceRetries", false) - val enableStopOnMandatoryRace: Boolean = sharedPreferences.getBoolean("enableStopOnMandatoryRace", false) - val enablePrioritizeEnergyOptions: Boolean = sharedPreferences.getBoolean("enablePrioritizeEnergyOptions", false) - val enableForceRacing: Boolean = sharedPreferences.getBoolean("enableForceRacing", false) - val debugMode: Boolean = sharedPreferences.getBoolean("debugMode", false) - val confidence: Int = sharedPreferences.getInt("confidence", 80) - val customScale: Int = sharedPreferences.getInt("customScale", 100) - val debugModeStartTemplateMatchingTest: Boolean = sharedPreferences.getBoolean("debugMode_startTemplateMatchingTest", false) - val debugModeStartSingleTrainingFailureOCRTest: Boolean = sharedPreferences.getBoolean("debugMode_startSingleTrainingFailureOCRTest", false) - val debugModeStartComprehensiveTrainingFailureOCRTest: Boolean = sharedPreferences.getBoolean("debugMode_startComprehensiveTrainingFailureOCRTest", false) - val hideComparisonResults: Boolean = sharedPreferences.getBoolean("hideComparisonResults", true) - - // Get references to the Preference components. - val campaignListPreference = findPreference("campaign")!! - val enableFarmingFansPreference = findPreference("enableFarmingFans")!! - val daysToRunExtraRacesPreference = findPreference("daysToRunExtraRaces")!! - val enableSkillPointCheckPreference = findPreference("enableSkillPointCheck")!! - val skillPointCheckPreference = findPreference("skillPointCheck")!! - val enablePopupCheckPreference = findPreference("enablePopupCheck")!! - val disableRaceRetriesPreference = findPreference("disableRaceRetries")!! - val enableStopOnMandatoryRacePreference = findPreference("enableStopOnMandatoryRace")!! - val enablePrioritizeEnergyOptionsPreference = findPreference("enablePrioritizeEnergyOptions")!! - val enableForceRacingPreference = findPreference("enableForceRacing")!! - val debugModePreference = findPreference("debugMode")!! - val confidencePreference = findPreference("confidence")!! - val customScalePreference = findPreference("customScale")!! - val debugModeStartTemplateMatchingTestPreference = findPreference("debugMode_startTemplateMatchingTest")!! - val debugModeStartSingleTrainingFailureOCRTestPreference = findPreference("debugMode_startSingleTrainingFailureOCRTest")!! - val debugModeStartComprehensiveTrainingFailureOCRTestPreference = findPreference("debugMode_startComprehensiveTrainingFailureOCRTest")!! - val hideComparisonResultsPreference = findPreference("hideComparisonResults")!! - - // Now set the following values from the shared preferences. - campaignListPreference.value = campaign - if (campaign != "") { - campaignListPreference.summary = "Selected: ${campaignListPreference.value}" - } - enableFarmingFansPreference.isChecked = enableFarmingFans - daysToRunExtraRacesPreference.isEnabled = enableFarmingFansPreference.isChecked - daysToRunExtraRacesPreference.value = daysToRunExtraRaces - enableSkillPointCheckPreference.isChecked = enableSkillPointCheck - skillPointCheckPreference.value = skillPointCheck - enablePopupCheckPreference.isChecked = enablePopupCheck - disableRaceRetriesPreference.isChecked = disableRaceRetries - enableStopOnMandatoryRacePreference.isChecked = enableStopOnMandatoryRace - enablePrioritizeEnergyOptionsPreference.isChecked = enablePrioritizeEnergyOptions - enableForceRacingPreference.isChecked = enableForceRacing - debugModePreference.isChecked = debugMode - confidencePreference.value = confidence - customScalePreference.value = customScale - debugModeStartTemplateMatchingTestPreference.isChecked = debugModeStartTemplateMatchingTest - debugModeStartSingleTrainingFailureOCRTestPreference.isChecked = debugModeStartSingleTrainingFailureOCRTest - debugModeStartComprehensiveTrainingFailureOCRTestPreference.isChecked = debugModeStartComprehensiveTrainingFailureOCRTest - hideComparisonResultsPreference.isChecked = hideComparisonResults - skillPointCheckPreference.isEnabled = enableSkillPointCheckPreference.isChecked - - // Solution courtesy of https://stackoverflow.com/a/63368599 - // In short, Fragments via the mobile_navigation.xml are children of NavHostFragment, not MainActivity's supportFragmentManager. - // This is why using the method described in official Google docs via OnPreferenceStartFragmentCallback and using the supportFragmentManager is not correct for this instance. - findPreference("trainingOptions")?.setOnPreferenceClickListener { - // Navigate to the TrainingFragment. - findNavController().navigate(R.id.nav_training) - true - } - findPreference("trainingEventOptions")?.setOnPreferenceClickListener { - // Navigate to the TrainingEventFragment. - findNavController().navigate(R.id.nav_training_event) - true - } - findPreference("ocrOptions")?.setOnPreferenceClickListener { - // Navigate to the OCRFragment. - findNavController().navigate(R.id.nav_ocr) - true - } - - Log.d(logTag, "Main Preferences created successfully.") - } -} \ No newline at end of file diff --git a/app/src/main/java/com/steve1316/uma_android_automation/ui/settings/TrainingEventFragment.kt b/app/src/main/java/com/steve1316/uma_android_automation/ui/settings/TrainingEventFragment.kt deleted file mode 100644 index c32c8ded..00000000 --- a/app/src/main/java/com/steve1316/uma_android_automation/ui/settings/TrainingEventFragment.kt +++ /dev/null @@ -1,250 +0,0 @@ -package com.steve1316.uma_android_automation.ui.settings - -import android.app.AlertDialog -import android.content.SharedPreferences -import android.os.Bundle -import android.util.Log -import androidx.core.content.edit -import androidx.preference.* -import com.steve1316.uma_android_automation.MainActivity -import com.steve1316.uma_android_automation.R - -class TrainingEventFragment : PreferenceFragmentCompat() { - private val logTag: String = "[${MainActivity.loggerTag}]TrainingEventFragment" - - private lateinit var sharedPreferences: SharedPreferences - - private lateinit var builder: AlertDialog.Builder - - private lateinit var items: Array - private lateinit var checkedItems: BooleanArray - private var userSelectedOptions: ArrayList = arrayListOf() - - // This listener is triggered whenever the user changes a Preference setting in the Training Event Settings Page. - private val onSharedPreferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key -> - val characterPicker: ListPreference = findPreference("characterPicker")!! - val selectAllCharactersCheckBox: CheckBoxPreference = findPreference("selectAllCharactersCheckBox")!! - val selectAllSupportCardsCheckBox: CheckBoxPreference = findPreference("selectAllSupportCardsCheckBox")!! - - if (key != null) { - // Note that is no need to handle the Preference that allows multiple selection here as it is already handled in its own function. - when (key) { - "characterPicker" -> { - sharedPreferences.edit { - putString("character", characterPicker.value) - commit() - } - - characterPicker.summary = "Covers all R, SR and SSR variants into one.\n\n${characterPicker.value}" - } - "selectAllCharactersCheckBox" -> { - sharedPreferences.edit { - putBoolean("selectAllCharacters", selectAllCharactersCheckBox.isChecked) - } - - characterPicker.isEnabled = !selectAllCharactersCheckBox.isChecked - characterPicker.value = "" - characterPicker.summary = "Covers all R, SR and SSR variants into one." - sharedPreferences.edit { - remove("character") - commit() - } - } - "selectAllSupportCardsCheckBox" -> { - sharedPreferences.edit { - putBoolean("selectAllSupportCards", selectAllSupportCardsCheckBox.isChecked) - } - - // Grab the Support Card items array and then enable/disable the multi-picker. - items = resources.getStringArray(R.array.support_list) - val multiplePreference: Preference = findPreference("supportPicker")!! - multiplePreference.isEnabled = !selectAllSupportCardsCheckBox.isChecked - - if (multiplePreference.isEnabled) { - // Repopulate the multi-picker for Support Cards. - createSupportCardPicker() - } else { - multiplePreference.summary = "Covers all R, SR and SSR variants into one." - } - - // Clear the selected Support Cards and then remove the setting from SharedPreferences. - userSelectedOptions.clear() - sharedPreferences.edit { - remove("supportList") - apply() - } - } - } - } - } - - override fun onResume() { - super.onResume() - - // Makes sure that OnSharedPreferenceChangeListener works properly and avoids the situation where the app suddenly stops triggering the listener. - preferenceScreen.sharedPreferences?.registerOnSharedPreferenceChangeListener(onSharedPreferenceChangeListener) - } - - override fun onPause() { - super.onPause() - preferenceScreen.sharedPreferences?.unregisterOnSharedPreferenceChangeListener(onSharedPreferenceChangeListener) - } - - // This function is called right after the user navigates to the SettingsFragment. - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - // Display the layout using the preferences xml. - setPreferencesFromResource(R.xml.preferences_training_event, rootKey) - - // Get the SharedPreferences. - sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) - - // Grab the saved preferences from the previous time the user used the app. - val character = sharedPreferences.getString("character", "") - val selectAllCharacters = sharedPreferences.getBoolean("selectAllCharacters", true) - val selectAllSupportCards = sharedPreferences.getBoolean("selectAllSupportCards", true) - - // Get references to the Preference components. - val characterPicker: ListPreference = findPreference("characterPicker")!! - val multiplePreference: Preference = findPreference("supportPicker")!! - val selectAllCharactersCheckBox: CheckBoxPreference = findPreference("selectAllCharactersCheckBox")!! - val selectAllSupportCardsCheckBox: CheckBoxPreference = findPreference("selectAllSupportCardsCheckBox")!! - - // Now set the following values from the shared preferences. - - if (character != null && character.isNotEmpty() && character != "") { - characterPicker.value = character - characterPicker.summary = "Covers all R, SR and SSR variants into one.\n\n${characterPicker.value}" - } - - // Populate the list in the multi-picker for Support Cards. - if (!selectAllSupportCards) { - multiplePreference.isEnabled = true - createSupportCardPicker() - } else { - multiplePreference.isEnabled = false - } - - characterPicker.isEnabled = !selectAllCharactersCheckBox.isChecked - multiplePreference.isEnabled = !selectAllSupportCardsCheckBox.isChecked - selectAllCharactersCheckBox.isChecked = selectAllCharacters - selectAllSupportCardsCheckBox.isChecked = selectAllSupportCards - - Log.d(logTag, "Training Event Preferences created successfully.") - } - - /** - * Build the Multi-Picker Alert Dialog for the Support Cards. - */ - private fun createSupportCardPicker() { - val multiplePreference: Preference = findPreference("supportPicker")!! - val savedOptions = sharedPreferences.getString("supportList", "")!!.split("|") - val selectedOptions = sharedPreferences.getString("selectedOptions", "")!!.split("|") - - // Update the Preference's summary to reflect the order of options selected if the user did it before. - if (savedOptions.toList().isEmpty() || savedOptions.toList()[0] == "") { - multiplePreference.summary = "Covers all R, SR and SSR variants into one." - } else { - multiplePreference.summary = "Covers all R, SR and SSR variants into one.\n\n${savedOptions.toList()}" - } - - multiplePreference.setOnPreferenceClickListener { - // Create the AlertDialog that pops up after clicking on this Preference. - builder = AlertDialog.Builder(context) - builder.setTitle("Select Option(s)") - - // Grab the Support Card items array. - items = resources.getStringArray(R.array.support_list) - - // Populate the list for multiple options if this is the first time. - if (savedOptions.isEmpty() || savedOptions[0] == "") { - checkedItems = BooleanArray(items.size) - var index = 0 - items.forEach { _ -> - checkedItems[index] = false - index++ - } - } else { - checkedItems = BooleanArray(items.size) - var index = 0 - items.forEach { - // Populate the checked items BooleanArray with true or false depending on what the user selected before. - checkedItems[index] = savedOptions.contains(it) - index++ - } - - // Repopulate the user selected options according to its order selected. - userSelectedOptions.clear() - selectedOptions.forEach { - userSelectedOptions.add(it.toInt()) - } - } - - // Set the selectable items for this AlertDialog. - builder.setMultiChoiceItems(items, checkedItems) { _, position, isChecked -> - if (isChecked) { - userSelectedOptions.add(position) - } else { - userSelectedOptions.remove(position) - } - } - - // Set the AlertDialog's PositiveButton. - builder.setPositiveButton("OK") { _, _ -> - // Grab the options using the acquired indexes. This will put them in order from the user's highest to lowest priority. - val values: ArrayList = arrayListOf() - - userSelectedOptions.forEach { - values.add(items[it]) - } - - // Join the elements together into a String with the "|" delimiter in order to keep its order when storing into SharedPreferences. - val newValues = values.joinToString("|") - val newSelectedOptions = userSelectedOptions.joinToString("|") - - // Note: putStringSet does not support ordering or duplicate values. If you need ordering/duplicate values, either concatenate the values together as a String separated by a - // delimiter or think of another way. - sharedPreferences.edit { - putString("supportList", newValues) - putString("selectedOptions", newSelectedOptions) - apply() - } - - // Recreate the AlertDialog again to update it with the newly selected items. - createSupportCardPicker() - - if (values.toList().isEmpty()) { - multiplePreference.summary = "Covers all R, SR and SSR variants into one." - } else { - multiplePreference.summary = "Covers all R, SR and SSR variants into one.\n\n${values.toList()}" - } - } - - // Set the AlertDialog's NegativeButton. - builder.setNegativeButton("Dismiss") { dialog, _ -> dialog?.dismiss() } - - // Set the AlertDialog's NeutralButton. - builder.setNeutralButton("Clear all") { _, _ -> - // Go through every checked item and set them to false. - for (i in checkedItems.indices) { - checkedItems[i] = false - } - - // After that, clear the list of user-selected options and the one in SharedPreferences. - userSelectedOptions.clear() - sharedPreferences.edit { - remove("supportList") - apply() - } - - // Recreate the AlertDialog again to update it with the newly selected items and reset its summary. - createSupportCardPicker() - multiplePreference.summary = "Covers all R, SR and SSR variants into one." - } - - // Finally, show the AlertDialog to the user. - builder.create().show() - - true - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/steve1316/uma_android_automation/ui/settings/TrainingFragment.kt b/app/src/main/java/com/steve1316/uma_android_automation/ui/settings/TrainingFragment.kt deleted file mode 100644 index fabb9fd0..00000000 --- a/app/src/main/java/com/steve1316/uma_android_automation/ui/settings/TrainingFragment.kt +++ /dev/null @@ -1,354 +0,0 @@ -package com.steve1316.uma_android_automation.ui.settings - -import android.app.AlertDialog -import android.content.SharedPreferences -import android.os.Bundle -import android.util.Log -import android.widget.Toast -import androidx.core.content.edit -import androidx.preference.* -import androidx.navigation.fragment.findNavController -import com.steve1316.uma_android_automation.MainActivity -import com.steve1316.uma_android_automation.R - -class TrainingFragment : PreferenceFragmentCompat() { - private val logTag: String = "[${MainActivity.loggerTag}]TrainingFragment" - - private lateinit var sharedPreferences: SharedPreferences - - private lateinit var builder: AlertDialog.Builder - - private lateinit var items: Array - private lateinit var checkedItems: BooleanArray - private var userSelectedOptions: ArrayList = arrayListOf() - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - // Display the layout using the preferences xml. - setPreferencesFromResource(R.xml.preferences_training, rootKey) - - // Get the SharedPreferences. - sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) - - // Grab the saved preferences from the previous time the user used the app. - val trainingBlacklist = sharedPreferences.getStringSet("trainingBlacklist", setOf()) - val maximumFailureChance = sharedPreferences.getInt("maximumFailureChance", 15) - val disableTrainingOnMaxedStat = sharedPreferences.getBoolean("disableTrainingOnMaxedStat", true) - val focusOnSparkStatTarget = sharedPreferences.getBoolean("focusOnSparkStatTarget", false) - - // Get references to the Preference components. - val trainingBlacklistPreference = findPreference("trainingBlacklist")!! - val maximumFailureChancePreference = findPreference("maximumFailureChance")!! - val disableTrainingOnMaxedStatPreference = findPreference("disableTrainingOnMaxedStat")!! - val focusOnSparkStatTargetPreference = findPreference("focusOnSparkStatTarget")!! - - // Now set the following values from the SharedPreferences. - trainingBlacklistPreference.values = trainingBlacklist - maximumFailureChancePreference.value = maximumFailureChance - disableTrainingOnMaxedStatPreference.isChecked = disableTrainingOnMaxedStat - focusOnSparkStatTargetPreference.isChecked = focusOnSparkStatTarget - createMultiSelectAlertDialog() - setupStatTargetPreferences() - - // Set this Preference listener to prevent users from blacklisting all 5 Trainings. - trainingBlacklistPreference.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _: Preference, newValue: Any? -> - if ((newValue as Set<*>).size != 5) { - true - } else { - Toast.makeText(context, "Cannot blacklist all 5 Trainings!", Toast.LENGTH_SHORT).show() - false - } - } - - // Update the summaries of the Preference components. - updateSummaries() - - Log.d(logTag, "Training Preferences created successfully.") - } - - /** - * Setup click listeners for the stat target preferences to navigate to TrainingStatTargetFragment. - */ - private fun setupStatTargetPreferences() { - // Sprint distance - val sprintPreference = findPreference("trainingSprintStatTarget")!! - sprintPreference.setOnPreferenceClickListener { - val bundle = Bundle().apply { - putString("distanceType", "trainingSprintStatTarget") - putString("distanceTitle", "Sprint") - } - findNavController().navigate(R.id.nav_training_stat_target, bundle) - true - } - - // Mile distance - val milePreference = findPreference("trainingMileStatTarget")!! - milePreference.setOnPreferenceClickListener { - val bundle = Bundle().apply { - putString("distanceType", "trainingMileStatTarget") - putString("distanceTitle", "Mile") - } - findNavController().navigate(R.id.nav_training_stat_target, bundle) - true - } - - // Medium distance - val mediumPreference = findPreference("trainingMediumStatTarget")!! - mediumPreference.setOnPreferenceClickListener { - val bundle = Bundle().apply { - putString("distanceType", "trainingMediumStatTarget") - putString("distanceTitle", "Medium") - } - findNavController().navigate(R.id.nav_training_stat_target, bundle) - true - } - - // Long distance - val longPreference = findPreference("trainingLongStatTarget")!! - longPreference.setOnPreferenceClickListener { - val bundle = Bundle().apply { - putString("distanceType", "trainingLongStatTarget") - putString("distanceTitle", "Long") - } - findNavController().navigate(R.id.nav_training_stat_target, bundle) - true - } - } - - // This listener is triggered whenever the user changes a Preference setting in the Training Settings Page. - private val onSharedPreferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key -> - if (key != null) { - when (key) { - "trainingBlacklist" -> { - val trainingBlacklistPreference = findPreference("trainingBlacklist")!! - - sharedPreferences.edit { - putStringSet("trainingBlacklist", trainingBlacklistPreference.values) - commit() - } - } - "maximumFailureChance" -> { - val maximumFailureChancePreference = findPreference("maximumFailureChance")!! - - sharedPreferences.edit { - putInt("maximumFailureChance", maximumFailureChancePreference.value) - commit() - } - } - "disableTrainingOnMaxedStat" -> { - val disableTrainingOnMaxedStatPreference = findPreference("disableTrainingOnMaxedStat")!! - sharedPreferences.edit { - putBoolean("disableTrainingOnMaxedStat", disableTrainingOnMaxedStatPreference.isChecked) - commit() - } - } - "focusOnSparkStatTarget" -> { - val focusOnSparkStatTargetPreference = findPreference("focusOnSparkStatTarget")!! - sharedPreferences.edit { - putBoolean("focusOnSparkStatTarget", focusOnSparkStatTargetPreference.isChecked) - commit() - } - } - } - - updateSummaries() - } - } - - override fun onResume() { - super.onResume() - - // Makes sure that OnSharedPreferenceChangeListener works properly and avoids the situation where the app suddenly stops triggering the listener. - preferenceScreen.sharedPreferences?.registerOnSharedPreferenceChangeListener(onSharedPreferenceChangeListener) - updateSummaries() - } - - override fun onPause() { - super.onPause() - preferenceScreen.sharedPreferences?.unregisterOnSharedPreferenceChangeListener(onSharedPreferenceChangeListener) - } - - /** - * Update the summaries of the Preference components on this page. - */ - private fun updateSummaries() { - val trainingBlacklistPreference = findPreference("trainingBlacklist")!! - trainingBlacklistPreference.summary = if (trainingBlacklistPreference.values.isNotEmpty()) { - "Select Training(s) to blacklist from being selected in order to narrow the focus of overall Training. Note that the blacklist is ignored during Junior Year to focus on building bond levels.\n\nBlacklisted: ${trainingBlacklistPreference.values.joinToString(", ")}" - } else { - "Select Training(s) to blacklist from being selected in order to narrow the focus of overall Training. Note that the blacklist is ignored during Junior Year to focus on building bond levels.\n\nNone Selected" - } - - val statPrioritizationPreference = findPreference("statPrioritization")!! - val statPrioritization: List = sharedPreferences.getString("statPrioritization", "")!!.split("|") - statPrioritizationPreference.summary = if (statPrioritization.isNotEmpty() && statPrioritization[0] != "") { - var summaryBody = "Select Stat(s) to prioritize in order from highest priority to lowest. Any stat not selected will be assigned the lowest priority.\n\nOrder of Stat Prioritisation:" - - var count = 1 - statPrioritization.forEach { stat -> - summaryBody += "\n$count. $stat" - count++ - } - - summaryBody - } else { - "Select Stat(s) to prioritize in order from highest priority to lowest. Any stat not selected will be assigned the lowest priority.\n\nFollowing Default Prioritisation Order:\n1. " + - "Speed\n2. Stamina\n3. Power\n4. Wit\n5. Guts" - } - - // Update the stat target summaries for each distance type. - // Sprint distance - val sprintPreference = findPreference("trainingSprintStatTarget")!! - val sprintSpeed = sharedPreferences.getInt("trainingSprintStatTarget_speedStatTarget", 900) - val sprintStamina = sharedPreferences.getInt("trainingSprintStatTarget_staminaStatTarget", 300) - val sprintPower = sharedPreferences.getInt("trainingSprintStatTarget_powerStatTarget", 600) - val sprintGuts = sharedPreferences.getInt("trainingSprintStatTarget_gutsStatTarget", 300) - val sprintWit = sharedPreferences.getInt("trainingSprintStatTarget_witStatTarget", 300) - sprintPreference.summary = "Set the stat targets for Sprint distance.\n\nCurrent Targets:\nSpeed: $sprintSpeed\nStamina: $sprintStamina\nPower: $sprintPower\nGuts: $sprintGuts\nWit: $sprintWit" - - // Mile distance - val milePreference = findPreference("trainingMileStatTarget")!! - val mileSpeed = sharedPreferences.getInt("trainingMileStatTarget_speedStatTarget", 900) - val mileStamina = sharedPreferences.getInt("trainingMileStatTarget_staminaStatTarget", 300) - val milePower = sharedPreferences.getInt("trainingMileStatTarget_powerStatTarget", 600) - val mileGuts = sharedPreferences.getInt("trainingMileStatTarget_gutsStatTarget", 300) - val mileWit = sharedPreferences.getInt("trainingMileStatTarget_witStatTarget", 300) - milePreference.summary = "Set the stat targets for Mile distance.\n\nCurrent Targets:\nSpeed: $mileSpeed\nStamina: $mileStamina\nPower: $milePower\nGuts: $mileGuts\nWit: $mileWit" - - // Medium distance - val mediumPreference = findPreference("trainingMediumStatTarget")!! - val mediumSpeed = sharedPreferences.getInt("trainingMediumStatTarget_speedStatTarget", 800) - val mediumStamina = sharedPreferences.getInt("trainingMediumStatTarget_staminaStatTarget", 450) - val mediumPower = sharedPreferences.getInt("trainingMediumStatTarget_powerStatTarget", 550) - val mediumGuts = sharedPreferences.getInt("trainingMediumStatTarget_gutsStatTarget", 300) - val mediumWit = sharedPreferences.getInt("trainingMediumStatTarget_witStatTarget", 300) - mediumPreference.summary = "Set the stat targets for Medium distance.\n\nCurrent Targets:\nSpeed: $mediumSpeed\nStamina: $mediumStamina\nPower: $mediumPower\nGuts: $mediumGuts\nWit: $mediumWit" - - // Long distance - val longPreference = findPreference("trainingLongStatTarget")!! - val longSpeed = sharedPreferences.getInt("trainingLongStatTarget_speedStatTarget", 700) - val longStamina = sharedPreferences.getInt("trainingLongStatTarget_staminaStatTarget", 600) - val longPower = sharedPreferences.getInt("trainingLongStatTarget_powerStatTarget", 450) - val longGuts = sharedPreferences.getInt("trainingLongStatTarget_gutsStatTarget", 300) - val longWit = sharedPreferences.getInt("trainingLongStatTarget_witStatTarget", 300) - longPreference.summary = "Set the stat targets for Long distance.\n\nCurrent Targets:\nSpeed: $longSpeed\nStamina: $longStamina\nPower: $longPower\nGuts: $longGuts\nWit: $longWit" - } - - /** - * Builds and displays a AlertDialog for multi-selection that retains its order. - * This also serves the purpose of populating the Preference with previously selected values from SharedPreferences. - */ - private fun createMultiSelectAlertDialog() { - val multiplePreference = findPreference("statPrioritization")!! - val key = "statPrioritization" - val savedOptions: List = sharedPreferences.getString("statPrioritization", "")!!.split("|") - val selectedOptions: List = sharedPreferences.getString("selectedOptions", "")!!.split("|") - - // Update the Preference's summary to reflect the order of options selected if the user did it before. - updateSummaries() - - multiplePreference.setOnPreferenceClickListener { - // Create the AlertDialog that pops up after clicking on this Preference. - builder = AlertDialog.Builder(context) - builder.setTitle("Select Option(s)") - - // Grab the Stats items array. - items = resources.getStringArray(R.array.stats) - - // Populate the list for multiple options if this is the first time. - if (savedOptions.isEmpty() || savedOptions[0] == "") { - checkedItems = BooleanArray(items.size) - var index = 0 - items.forEach { _ -> - checkedItems[index] = false - index++ - } - } else { - checkedItems = BooleanArray(items.size) - var index = 0 - items.forEach { - // Populate the checked items BooleanArray with true or false depending on what the user selected before. - checkedItems[index] = savedOptions.contains(it) - index++ - } - - // Repopulate the user selected options according to its order selected. - userSelectedOptions.clear() - selectedOptions.filter { it.isNotEmpty() }.forEach { - userSelectedOptions.add(it.toInt()) - } - } - - // Set the selectable items for this AlertDialog. - builder.setMultiChoiceItems(items, checkedItems) { _, position, isChecked -> - if (isChecked) { - Log.d(logTag, "Adding $position") - userSelectedOptions.add(position) - } else { - Log.d(logTag, "Removing $position") - userSelectedOptions.remove(position) - } - } - - // Set the AlertDialog's PositiveButton. - builder.setPositiveButton("OK") { _, _ -> - // Grab the options using the acquired indexes. This will put them in order from the user's highest to lowest priority. - val values: ArrayList = arrayListOf() - - userSelectedOptions.forEach { - values.add(items[it]) - } - - // Join the elements together into a String with the "|" delimiter in order to keep its order when storing into SharedPreferences. - val newValues = values.joinToString("|") - val newSelectedOptions = userSelectedOptions.joinToString("|") - - // Note: putStringSet does not support ordering or duplicate values. If you need ordering/duplicate values, either concatenate the values together as a String separated by a - // delimiter or think of another way. - sharedPreferences.edit { - putString(key, newValues) - putString("selectedOptions", newSelectedOptions) - apply() - } - - // Recreate the AlertDialog again to update it with the newly selected items. - createMultiSelectAlertDialog() - updateSummaries() - } - - // Set the AlertDialog's NegativeButton. - builder.setNegativeButton("Dismiss") { dialog, _ -> dialog?.dismiss() } - - // Set the behavior of the AlertDialog canceling. - builder.setOnCancelListener { - it.cancel() - - // Clear any newly selected options. - userSelectedOptions.clear() - } - - // Set the AlertDialog's NeutralButton. - builder.setNeutralButton("Clear all") { _, _ -> - // Go through every checked item and set them to false. - for (i in checkedItems.indices) { - checkedItems[i] = false - } - - // After that, clear the list of user-selected options and the one in SharedPreferences. - userSelectedOptions.clear() - sharedPreferences.edit { - remove(key) - apply() - } - - // Recreate the AlertDialog again to update it with the newly selected items and reset its summary. - createMultiSelectAlertDialog() - updateSummaries() - } - - // Finally, show the AlertDialog to the user. - builder.create().show() - - true - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/steve1316/uma_android_automation/ui/settings/TrainingStatTargetFragment.kt b/app/src/main/java/com/steve1316/uma_android_automation/ui/settings/TrainingStatTargetFragment.kt deleted file mode 100644 index 19b7a4d5..00000000 --- a/app/src/main/java/com/steve1316/uma_android_automation/ui/settings/TrainingStatTargetFragment.kt +++ /dev/null @@ -1,130 +0,0 @@ -package com.steve1316.uma_android_automation.ui.settings - -import android.content.SharedPreferences -import android.os.Bundle -import android.util.Log -import androidx.core.content.edit -import androidx.preference.* -import com.steve1316.uma_android_automation.MainActivity -import com.steve1316.uma_android_automation.R - -class TrainingStatTargetFragment : PreferenceFragmentCompat() { - private val logTag: String = "[${MainActivity.loggerTag}]TrainingStatTargetFragment" - private lateinit var sharedPreferences: SharedPreferences - private lateinit var distanceType: String - - // This listener is triggered whenever the user changes a Preference setting in the Training Stat Targets Settings Page. - private val onSharedPreferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key -> - if (key != null) { - when (key) { - "speedStatTarget" -> { - val speedStatTargetPreference = findPreference("speedStatTarget")!! - sharedPreferences.edit { - putInt("${distanceType}_speedStatTarget", speedStatTargetPreference.value) - commit() - } - } - "staminaStatTarget" -> { - val staminaStatTargetPreference = findPreference("staminaStatTarget")!! - sharedPreferences.edit { - putInt("${distanceType}_staminaStatTarget", staminaStatTargetPreference.value) - commit() - } - } - "powerStatTarget" -> { - val powerStatTargetPreference = findPreference("powerStatTarget")!! - sharedPreferences.edit { - putInt("${distanceType}_powerStatTarget", powerStatTargetPreference.value) - commit() - } - } - "gutsStatTarget" -> { - val gutsStatTargetPreference = findPreference("gutsStatTarget")!! - sharedPreferences.edit { - putInt("${distanceType}_gutsStatTarget", gutsStatTargetPreference.value) - commit() - } - } - "witStatTarget" -> { - val witStatTargetPreference = findPreference("witStatTarget")!! - sharedPreferences.edit { - putInt("${distanceType}_witStatTarget", witStatTargetPreference.value) - commit() - } - } - } - } - } - - override fun onResume() { - super.onResume() - - // Makes sure that OnSharedPreferenceChangeListener works properly and avoids the situation where the app suddenly stops triggering the listener. - preferenceScreen.sharedPreferences?.registerOnSharedPreferenceChangeListener(onSharedPreferenceChangeListener) - } - - override fun onPause() { - super.onPause() - preferenceScreen.sharedPreferences?.unregisterOnSharedPreferenceChangeListener(onSharedPreferenceChangeListener) - } - - // This function is called right after the user navigates to the SettingsFragment. - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - // Display the layout using the preferences xml. - setPreferencesFromResource(R.xml.preferences_training_stat_target, rootKey) - - // Get the SharedPreferences. - sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) - - // Get the distance type from arguments - distanceType = arguments?.getString("distanceType") ?: "trainingSprintStatTarget" - - // Load the saved stat targets for this distance type - loadStatTargets() - - Log.d(logTag, "Training Stat Target Preferences created successfully for $distanceType.") - } - - /** - * Load the saved stat targets for the current distance type. - */ - private fun loadStatTargets() { - // Get references to the SeekBarPreference components - val speedStatTargetPreference = findPreference("speedStatTarget")!! - val staminaStatTargetPreference = findPreference("staminaStatTarget")!! - val powerStatTargetPreference = findPreference("powerStatTarget")!! - val gutsStatTargetPreference = findPreference("gutsStatTarget")!! - val witStatTargetPreference = findPreference("witStatTarget")!! - - // Load saved values or use defaults based on distance type - val (defaultSpeed, defaultStamina, defaultPower, defaultGuts, defaultWit) = getDefaultTargets() - - val savedSpeed = sharedPreferences.getInt("${distanceType}_speedStatTarget", defaultSpeed) - val savedStamina = sharedPreferences.getInt("${distanceType}_staminaStatTarget", defaultStamina) - val savedPower = sharedPreferences.getInt("${distanceType}_powerStatTarget", defaultPower) - val savedGuts = sharedPreferences.getInt("${distanceType}_gutsStatTarget", defaultGuts) - val savedWit = sharedPreferences.getInt("${distanceType}_witStatTarget", defaultWit) - - // Set the values - speedStatTargetPreference.value = savedSpeed - staminaStatTargetPreference.value = savedStamina - powerStatTargetPreference.value = savedPower - gutsStatTargetPreference.value = savedGuts - witStatTargetPreference.value = savedWit - } - - /** - * Get the default stat targets based on the distance type. - * - * @return The ArrayList of stat targets for training. - */ - private fun getDefaultTargets(): ArrayList { - return when (distanceType) { - "trainingSprintStatTarget" -> arrayListOf(900, 300, 600, 300, 300) - "trainingMileStatTarget" -> arrayListOf(900, 300, 600, 300, 300) - "trainingMediumStatTarget" -> arrayListOf(800, 450, 550, 300, 300) - "trainingLongStatTarget" -> arrayListOf(700, 600, 450, 300, 300) - else -> arrayListOf(900, 300, 600, 300, 300) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/steve1316/uma_android_automation/utils/BotService.kt b/app/src/main/java/com/steve1316/uma_android_automation/utils/BotService.kt deleted file mode 100644 index 5e18bb40..00000000 --- a/app/src/main/java/com/steve1316/uma_android_automation/utils/BotService.kt +++ /dev/null @@ -1,299 +0,0 @@ -package com.steve1316.uma_android_automation.utils - -import android.annotation.SuppressLint -import android.app.Service -import android.content.Context -import android.content.Intent -import android.graphics.PixelFormat -import android.os.Build -import android.os.Handler -import android.os.IBinder -import android.os.Looper -import android.util.Log -import android.view.LayoutInflater -import android.view.MotionEvent -import android.view.View -import android.view.WindowManager -import android.view.animation.Animation -import android.view.animation.AnimationUtils -import android.widget.ImageButton -import android.widget.Toast -import com.steve1316.uma_android_automation.MainActivity -import com.steve1316.uma_android_automation.R -import com.steve1316.uma_android_automation.bot.Game -import kotlin.concurrent.thread -import kotlin.math.roundToInt - -/** - * This Service will allow starting and stopping the automation workflow on a Thread based on the chosen preference settings. - * - * Source for being able to send custom Intents to BroadcastReceiver to notify users of bot state changes is from: - * https://www.tutorialspoint.com/in-android-how-to-register-a-custom-intent-filter-to-a-broadcast-receiver - */ -class BotService : Service() { - private val tag: String = "[${MainActivity.loggerTag}]BotService" - private var appName: String = "" - private lateinit var myContext: Context - private lateinit var overlayView: View - private lateinit var overlayButton: ImageButton - - private lateinit var playButtonAnimation: Animation - private lateinit var playButtonAnimationAlt: Animation - private lateinit var stopButtonAnimation: Animation - private var currentPlayButtonAnimationType = PlayButtonAnimationType.PULSE_FADE - - /** - * Enum to track which play button animation is currently active. - */ - private enum class PlayButtonAnimationType { - PULSE_FADE, - BOUNCE_FADE - } - - companion object { - private lateinit var thread: Thread - private lateinit var windowManager: WindowManager - - // Create the LayoutParams for the floating overlay START/STOP button. - private val overlayLayoutParams = WindowManager.LayoutParams().apply { - type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY - } else { - WindowManager.LayoutParams.TYPE_SYSTEM_ALERT - } - flags = WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE - format = PixelFormat.TRANSLUCENT - width = WindowManager.LayoutParams.WRAP_CONTENT - height = WindowManager.LayoutParams.WRAP_CONTENT - windowAnimations = android.R.style.Animation_Toast - } - - var isRunning = false - } - - @SuppressLint("ClickableViewAccessibility", "InflateParams") - override fun onCreate() { - super.onCreate() - - myContext = this - appName = myContext.getString(R.string.app_name) - - // Initialize the animations for the floating overlay button. - initializeAnimations() - - // Display the overlay view layout on the screen. - overlayView = LayoutInflater.from(this).inflate(R.layout.bot_actions, null) - windowManager = getSystemService(WINDOW_SERVICE) as WindowManager - windowManager.addView(overlayView, overlayLayoutParams) - - // This button is able to be moved around the screen and clicking it will start/stop the game automation. - overlayButton = overlayView.findViewById(R.id.bot_actions_overlay_button) - - // Start the initial animations for the floating overlay button. - startAnimations() - - overlayButton.setOnTouchListener(object : View.OnTouchListener { - private var initialX: Int = 0 - private var initialY: Int = 0 - private var initialTouchX: Float = 0F - private var initialTouchY: Float = 0F - - override fun onTouch(v: View?, event: MotionEvent?): Boolean { - val action = event?.action - - if (action == MotionEvent.ACTION_DOWN) { - // Get the initial position. - initialX = overlayLayoutParams.x - initialY = overlayLayoutParams.y - - // Now get the new position. - initialTouchX = event.rawX - initialTouchY = event.rawY - - return false - } else if (action == MotionEvent.ACTION_UP) { - val elapsedTime: Long = event.eventTime - event.downTime - if (elapsedTime < 100L) { - // Update both the Notification and the overlay button to reflect the current bot status. - if (!isRunning) { - Log.d(tag, "Bot Service for $appName is now running.") - Toast.makeText(myContext, "Bot Service for $appName is now running.", Toast.LENGTH_SHORT).show() - isRunning = true - NotificationUtils.updateNotification(myContext, isRunning) - overlayButton.setImageResource(R.drawable.stop_circle_filled) - - // Switch animations from the play to the stop button animations. - startAnimations() - - var game: Game? = null - - thread = thread { - try { - game = Game(myContext) - - // Clear the Message Log. - MessageLog.clearLog() - MessageLog.saveCheck = false - - // Start with the provided settings from SharedPreferences. - game.start() - - val notificationMessage = if (game.notificationMessage != "") game.notificationMessage else "Bot has completed successfully." - NotificationUtils.updateNotification(myContext, false, notificationMessage) - } catch (e: Exception) { - if (e.toString() == "java.lang.InterruptedException") { - NotificationUtils.updateNotification(myContext, false, "Bot was manually stopped.") - } else { - NotificationUtils.updateNotification(myContext, false, "Encountered an Exception: $e.\nTap me to see more details.") - game?.printToLog("$appName encountered an Exception: ${e.stackTraceToString()}", tag = tag, isError = true) - } - } finally { - performCleanUp() - } - } - } else { - thread.interrupt() - NotificationUtils.updateNotification(myContext, false, "Bot was manually stopped.") - performCleanUp() - } - - // Returning true here freezes the animation of the click on the button. - return false - } - } else if (action == MotionEvent.ACTION_MOVE) { - val xDiff = (event.rawX - initialTouchX).roundToInt() - val yDiff = (event.rawY - initialTouchY).roundToInt() - - // Calculate the X and Y coordinates of the view. - overlayLayoutParams.x = initialX + xDiff - overlayLayoutParams.y = initialY + yDiff - - // Now update the layout. - windowManager.updateViewLayout(overlayView, overlayLayoutParams) - return false - } - - return false - } - }) - } - - /** - * Initialize the animations for the floating overlay button. - */ - private fun initializeAnimations() { - playButtonAnimation = AnimationUtils.loadAnimation(this, R.anim.play_button_animation) - playButtonAnimationAlt = AnimationUtils.loadAnimation(this, R.anim.play_button_animation_alt) - stopButtonAnimation = AnimationUtils.loadAnimation(this, R.anim.stop_button_animation) - - // Set up animation listeners for continuous cycling. - setupPlayButtonAnimationListener() - setupPlayButtonAltAnimationListener() - setupStopButtonAnimationListener() - } - - /** - * Set up the initial animation listener for the play button animation. - */ - private fun setupPlayButtonAnimationListener() { - playButtonAnimation.setAnimationListener(object : Animation.AnimationListener { - override fun onAnimationStart(animation: Animation?) {} - override fun onAnimationEnd(animation: Animation?) { - if (!isRunning) { - // Switch animations. - currentPlayButtonAnimationType = PlayButtonAnimationType.BOUNCE_FADE - overlayButton.startAnimation(playButtonAnimation) - } - } - override fun onAnimationRepeat(animation: Animation?) {} - }) - } - - /** - * Set up the other animation listener for the play button animation. - */ - private fun setupPlayButtonAltAnimationListener() { - playButtonAnimationAlt.setAnimationListener(object : Animation.AnimationListener { - override fun onAnimationStart(animation: Animation?) {} - override fun onAnimationEnd(animation: Animation?) { - if (!isRunning) { - // Switch animations. - currentPlayButtonAnimationType = PlayButtonAnimationType.PULSE_FADE - overlayButton.startAnimation(playButtonAnimationAlt) - } - } - override fun onAnimationRepeat(animation: Animation?) {} - }) - } - - /** - * Set up the animation listener for the stop button animation. - */ - private fun setupStopButtonAnimationListener() { - stopButtonAnimation.setAnimationListener(object : Animation.AnimationListener { - override fun onAnimationStart(animation: Animation?) {} - override fun onAnimationEnd(animation: Animation?) { - if (isRunning) { - // Restart the animation. - overlayButton.startAnimation(stopButtonAnimation) - } - } - override fun onAnimationRepeat(animation: Animation?) {} - }) - } - - /** - * Start the appropriate animations for the floating overlay button based on the bot state. - */ - private fun startAnimations() { - // Clear any existing animation. - overlayButton.clearAnimation() - - // Start the appropriate animation based on bot state. - if (isRunning) { - overlayButton.startAnimation(stopButtonAnimation) - } else { - currentPlayButtonAnimationType = PlayButtonAnimationType.PULSE_FADE - overlayButton.startAnimation(playButtonAnimationAlt) - } - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - // Do not attempt to restart the bot service if it crashes. - return START_NOT_STICKY - } - - override fun onBind(intent: Intent?): IBinder? { - return null - } - - override fun onDestroy() { - super.onDestroy() - - // Stop animations before removing the view. - overlayButton.clearAnimation() - - // Remove the overlay View that holds the overlay button. - windowManager.removeView(overlayView) - - val service = Intent(myContext, MyAccessibilityService::class.java) - myContext.stopService(service) - } - - /** - * Perform cleanup upon app completion or encountering an Exception. - */ - private fun performCleanUp() { - // Save the message log. - MessageLog.saveLogToFile(myContext) - - Log.d(tag, "Bot Service for $appName is now stopped.") - isRunning = false - - // Reset the overlay button's image and animation on a separate UI thread. - Handler(Looper.getMainLooper()).post { - overlayButton.setImageResource(R.drawable.play_circle_filled) - startAnimations() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/steve1316/uma_android_automation/utils/ImageUtils.kt b/app/src/main/java/com/steve1316/uma_android_automation/utils/ImageUtils.kt deleted file mode 100644 index 53456220..00000000 --- a/app/src/main/java/com/steve1316/uma_android_automation/utils/ImageUtils.kt +++ /dev/null @@ -1,2273 +0,0 @@ -package com.steve1316.uma_android_automation.utils - -import android.content.Context -import android.content.SharedPreferences -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.util.Log -import androidx.preference.PreferenceManager -import com.google.mlkit.vision.common.InputImage -import com.google.mlkit.vision.text.TextRecognition -import com.google.mlkit.vision.text.latin.TextRecognizerOptions -import com.googlecode.tesseract.android.TessBaseAPI -import com.steve1316.uma_android_automation.MainActivity -import com.steve1316.uma_android_automation.bot.Game -import org.opencv.android.Utils -import org.opencv.core.* -import org.opencv.imgcodecs.Imgcodecs -import org.opencv.imgproc.Imgproc -import java.io.File -import java.io.FileOutputStream -import java.io.IOException -import java.lang.Integer.max -import java.text.DecimalFormat -import androidx.core.graphics.scale -import androidx.core.graphics.get -import androidx.core.graphics.createBitmap -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import kotlin.collections.component1 -import kotlin.collections.component2 -import kotlin.math.sqrt -import kotlin.text.replace - - -/** - * Utility functions for image processing via CV like OpenCV. - */ -class ImageUtils(context: Context, private val game: Game) { - private val tag: String = "[${MainActivity.loggerTag}]ImageUtils" - private var myContext = context - private val matchMethod: Int = Imgproc.TM_CCOEFF_NORMED - private val decimalFormat = DecimalFormat("#.###") - private val textRecognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS) - private val tessBaseAPI: TessBaseAPI - private val tesseractLanguages = arrayListOf("eng") - - //////////////////////////////////////////////////////////////////// - //////////////////////////////////////////////////////////////////// - // SharedPreferences - private var sharedPreferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) - private val campaign: String = sharedPreferences.getString("campaign", "")!! - private var confidence: Double = sharedPreferences.getInt("confidence", 80).toDouble() / 100.0 - private var customScale: Double = sharedPreferences.getInt("customScale", 100).toDouble() / 100.0 - private val debugMode: Boolean = sharedPreferences.getBoolean("debugMode", false) - - //////////////////////////////////////////////////////////////////// - //////////////////////////////////////////////////////////////////// - // Device configuration - private val displayWidth: Int = MediaProjectionService.displayWidth - private val displayHeight: Int = MediaProjectionService.displayHeight - private val isLowerEnd: Boolean = (displayWidth == 720) - private val isDefault: Boolean = (displayWidth == 1080) - val isTablet: Boolean = (displayWidth == 1600 && displayHeight == 2560) || (displayWidth == 2560 && displayHeight == 1600) // Galaxy Tab S7 1600x2560 Portrait Mode - private val isLandscape: Boolean = (displayWidth == 2560 && displayHeight == 1600) // Galaxy Tab S7 1600x2560 Landscape Mode - private val isSplitScreen: Boolean = false // Uma Musume Pretty Derby is only playable in Portrait mode. - - // Scales - private val lowerEndScales: MutableList = generateSequence(0.50) { it + 0.01 } - .takeWhile { it <= 0.70 } - .toMutableList() - - private val middleEndScales: MutableList = generateSequence(0.50) { it + 0.01 } - .takeWhile { it <= 3.00 } - .toMutableList() - - private val tabletSplitPortraitScales: MutableList = generateSequence(0.50) { it + 0.01 } - .takeWhile { it <= 1.00 } - .toMutableList() - - private val tabletSplitLandscapeScales: MutableList = generateSequence(0.50) { it + 0.01 } - .takeWhile { it <= 1.00 } - .toMutableList() - - private val tabletPortraitScales: MutableList = generateSequence(1.00) { it + 0.01 } - .takeWhile { it <= 2.00 } - .toMutableList() - - // TODO: Separate tablet landscape scale to non-splitscreen and splitscreen scales. - - // Define template matching regions of the screen. - val regionTopHalf: IntArray = intArrayOf(0, 0, displayWidth, displayHeight / 2) - val regionBottomHalf: IntArray = intArrayOf(0, displayHeight / 2, displayWidth, displayHeight / 2) - val regionMiddle: IntArray = intArrayOf(0, displayHeight / 4, displayWidth, displayHeight / 2) - - //////////////////////////////////////////////////////////////////// - //////////////////////////////////////////////////////////////////// - - companion object { - private var matchFilePath: String = "" - - /** - * Saves the file path to the saved match image file for debugging purposes. - * - * @param filePath File path to where to store the image containing the location of where the match was found. - */ - private fun updateMatchFilePath(filePath: String) { - matchFilePath = filePath - } - } - - init { - // Set the file path to the /files/temp/ folder. - val matchFilePath: String = myContext.getExternalFilesDir(null)?.absolutePath + "/temp" - updateMatchFilePath(matchFilePath) - - // Initialize Tesseract with the traineddata model. - initTesseract() - tessBaseAPI = TessBaseAPI() - - // Start up Tesseract. - tessBaseAPI.init(myContext.getExternalFilesDir(null)?.absolutePath + "/tesseract/", "eng") - game.printToLog("[INFO] Training file loaded.\n", tag = tag) - } - - data class RaceDetails ( - val fans: Int, - val hasDoublePredictions: Boolean - ) - - data class ScaleConfidenceResult( - val scale: Double, - val confidence: Double - ) - - data class BarFillResult( - val fillPercent: Double, - val filledSegments: Int, - val dominantColor: String - ) - - //////////////////////////////////////////////////////////////////// - //////////////////////////////////////////////////////////////////// - - /** - * Starts a test to determine what scales are working on this device by looping through some template images. - * - * @return A mapping of template image names used to test and their lists of working scales. - */ - fun startTemplateMatchingTest(): MutableMap> { - val results = mutableMapOf>( - "energy" to mutableListOf(), - "tazuna" to mutableListOf(), - "skill_points" to mutableListOf() - ) - - val defaultConfidence = 0.8 - val testScaleDecimalFormat = DecimalFormat("#.##") - val testConfidenceDecimalFormat = DecimalFormat("#.##") - - for (key in results.keys) { - val (sourceBitmap, templateBitmap) = getBitmaps(key) - - // First, try the default values of 1.0 for scale and 0.8 for confidence. - val (success, _) = match(sourceBitmap, templateBitmap!!, key, useSingleScale = true, customConfidence = defaultConfidence, testScale = 1.0) - if (success) { - game.printToLog("[TEST] Initial test for $key succeeded at the default values.", tag = tag) - results[key]?.add(ScaleConfidenceResult(1.0, defaultConfidence)) - continue // If it works, skip to the next template. - } - - // If not, try all scale/confidence combinations. - val scalesToTest = mutableListOf() - var scale = 0.5 - while (scale <= 3.0) { - scalesToTest.add(testScaleDecimalFormat.format(scale).toDouble()) - scale += 0.1 - } - - for (testScale in scalesToTest) { - var confidence = 0.6 - while (confidence <= 1.0) { - val formattedConfidence = testConfidenceDecimalFormat.format(confidence).toDouble() - val (testSuccess, _) = match(sourceBitmap, templateBitmap, key, useSingleScale = true, customConfidence = formattedConfidence, testScale = testScale) - if (testSuccess) { - game.printToLog("[TEST] Test for $key succeeded at scale $testScale and confidence $formattedConfidence.", tag = tag) - results[key]?.add(ScaleConfidenceResult(testScale, formattedConfidence)) - } - confidence += 0.1 - } - } - } - - return results - } - - //////////////////////////////////////////////////////////////////// - //////////////////////////////////////////////////////////////////// - - /** - * Match between the source Bitmap from /files/temp/ and the template Bitmap from the assets folder. - * - * @param sourceBitmap Bitmap from the /files/temp/ folder. - * @param templateBitmap Bitmap from the assets folder. - * @param templateName Name of the template image to use in debugging log messages. - * @param region Specify the region consisting of (x, y, width, height) of the source screenshot to template match. Defaults to (0, 0, 0, 0) which is equivalent to searching the full image. - * @param useSingleScale Whether to use only the single custom scale or to use a range based off of it. - * @param customConfidence Specify a custom confidence. Defaults to the confidence set in the app's settings. - * @param testScale Scale used by testing. Defaults to 0.0 which will fallback to the other scale conditions. - * @return Pair of (success: Boolean, location: Point?) where success indicates if a match was found and location contains the match coordinates if found. - */ - private fun match(sourceBitmap: Bitmap, templateBitmap: Bitmap, templateName: String, region: IntArray = intArrayOf(0, 0, 0, 0), useSingleScale: Boolean = false, customConfidence: Double = 0.0, testScale: Double = 0.0): Pair { - // If a custom region was specified, crop the source screenshot. - val srcBitmap = if (!region.contentEquals(intArrayOf(0, 0, 0, 0))) { - // Validate region bounds to prevent IllegalArgumentException with creating a crop area that goes beyond the source Bitmap. - val x = max(0, region[0].coerceAtMost(sourceBitmap.width)) - val y = max(0, region[1].coerceAtMost(sourceBitmap.height)) - val width = region[2].coerceAtMost(sourceBitmap.width - x) - val height = region[3].coerceAtMost(sourceBitmap.height - y) - - createSafeBitmap(sourceBitmap, x, y, width, height, "match region crop") ?: sourceBitmap - } else { - sourceBitmap - } - - val setConfidence: Double = if (templateName == "training_rainbow") { - game.printToLog("[INFO] For detection of rainbow training, confidence will be forcibly set to 0.9 to avoid false positives.", tag = tag) - 0.9 - } else if (customConfidence == 0.0) { - confidence - } else { - customConfidence - } - - // Scale images if the device is not 1080p which is supported by default. - val scales: MutableList = when { - testScale != 0.0 -> { - mutableListOf(testScale) - } - customScale != 1.0 && !useSingleScale -> { - mutableListOf(customScale - 0.02, customScale - 0.01, customScale, customScale + 0.01, customScale + 0.02, customScale + 0.03, customScale + 0.04) - } - customScale != 1.0 && useSingleScale -> { - mutableListOf(customScale) - } - isLowerEnd -> { - lowerEndScales.toMutableList() - } - !isLowerEnd && !isDefault && !isTablet -> { - middleEndScales.toMutableList() - } - isTablet && isSplitScreen && isLandscape -> { - tabletSplitLandscapeScales.toMutableList() - } - isTablet && isSplitScreen && !isLandscape -> { - tabletSplitPortraitScales.toMutableList() - } - isTablet && !isSplitScreen && !isLandscape -> { - tabletPortraitScales.toMutableList() - } - else -> { - mutableListOf(1.0) - } - } - - while (scales.isNotEmpty()) { - val newScale: Double = decimalFormat.format(scales.removeAt(0)).toDouble() - - val tmp: Bitmap = if (newScale != 1.0) { - templateBitmap.scale((templateBitmap.width * newScale).toInt(), (templateBitmap.height * newScale).toInt()) - } else { - templateBitmap - } - - // Create the Mats of both source and template images. - val sourceMat = Mat() - val templateMat = Mat() - Utils.bitmapToMat(srcBitmap, sourceMat) - Utils.bitmapToMat(tmp, templateMat) - - // Clamp template dimensions to source dimensions if template is too large. - val clampedTemplateMat = if (templateMat.cols() > sourceMat.cols() || templateMat.rows() > sourceMat.rows()) { - Log.d(tag, "Image sizes for match assertion failed - sourceMat: ${sourceMat.size()}, templateMat: ${templateMat.size()}") - // Create a new Mat with clamped dimensions. - val clampedWidth = minOf(templateMat.cols(), sourceMat.cols()) - val clampedHeight = minOf(templateMat.rows(), sourceMat.rows()) - Mat(templateMat, Rect(0, 0, clampedWidth, clampedHeight)) - } else { - templateMat - } - - // Make the Mats grayscale for the source and the template. - Imgproc.cvtColor(sourceMat, sourceMat, Imgproc.COLOR_BGR2GRAY) - Imgproc.cvtColor(clampedTemplateMat, clampedTemplateMat, Imgproc.COLOR_BGR2GRAY) - - // Create the result matrix. - val resultColumns: Int = sourceMat.cols() - clampedTemplateMat.cols() + 1 - val resultRows: Int = sourceMat.rows() - clampedTemplateMat.rows() + 1 - val resultMat = Mat(resultRows, resultColumns, CvType.CV_32FC1) - - // Now perform the matching and localize the result. - Imgproc.matchTemplate(sourceMat, clampedTemplateMat, resultMat, matchMethod) - val mmr: Core.MinMaxLocResult = Core.minMaxLoc(resultMat) - - var matchLocation = Point() - var matchCheck = false - - // Format minVal or maxVal. - val minVal: Double = decimalFormat.format(mmr.minVal).toDouble() - val maxVal: Double = decimalFormat.format(mmr.maxVal).toDouble() - - // Depending on which matching method was used, the algorithms determine which location was the best. - if ((matchMethod == Imgproc.TM_SQDIFF || matchMethod == Imgproc.TM_SQDIFF_NORMED) && mmr.minVal <= (1.0 - setConfidence)) { - matchLocation = mmr.minLoc - matchCheck = true - if (debugMode) { - game.printToLog("[DEBUG] Match found for \"$templateName\" with $minVal <= ${1.0 - setConfidence} at Point $matchLocation using scale: $newScale.", tag = tag) - } - } else if ((matchMethod != Imgproc.TM_SQDIFF && matchMethod != Imgproc.TM_SQDIFF_NORMED) && mmr.maxVal >= setConfidence) { - matchLocation = mmr.maxLoc - matchCheck = true - if (debugMode) { - game.printToLog("[DEBUG] Match found for \"$templateName\" with $maxVal >= $setConfidence at Point $matchLocation using scale: $newScale.", tag = tag) - } - } else { - if (debugMode) { - if ((matchMethod != Imgproc.TM_SQDIFF && matchMethod != Imgproc.TM_SQDIFF_NORMED)) { - game.printToLog("[DEBUG] Match not found for \"$templateName\" with $maxVal not >= $setConfidence at Point ${mmr.maxLoc} using scale $newScale.", tag = tag) - } else { - game.printToLog("[DEBUG] Match not found for \"$templateName\" with $minVal not <= ${1.0 - setConfidence} at Point ${mmr.minLoc} using scale $newScale.", tag = tag) - } - } - } - - if (matchCheck) { - if (debugMode && matchFilePath != "") { - // Draw a rectangle around the supposed best matching location and then save the match into a file in /files/temp/ directory. This is for debugging purposes to see if this - // algorithm found the match accurately or not. - Imgproc.rectangle(sourceMat, matchLocation, Point(matchLocation.x + templateMat.cols(), matchLocation.y + templateMat.rows()), Scalar(0.0, 0.0, 0.0), 10) - Imgcodecs.imwrite("$matchFilePath/match.png", sourceMat) - } - - // Center the coordinates so that any tap gesture would be directed at the center of that match location instead of the default - // position of the top left corner of the match location. - matchLocation.x += (templateMat.cols() / 2) - matchLocation.y += (templateMat.rows() / 2) - - // If a custom region was specified, readjust the coordinates to reflect the fullscreen source screenshot. - if (!region.contentEquals(intArrayOf(0, 0, 0, 0))) { - matchLocation.x = sourceBitmap.width - (sourceBitmap.width - (region[0] + matchLocation.x)) - matchLocation.y = sourceBitmap.height - (sourceBitmap.height - (region[1] + matchLocation.y)) - } - - return Pair(true, matchLocation) - } - - if (!BotService.isRunning) { - throw InterruptedException() - } - - sourceMat.release() - templateMat.release() - clampedTemplateMat.release() - resultMat.release() - } - - return Pair(false, null) - } - - /** - * Search through the whole source screenshot for all matches to the template image. - * - * @param sourceBitmap Bitmap from the /files/temp/ folder. - * @param templateBitmap Bitmap from the assets folder. - * @param region Specify the region consisting of (x, y, width, height) of the source screenshot to template match. Defaults to (0, 0, 0, 0) which is equivalent to searching the full image. - * @param customConfidence Specify a custom confidence. Defaults to the confidence set in the app's settings. - * @return ArrayList of Point objects that represents the matches found on the source screenshot. - */ - private fun matchAll(sourceBitmap: Bitmap, templateBitmap: Bitmap, region: IntArray = intArrayOf(0, 0, 0, 0), customConfidence: Double = 0.0): java.util.ArrayList { - // Create a local matchLocations list for this method - var matchLocation = Point() - val matchLocations = arrayListOf() - - // If a custom region was specified, crop the source screenshot. - val srcBitmap = if (!region.contentEquals(intArrayOf(0, 0, 0, 0))) { - // Validate region bounds to prevent IllegalArgumentException with creating a crop area that goes beyond the source Bitmap. - val x = max(0, region[0].coerceAtMost(sourceBitmap.width)) - val y = max(0, region[1].coerceAtMost(sourceBitmap.height)) - val width = region[2].coerceAtMost(sourceBitmap.width - x) - val height = region[3].coerceAtMost(sourceBitmap.height - y) - - createSafeBitmap(sourceBitmap, x, y, width, height, "matchAll region crop") ?: sourceBitmap - } else { - sourceBitmap - } - - // Scale images if the device is not 1080p which is supported by default. - val scales: MutableList = when { - customScale != 1.0 -> { - mutableListOf(customScale - 0.02, customScale - 0.01, customScale, customScale + 0.01, customScale + 0.02, customScale + 0.03, customScale + 0.04) - } - isLowerEnd -> { - lowerEndScales.toMutableList() - } - !isLowerEnd && !isDefault && !isTablet -> { - middleEndScales.toMutableList() - } - isTablet && isSplitScreen && isLandscape -> { - tabletSplitLandscapeScales.toMutableList() - } - isTablet && isSplitScreen && !isLandscape -> { - tabletSplitPortraitScales.toMutableList() - } - isTablet && !isSplitScreen && !isLandscape -> { - tabletPortraitScales.toMutableList() - } - else -> { - mutableListOf(1.0) - } - } - - val setConfidence: Double = if (customConfidence == 0.0) { - confidence - } else { - customConfidence - } - - var matchCheck = false - var newScale = 0.0 - val sourceMat = Mat() - val templateMat = Mat() - var resultMat = Mat() - var clampedTemplateMat: Mat? = null - - // Set templateMat at whatever scale it found the very first match for the next while loop. - while (!matchCheck && scales.isNotEmpty()) { - newScale = decimalFormat.format(scales.removeAt(0)).toDouble() - - val tmp: Bitmap = if (newScale != 1.0) { - templateBitmap.scale((templateBitmap.width * newScale).toInt(), (templateBitmap.height * newScale).toInt()) - } else { - templateBitmap - } - - // Create the Mats of both source and template images. - Utils.bitmapToMat(srcBitmap, sourceMat) - Utils.bitmapToMat(tmp, templateMat) - - // Clamp template dimensions to source dimensions if template is too large. - clampedTemplateMat = if (templateMat.cols() > sourceMat.cols() || templateMat.rows() > sourceMat.rows()) { - Log.d(tag, "Image sizes for matchAll assertion failed - sourceMat: ${sourceMat.size()}, templateMat: ${templateMat.size()}") - // Create a new Mat with clamped dimensions. - val clampedWidth = minOf(templateMat.cols(), sourceMat.cols()) - val clampedHeight = minOf(templateMat.rows(), sourceMat.rows()) - Mat(templateMat, Rect(0, 0, clampedWidth, clampedHeight)) - } else { - templateMat - } - - // Make the Mats grayscale for the source and the template. - Imgproc.cvtColor(sourceMat, sourceMat, Imgproc.COLOR_BGR2GRAY) - Imgproc.cvtColor(clampedTemplateMat, clampedTemplateMat, Imgproc.COLOR_BGR2GRAY) - - // Create the result matrix. - val resultColumns: Int = sourceMat.cols() - clampedTemplateMat.cols() + 1 - val resultRows: Int = sourceMat.rows() - clampedTemplateMat.rows() + 1 - if (resultColumns < 0 || resultRows < 0) { - break - } - - resultMat = Mat(resultRows, resultColumns, CvType.CV_32FC1) - - // Now perform the matching and localize the result. - Imgproc.matchTemplate(sourceMat, clampedTemplateMat, resultMat, matchMethod) - val mmr: Core.MinMaxLocResult = Core.minMaxLoc(resultMat) - - // Depending on which matching method was used, the algorithms determine which location was the best. - if ((matchMethod == Imgproc.TM_SQDIFF || matchMethod == Imgproc.TM_SQDIFF_NORMED) && mmr.minVal <= (1.0 - setConfidence)) { - matchLocation = mmr.minLoc - matchCheck = true - - // Draw a rectangle around the match on the source Mat. This will prevent false positives and infinite looping on subsequent matches. - Imgproc.rectangle(sourceMat, matchLocation, Point(matchLocation.x + clampedTemplateMat.cols(), matchLocation.y + clampedTemplateMat.rows()), Scalar(0.0, 0.0, 0.0), 20) - - // Center the location coordinates and then save it. - matchLocation.x += (clampedTemplateMat.cols() / 2) - matchLocation.y += (clampedTemplateMat.rows() / 2) - - // If a custom region was specified, readjust the coordinates to reflect the fullscreen source screenshot. - if (!region.contentEquals(intArrayOf(0, 0, 0, 0))) { - matchLocation.x = sourceBitmap.width - (sourceBitmap.width - (region[0] + matchLocation.x)) - matchLocation.y = sourceBitmap.height - (sourceBitmap.height - (region[1] + matchLocation.y)) - } - - matchLocations.add(matchLocation) - } else if ((matchMethod != Imgproc.TM_SQDIFF && matchMethod != Imgproc.TM_SQDIFF_NORMED) && mmr.maxVal >= setConfidence) { - matchLocation = mmr.maxLoc - matchCheck = true - - // Draw a rectangle around the match on the source Mat. This will prevent false positives and infinite looping on subsequent matches. - Imgproc.rectangle(sourceMat, matchLocation, Point(matchLocation.x + clampedTemplateMat.cols(), matchLocation.y + clampedTemplateMat.rows()), Scalar(0.0, 0.0, 0.0), 20) - - // Center the location coordinates and then save it. - matchLocation.x += (clampedTemplateMat.cols() / 2) - matchLocation.y += (clampedTemplateMat.rows() / 2) - - // If a custom region was specified, readjust the coordinates to reflect the fullscreen source screenshot. - if (!region.contentEquals(intArrayOf(0, 0, 0, 0))) { - matchLocation.x = sourceBitmap.width - (sourceBitmap.width - (region[0] + matchLocation.x)) - matchLocation.y = sourceBitmap.height - (sourceBitmap.height - (region[1] + matchLocation.y)) - } - - matchLocations.add(matchLocation) - } - - if (!BotService.isRunning) { - throw InterruptedException() - } - } - - // Loop until all other matches are found and break out when there are no more to be found. - while (matchCheck) { - // Now perform the matching and localize the result. - Imgproc.matchTemplate(sourceMat, clampedTemplateMat, resultMat, matchMethod) - val mmr: Core.MinMaxLocResult = Core.minMaxLoc(resultMat) - - // Format minVal or maxVal. - val minVal: Double = decimalFormat.format(mmr.minVal).toDouble() - val maxVal: Double = decimalFormat.format(mmr.maxVal).toDouble() - - if (clampedTemplateMat != null && (matchMethod == Imgproc.TM_SQDIFF || matchMethod == Imgproc.TM_SQDIFF_NORMED) && mmr.minVal <= (1.0 - setConfidence)) { - val tempMatchLocation: Point = mmr.minLoc - - // Draw a rectangle around the match on the source Mat. This will prevent false positives and infinite looping on subsequent matches. - Imgproc.rectangle(sourceMat, tempMatchLocation, Point(tempMatchLocation.x + clampedTemplateMat.cols(), tempMatchLocation.y + clampedTemplateMat.rows()), Scalar(0.0, 0.0, 0.0), 20) - - if (debugMode) { - game.printToLog("[DEBUG] Match All found with $minVal <= ${1.0 - setConfidence} at Point $matchLocation with scale: $newScale.", tag = tag) - Imgcodecs.imwrite("$matchFilePath/matchAll.png", sourceMat) - } - - // Center the location coordinates and then save it. - tempMatchLocation.x += (clampedTemplateMat.cols() / 2) - tempMatchLocation.y += (clampedTemplateMat.rows() / 2) - - // If a custom region was specified, readjust the coordinates to reflect the fullscreen source screenshot. - if (!region.contentEquals(intArrayOf(0, 0, 0, 0))) { - tempMatchLocation.x = sourceBitmap.width - (sourceBitmap.width - (region[0] + tempMatchLocation.x)) - tempMatchLocation.y = sourceBitmap.height - (sourceBitmap.height - (region[1] + tempMatchLocation.y)) - } - - if (!matchLocations.contains(tempMatchLocation) && !matchLocations.contains(Point(tempMatchLocation.x + 1.0, tempMatchLocation.y)) && - !matchLocations.contains(Point(tempMatchLocation.x, tempMatchLocation.y + 1.0)) && !matchLocations.contains(Point(tempMatchLocation.x + 1.0, tempMatchLocation.y + 1.0))) { - matchLocations.add(tempMatchLocation) - } - } else if (clampedTemplateMat != null && (matchMethod != Imgproc.TM_SQDIFF && matchMethod != Imgproc.TM_SQDIFF_NORMED) && mmr.maxVal >= setConfidence) { - val tempMatchLocation: Point = mmr.maxLoc - - // Draw a rectangle around the match on the source Mat. This will prevent false positives and infinite looping on subsequent matches. - Imgproc.rectangle(sourceMat, tempMatchLocation, Point(tempMatchLocation.x + clampedTemplateMat.cols(), tempMatchLocation.y + clampedTemplateMat.rows()), Scalar(0.0, 0.0, 0.0), 20) - - if (debugMode) { - game.printToLog("[DEBUG] Match All found with $maxVal >= $setConfidence at Point $matchLocation with scale: $newScale.", tag = tag) - Imgcodecs.imwrite("$matchFilePath/matchAll.png", sourceMat) - } - - // Center the location coordinates and then save it. - tempMatchLocation.x += (clampedTemplateMat.cols() / 2) - tempMatchLocation.y += (clampedTemplateMat.rows() / 2) - - // If a custom region was specified, readjust the coordinates to reflect the fullscreen source screenshot. - if (!region.contentEquals(intArrayOf(0, 0, 0, 0))) { - tempMatchLocation.x = sourceBitmap.width - (sourceBitmap.width - (region[0] + tempMatchLocation.x)) - tempMatchLocation.y = sourceBitmap.height - (sourceBitmap.height - (region[1] + tempMatchLocation.y)) - } - - if (!matchLocations.contains(tempMatchLocation) && !matchLocations.contains(Point(tempMatchLocation.x + 1.0, tempMatchLocation.y)) && - !matchLocations.contains(Point(tempMatchLocation.x, tempMatchLocation.y + 1.0)) && !matchLocations.contains(Point(tempMatchLocation.x + 1.0, tempMatchLocation.y + 1.0))) { - matchLocations.add(tempMatchLocation) - } - } else { - break - } - - if (!BotService.isRunning) { - throw InterruptedException() - } - } - - sourceMat.release() - templateMat.release() - clampedTemplateMat?.release() - resultMat.release() - - return matchLocations - } - - /** - * Convert absolute x-coordinate on 1080p to relative coordinate on different resolutions for the width. - * - * @param oldX The old absolute x-coordinate based off of the 1080p resolution. - * @return The new relative x-coordinate based off of the current resolution. - */ - fun relWidth(oldX: Int): Int { - return if (isDefault) { - oldX - } else { - (oldX.toDouble() * (displayWidth.toDouble() / 1080.0)).toInt() - } - } - - /** - * Convert absolute y-coordinate on 1080p to relative coordinate on different resolutions for the height. - * - * @param oldY The old absolute y-coordinate based off of the 1080p resolution. - * @return The new relative y-coordinate based off of the current resolution. - */ - fun relHeight(oldY: Int): Int { - return if (isDefault) { - oldY - } else { - (oldY.toDouble() * (displayHeight.toDouble() / 2340.0)).toInt() - } - } - - /** - * Helper function to calculate the x-coordinate with relative offset. - * - * @param baseX The base x-coordinate. - * @param offset The offset to add/subtract from the base coordinate and to make relative to. - * @return The calculated relative x-coordinate. - */ - fun relX(baseX: Double, offset: Int): Int { - return baseX.toInt() + relWidth(offset) - } - - /** - * Helper function to calculate relative y-coordinate with relative offset. - * - * @param baseY The base y-coordinate. - * @param offset The offset to add/subtract from the base coordinate and to make relative to. - * @return The calculated relative y-coordinate. - */ - fun relY(baseY: Double, offset: Int): Int { - return baseY.toInt() + relHeight(offset) - } - - //////////////////////////////////////////////////////////////////// - //////////////////////////////////////////////////////////////////// - - /** - * Open the source and template image files and return Bitmaps for them. - * - * @param templateName File name of the template image. - * @return A Pair of source and template Bitmaps. - */ - fun getBitmaps(templateName: String): Pair { - var sourceBitmap: Bitmap? = null - - while (sourceBitmap == null) { - sourceBitmap = MediaProjectionService.takeScreenshotNow(saveImage = debugMode) - } - - var templateBitmap: Bitmap? - - // Get the Bitmap from the template image file inside the specified folder. - myContext.assets?.open("images/$templateName.png").use { inputStream -> - // Get the Bitmap from the template image file and then start matching. - templateBitmap = BitmapFactory.decodeStream(inputStream) - } - - return if (templateBitmap != null) { - Pair(sourceBitmap, templateBitmap) - } else { - if (debugMode) { - game.printToLog("[ERROR] The template Bitmap is null.", tag = tag, isError = true) - } - - Pair(sourceBitmap, templateBitmap) - } - } - - /** - * Acquire the Bitmap for only the source screenshot. - * - * @return Bitmap of the source screenshot. - */ - private fun getSourceBitmap(): Bitmap { - var sourceBitmap: Bitmap? = null - while (sourceBitmap == null) { - sourceBitmap = MediaProjectionService.takeScreenshotNow(saveImage = debugMode) - } - - return sourceBitmap - } - - /** - * Safely creates a bitmap with bounds checking to prevent IllegalArgumentException. - * Clamps individual dimensions to source bitmap bounds if they exceed limits. - * - * @param sourceBitmap The source bitmap to crop from. - * @param x The x coordinate for the crop. - * @param y The y coordinate for the crop. - * @param width The width of the crop. - * @param height The height of the crop. - * @param context String describing the context for error logging. - * @return The cropped bitmap or null if bounds are still invalid after clamping. - */ - private fun createSafeBitmap(sourceBitmap: Bitmap, x: Int, y: Int, width: Int, height: Int, context: String): Bitmap? { - // Clamp individual dimensions to source bitmap bounds. - val clampedX = x.coerceIn(0, sourceBitmap.width) - val clampedY = y.coerceIn(0, sourceBitmap.height) - val clampedWidth = width.coerceIn(1, sourceBitmap.width - clampedX) - val clampedHeight = height.coerceIn(1, sourceBitmap.height - clampedY) - - // Check if any dimensions were clamped and log a warning. - if (x != clampedX || y != clampedY || width != clampedWidth || height != clampedHeight) { - game.printToLog("[WARNING] Clamped bounds for $context: original(x=$x, y=$y, width=$width, height=$height) -> clamped(x=$clampedX, y=$clampedY, width=$clampedWidth, height=$clampedHeight), sourceBitmap=${sourceBitmap.width}x${sourceBitmap.height}", tag = tag) - } - - // Final validation to ensure the clamped dimensions are still valid. - if (clampedX < 0 || clampedY < 0 || clampedWidth <= 0 || clampedHeight <= 0 || - clampedX + clampedWidth > sourceBitmap.width || clampedY + clampedHeight > sourceBitmap.height) { - game.printToLog("[ERROR] Invalid bounds for $context after clamping: x=$clampedX, y=$clampedY, width=$clampedWidth, height=$clampedHeight, sourceBitmap=${sourceBitmap.width}x${sourceBitmap.height}", tag = tag, isError = true) - return null - } - - return Bitmap.createBitmap(sourceBitmap, clampedX, clampedY, clampedWidth, clampedHeight) - } - - /** - * Finds the location of the specified image from the /images/ folder inside assets. - * - * @param templateName File name of the template image. - * @param tries Number of tries before failing. Defaults to 5. - * @param region Specify the region consisting of (x, y, width, height) of the source screenshot to template match. Defaults to (0, 0, 0, 0) which is equivalent to searching the full image. - * @param suppressError Whether or not to suppress saving error messages to the log. Defaults to false. - * @return Pair object consisting of the Point object containing the location of the match and the source screenshot. Can be null. - */ - fun findImage(templateName: String, tries: Int = 5, region: IntArray = intArrayOf(0, 0, 0, 0), suppressError: Boolean = false): Pair { - var numberOfTries = tries - - if (debugMode) { - game.printToLog("\n[DEBUG] Starting process to find the ${templateName.uppercase()} button image...", tag = tag) - } - - var (sourceBitmap, templateBitmap) = getBitmaps(templateName) - - while (numberOfTries > 0) { - if (templateBitmap != null) { - val (resultFlag, location) = match(sourceBitmap, templateBitmap, templateName, region) - if (!resultFlag) { - numberOfTries -= 1 - if (numberOfTries <= 0) { - if (debugMode && !suppressError) { - game.printToLog("[WARNING] Failed to find the ${templateName.uppercase()} button.", tag = tag) - } - - break - } - - Log.d(tag, "Failed to find the ${templateName.uppercase()} button. Trying again...") - game.wait(0.1) - sourceBitmap = getSourceBitmap() - } else { - game.printToLog("[SUCCESS] Found the ${templateName.uppercase()} at $location.", tag = tag) - return Pair(location, sourceBitmap) - } - } - } - - return Pair(null, sourceBitmap) - } - - /** - * Confirms whether or not the bot is at the specified location from the /headers/ folder inside assets. - * - * @param templateName File name of the template image. - * @param tries Number of tries before failing. Defaults to 5. - * @param region Specify the region consisting of (x, y, width, height) of the source screenshot to template match. Defaults to (0, 0, 0, 0) which is equivalent to searching the full image. - * @param suppressError Whether or not to suppress saving error messages to the log. - * @return True if the current location is at the specified location. False otherwise. - */ - fun confirmLocation(templateName: String, tries: Int = 5, region: IntArray = intArrayOf(0, 0, 0, 0), suppressError: Boolean = false): Boolean { - var numberOfTries = tries - - if (debugMode) { - game.printToLog("\n[DEBUG] Starting process to find the ${templateName.uppercase()} header image...", tag = tag) - } - - var (sourceBitmap, templateBitmap) = getBitmaps(templateName + "_header") - - while (numberOfTries > 0) { - if (templateBitmap != null) { - val (resultFlag, _) = match(sourceBitmap, templateBitmap, templateName, region) - if (!resultFlag) { - numberOfTries -= 1 - if (numberOfTries <= 0) { - break - } - - game.wait(0.1) - sourceBitmap = getSourceBitmap() - } else { - game.printToLog("[SUCCESS] Current location confirmed to be at ${templateName.uppercase()}.", tag = tag) - return true - } - } else { - break - } - } - - if (debugMode && !suppressError) { - game.printToLog("[WARNING] Failed to confirm the bot location at ${templateName.uppercase()}.", tag = tag) - } - - return false - } - - /** - * Finds all occurrences of the specified image in the images folder. - * - * @param templateName File name of the template image. - * @param region Specify the region consisting of (x, y, width, height) of the source screenshot to template match. Defaults to (0, 0, 0, 0) which is equivalent to searching the full image. - * @return An ArrayList of Point objects containing all the occurrences of the specified image or null if not found. - */ - fun findAll(templateName: String, region: IntArray = intArrayOf(0, 0, 0, 0)): ArrayList { - val (sourceBitmap, templateBitmap) = getBitmaps(templateName) - - if (templateBitmap != null) { - val matchLocations = matchAll(sourceBitmap, templateBitmap, region = region) - - // Sort the match locations by ascending x and y coordinates. - matchLocations.sortBy { it.x } - matchLocations.sortBy { it.y } - - if (debugMode) { - game.printToLog("[DEBUG] Found match locations for $templateName: $matchLocations.", tag = tag) - } else { - Log.d(tag, "[DEBUG] Found match locations for $templateName: $matchLocations.") - } - - return matchLocations - } - - return arrayListOf() - } - - /** - * Find all occurrences of the specified image in the images folder using a provided source bitmap. Useful for parallel processing to avoid exceeding the maxImages buffer. - * - * @param templateName File name of the template image. - * @param sourceBitmap The source bitmap to search in. - * @param region Specify the region consisting of (x, y, width, height) of the source screenshot to template match. Defaults to (0, 0, 0, 0) which is equivalent to searching the full image. - * @return An ArrayList of Point objects containing all the occurrences of the specified image or null if not found. - */ - private fun findAllWithBitmap(templateName: String, sourceBitmap: Bitmap, region: IntArray = intArrayOf(0, 0, 0, 0)): ArrayList { - var templateBitmap: Bitmap? - myContext.assets?.open("images/$templateName.png").use { inputStream -> - templateBitmap = BitmapFactory.decodeStream(inputStream) - } - - if (templateBitmap != null) { - val matchLocations = matchAll(sourceBitmap, templateBitmap, region = region) - - // Sort the match locations by ascending x and y coordinates. - matchLocations.sortBy { it.x } - matchLocations.sortBy { it.y } - - if (debugMode) { - game.printToLog("[DEBUG] Found match locations for $templateName: $matchLocations.", tag = tag) - } else { - Log.d(tag, "[DEBUG] Found match locations for $templateName: $matchLocations.") - } - - return matchLocations - } - - return arrayListOf() - } - - /** - * Check if the color at the specified coordinates matches the given RGB value. - * - * @param x X coordinate to check. - * @param y Y coordinate to check. - * @param rgb Expected RGB values as red, blue and green (0-255). - * @param tolerance Tolerance for color matching (0-255). Defaults to 0 for exact match. - * @return True if the color at the coordinates matches the expected RGB values within tolerance, false otherwise. - */ - fun checkColorAtCoordinates(x: Int, y: Int, rgb: IntArray, tolerance: Int = 0): Boolean { - val sourceBitmap = getSourceBitmap() - - // Check if coordinates are within bounds. - if (x < 0 || y < 0 || x >= sourceBitmap.width || y >= sourceBitmap.height) { - if (debugMode) game.printToLog("[WARNING] Coordinates ($x, $y) are out of bounds for bitmap size ${sourceBitmap.width}x${sourceBitmap.height}", tag = tag) - return false - } - - // Get the pixel color at the specified coordinates. - val pixel = sourceBitmap[x, y] - - // Extract RGB values from the pixel. - val actualRed = android.graphics.Color.red(pixel) - val actualGreen = android.graphics.Color.green(pixel) - val actualBlue = android.graphics.Color.blue(pixel) - - // Check if the colors match within the specified tolerance. - val redMatch = kotlin.math.abs(actualRed - rgb[0]) <= tolerance - val greenMatch = kotlin.math.abs(actualGreen - rgb[1]) <= tolerance - val blueMatch = kotlin.math.abs(actualBlue - rgb[2]) <= tolerance - - if (debugMode) { - game.printToLog("[DEBUG] Color check at ($x, $y): Expected RGB(${rgb[0]}, ${rgb[1]}, ${rgb[2]}), Actual RGB($actualRed, $actualGreen, $actualBlue), Match: ${redMatch && greenMatch && blueMatch}", tag = tag) - } - - return redMatch && greenMatch && blueMatch - } - - //////////////////////////////////////////////////////////////////// - //////////////////////////////////////////////////////////////////// - - /** - * Perform OCR text detection using Tesseract along with some image manipulation via thresholding to make the cropped screenshot black and white using OpenCV. - * - * @param increment Increments the threshold by this value. Defaults to 0.0. - * @return The detected String in the cropped region. - */ - fun findText(increment: Double = 0.0): String { - val (sourceBitmap, templateBitmap) = getBitmaps("shift") - - // Acquire the location of the energy text image. - val (_, energyTemplateBitmap) = getBitmaps("energy") - val (_, matchLocation) = match(sourceBitmap, energyTemplateBitmap!!, "energy") - if (matchLocation == null) { - game.printToLog("[WARNING] Could not proceed with OCR text detection due to not being able to find the energy template on the source image.") - return "empty!" - } - - // Use the match location acquired from finding the energy text image and acquire the (x, y) coordinates of the event title container right below the location of the energy text image. - val newX: Int - val newY: Int - var croppedBitmap: Bitmap? = if (isTablet) { - newX = max(0, matchLocation.x.toInt() - relWidth(250)) - newY = max(0, matchLocation.y.toInt() + relHeight(154)) - createSafeBitmap(sourceBitmap, newX, newY, relWidth(746), relHeight(85), "findText tablet crop") - } else { - newX = max(0, matchLocation.x.toInt() - relWidth(125)) - newY = max(0, matchLocation.y.toInt() + relHeight(116)) - createSafeBitmap(sourceBitmap, newX, newY, relWidth(645), relHeight(65), "findText phone crop") - } - if (croppedBitmap == null) { - game.printToLog("[ERROR] Failed to create cropped bitmap for text detection", tag = tag, isError = true) - return "empty!" - } - - val tempImage = Mat() - Utils.bitmapToMat(croppedBitmap, tempImage) - if (debugMode) Imgcodecs.imwrite("$matchFilePath/debugEventTitleText.png", tempImage) - - // Now see if it is necessary to shift the cropped region over by 70 pixels or not to account for certain events. - val (shiftMatch, _) = match(croppedBitmap, templateBitmap!!, "shift") - croppedBitmap = if (shiftMatch) { - Log.d(tag, "Shifting the region over by 70 pixels!") - createSafeBitmap(sourceBitmap, relX(newX.toDouble(), 70), newY, 645 - 70, 65, "findText shifted crop") ?: croppedBitmap - } else { - Log.d(tag, "Do not need to shift.") - croppedBitmap - } - - // Make the cropped screenshot grayscale. - val cvImage = Mat() - Utils.bitmapToMat(croppedBitmap, cvImage) - Imgproc.cvtColor(cvImage, cvImage, Imgproc.COLOR_BGR2GRAY) - - // Save the cropped image before converting it to black and white in order to troubleshoot issues related to differing device sizes and cropping. - if (debugMode) Imgcodecs.imwrite("$matchFilePath/debugEventTitleText_afterCrop.png", cvImage) - - // Thresh the grayscale cropped image to make it black and white. - val bwImage = Mat() - val threshold = sharedPreferences.getInt("threshold", 230) - Imgproc.threshold(cvImage, bwImage, threshold.toDouble() + increment, 255.0, Imgproc.THRESH_BINARY) - if (debugMode) Imgcodecs.imwrite("$matchFilePath/debugEventTitleText_afterThreshold.png", bwImage) - - // Convert the Mat directly to Bitmap and then pass it to the text reader. - val resultBitmap = createBitmap(bwImage.cols(), bwImage.rows()) - Utils.matToBitmap(bwImage, resultBitmap) - tessBaseAPI.setImage(resultBitmap) - - // Set the Page Segmentation Mode to '--psm 7' or "Treat the image as a single text line" according to https://tesseract-ocr.github.io/tessdoc/ImproveQuality.html#page-segmentation-method - tessBaseAPI.pageSegMode = TessBaseAPI.PageSegMode.PSM_SINGLE_LINE - - var result = "empty!" - try { - // Finally, detect text on the cropped region. - result = tessBaseAPI.utF8Text - game.printToLog("[INFO] Detected text with Tesseract: $result", tag = tag) - } catch (e: Exception) { - game.printToLog("[ERROR] Cannot perform OCR: ${e.stackTraceToString()}", tag = tag, isError = true) - } - - tessBaseAPI.clear() - tempImage.release() - cvImage.release() - bwImage.release() - - return result - } - - /** - * Find the success percentage chance on the currently selected stat. Parameters are optional to allow for thread-safe operations. - * - * @param sourceBitmap Bitmap of the source image separately taken. Defaults to null. - * @param trainingSelectionLocation Point location of the template image separately taken. Defaults to null. - * - * @return Integer representing the percentage. - */ - fun findTrainingFailureChance(sourceBitmap: Bitmap? = null, trainingSelectionLocation: Point? = null): Int { - // Crop the source screenshot to hold the success percentage only. - val (trainingSelectionLocation, sourceBitmap) = if (sourceBitmap == null && trainingSelectionLocation == null) { - findImage("training_failure_chance") - } else { - Pair(trainingSelectionLocation, sourceBitmap) - } - - if (trainingSelectionLocation == null) { - return -1 - } - - val croppedBitmap: Bitmap? = if (isTablet) { - createSafeBitmap(sourceBitmap!!, relX(trainingSelectionLocation.x, -65), relY(trainingSelectionLocation.y, 23), relWidth(130), relHeight(50), "findTrainingFailureChance tablet") - } else { - createSafeBitmap(sourceBitmap!!, relX(trainingSelectionLocation.x, -45), relY(trainingSelectionLocation.y, 15), relWidth(100), relHeight(37), "findTrainingFailureChance phone") - } - if (croppedBitmap == null) { - game.printToLog("[ERROR] Failed to create cropped bitmap for training failure chance detection.", tag = tag, isError = true) - return -1 - } - - val resizedBitmap = croppedBitmap.scale(croppedBitmap.width * 2, croppedBitmap.height * 2) - - // Save the cropped image for debugging purposes. - val tempMat = Mat() - Utils.bitmapToMat(resizedBitmap, tempMat) - Imgproc.cvtColor(tempMat, tempMat, Imgproc.COLOR_BGR2GRAY) - if (debugMode) Imgcodecs.imwrite("$matchFilePath/debugTrainingFailureChance_afterCrop.png", tempMat) - - // Create a InputImage object for Google's ML OCR. - val resultBitmap = createBitmap(tempMat.cols(), tempMat.rows()) - Utils.matToBitmap(tempMat, resultBitmap) - val inputImage: InputImage = InputImage.fromBitmap(resultBitmap, 0) - - // Use CountDownLatch to make the async operation synchronous. - val latch = CountDownLatch(1) - var result = -1 - var mlkitFailed = false - - textRecognizer.process(inputImage) - .addOnSuccessListener { text -> - if (text.textBlocks.isNotEmpty()) { - for (block in text.textBlocks) { - try { - game.printToLog("[INFO] Detected Training failure chance with Google ML Kit: ${block.text}", tag = tag) - result = block.text.replace("%", "").trim().toInt() - } catch (_: NumberFormatException) { - } - } - } - latch.countDown() - } - .addOnFailureListener { - game.printToLog("[ERROR] Failed to do text detection via Google's ML Kit. Falling back to Tesseract.", tag = tag, isError = true) - mlkitFailed = true - latch.countDown() - } - - // Wait for the async operation to complete. - try { - latch.await(5, TimeUnit.SECONDS) - } catch (_: InterruptedException) { - game.printToLog("[ERROR] Google ML Kit operation timed out", tag = tag, isError = true) - } - - // Fallback to Tesseract if ML Kit failed or didn't find result. - if (mlkitFailed || result == -1) { - tessBaseAPI.setImage(resultBitmap) - tessBaseAPI.pageSegMode = TessBaseAPI.PageSegMode.PSM_SINGLE_LINE - - try { - val detectedText = tessBaseAPI.utF8Text.replace("%", "") - game.printToLog("[INFO] Detected training failure chance with Tesseract: $detectedText", tag = tag) - val cleanedResult = detectedText.replace(Regex("[^0-9]"), "") - result = cleanedResult.toInt() - } catch (_: NumberFormatException) { - game.printToLog("[ERROR] Could not convert \"${tessBaseAPI.utF8Text.replace("%", "")}\" to integer.", tag = tag, isError = true) - result = -1 - } catch (e: Exception) { - game.printToLog("[ERROR] Cannot perform OCR using Tesseract: ${e.stackTraceToString()}", tag = tag, isError = true) - result = -1 - } - - tessBaseAPI.clear() - } - - if (debugMode) { - game.printToLog("[DEBUG] Failure chance detected to be at $result%.") - } else { - Log.d(tag, "Failure chance detected to be at $result%.") - } - - tempMat.release() - - return result - } - - /** - * Determines the day number to see if today is eligible for doing an extra race. - * - * @return Number of the day. - */ - fun determineDayForExtraRace(): Int { - var result = -1 - val (energyTextLocation, sourceBitmap) = findImage("energy", tries = 1, region = regionTopHalf) - - if (energyTextLocation != null) { - // Crop the source screenshot to only contain the day number. - val croppedBitmap: Bitmap? = if (campaign == "Ao Haru") { - if (isTablet) { - createSafeBitmap(sourceBitmap, relX(energyTextLocation.x, -(260 * 1.32).toInt()), relY(energyTextLocation.y, -(140 * 1.32).toInt()), relWidth(135), relHeight(100), "determineDayForExtraRace Ao Haru tablet") - } else { - createSafeBitmap(sourceBitmap, relX(energyTextLocation.x, -260), relY(energyTextLocation.y, -140), relWidth(105), relHeight(75), "determineDayForExtraRace Ao Haru phone") - } - } else { - if (isTablet) { - createSafeBitmap(sourceBitmap, relX(energyTextLocation.x, -(246 * 1.32).toInt()), relY(energyTextLocation.y, -(96 * 1.32).toInt()), relWidth(175), relHeight(116), "determineDayForExtraRace default tablet") - } else { - createSafeBitmap(sourceBitmap, relX(energyTextLocation.x, -246), relY(energyTextLocation.y, -100), relWidth(140), relHeight(95), "determineDayForExtraRace default phone") - } - } - if (croppedBitmap == null) { - game.printToLog("[ERROR] Failed to create cropped bitmap for day detection.", tag = tag, isError = true) - return -1 - } - - val resizedBitmap = croppedBitmap.scale(croppedBitmap.width * 2, croppedBitmap.height * 2) - - // Make the cropped screenshot grayscale. - val cvImage = Mat() - Utils.bitmapToMat(resizedBitmap, cvImage) - Imgproc.cvtColor(cvImage, cvImage, Imgproc.COLOR_BGR2GRAY) - if (debugMode) Imgcodecs.imwrite("$matchFilePath/debugDayForExtraRace_afterCrop.png", cvImage) - - // Thresh the grayscale cropped image to make it black and white. - val bwImage = Mat() - val threshold = sharedPreferences.getInt("threshold", 230) - Imgproc.threshold(cvImage, bwImage, threshold.toDouble(), 255.0, Imgproc.THRESH_BINARY) - if (debugMode) Imgcodecs.imwrite("$matchFilePath/debugDayForExtraRace_afterThreshold.png", bwImage) - - // Create a InputImage object for Google's ML OCR. - val resultBitmap = createBitmap(bwImage.cols(), bwImage.rows()) - Utils.matToBitmap(bwImage, resultBitmap) - val inputImage: InputImage = InputImage.fromBitmap(resultBitmap, 0) - - // Use CountDownLatch to make the async operation synchronous. - val latch = CountDownLatch(1) - var mlkitFailed = false - - textRecognizer.process(inputImage) - .addOnSuccessListener { text -> - if (text.textBlocks.isNotEmpty()) { - for (block in text.textBlocks) { - try { - game.printToLog("[INFO] Detected Day Number for Extra Race with Google ML Kit: ${block.text}", tag = tag) - result = block.text.toInt() - } catch (_: NumberFormatException) { - } - } - } - latch.countDown() - } - .addOnFailureListener { - game.printToLog("[ERROR] Failed to do text detection via Google's ML Kit. Falling back to Tesseract.", tag = tag, isError = true) - mlkitFailed = true - latch.countDown() - } - - // Wait for the async operation to complete. - try { - latch.await(5, TimeUnit.SECONDS) - } catch (_: InterruptedException) { - game.printToLog("[ERROR] Google ML Kit operation timed out", tag = tag, isError = true) - } - - // Fallback to Tesseract if ML Kit failed or didn't find result. - if (mlkitFailed || result == -1) { - tessBaseAPI.setImage(resultBitmap) - tessBaseAPI.pageSegMode = TessBaseAPI.PageSegMode.PSM_SINGLE_LINE - - try { - val detectedText = tessBaseAPI.utF8Text.replace("%", "") - game.printToLog("[INFO] Detected day for extra racing with Tesseract: $detectedText", tag = tag) - val cleanedResult = detectedText.replace(Regex("[^0-9]"), "") - result = cleanedResult.toInt() - } catch (_: NumberFormatException) { - game.printToLog("[ERROR] Could not convert \"${tessBaseAPI.utF8Text.replace("%", "")}\" to integer.", tag = tag, isError = true) - result = -1 - } catch (e: Exception) { - game.printToLog("[ERROR] Cannot perform OCR using Tesseract: ${e.stackTraceToString()}", tag = tag, isError = true) - result = -1 - } - - tessBaseAPI.clear() - } - - cvImage.release() - bwImage.release() - } - - return result - } - - /** - * Determine the amount of fans that the extra race will give only if it matches the double star prediction. - * - * @param extraRaceLocation Point object of the extra race's location. - * @param sourceBitmap Bitmap of the source screenshot. - * @param doubleStarPredictionBitmap Bitmap of the double star prediction template image. - * @param forceRacing Flag to allow the extra race to forcibly pass double star prediction check. Defaults to false. - * @return Number of fans to be gained from the extra race or -1 if not found as an object. - */ - fun determineExtraRaceFans(extraRaceLocation: Point, sourceBitmap: Bitmap, doubleStarPredictionBitmap: Bitmap, forceRacing: Boolean = false): RaceDetails { - // Crop the source screenshot to show only the fan amount and the predictions. - val croppedBitmap = if (isTablet) { - createSafeBitmap(sourceBitmap, relX(extraRaceLocation.x, -(173 * 1.34).toInt()), relY(extraRaceLocation.y, -(106 * 1.34).toInt()), relWidth(220), relHeight(125), "determineExtraRaceFans prediction tablet") - } else { - createSafeBitmap(sourceBitmap, relX(extraRaceLocation.x, -173), relY(extraRaceLocation.y, -106), relWidth(163), relHeight(96), "determineExtraRaceFans prediction phone") - } - if (croppedBitmap == null) { - game.printToLog("[ERROR] Failed to create cropped bitmap for extra race prediction detection.", tag = tag, isError = true) - return RaceDetails(-1, false) - } - - val cvImage = Mat() - Utils.bitmapToMat(croppedBitmap, cvImage) - if (debugMode) Imgcodecs.imwrite("$matchFilePath/debugExtraRacePrediction.png", cvImage) - - // Determine if the extra race has double star prediction. - val (predictionCheck, _) = match(croppedBitmap, doubleStarPredictionBitmap, "race_extra_double_prediction") - - return if (forceRacing || predictionCheck) { - if (debugMode && !forceRacing) game.printToLog("[DEBUG] This race has double predictions. Now checking how many fans this race gives.", tag = tag) - else if (debugMode) game.printToLog("[DEBUG] Check for double predictions was skipped due to the force racing flag being enabled. Now checking how many fans this race gives.", tag = tag) - - // Crop the source screenshot to show only the fans. - val croppedBitmap2 = if (isTablet) { - createSafeBitmap(sourceBitmap, relX(extraRaceLocation.x, -(625 * 1.40).toInt()), relY(extraRaceLocation.y, -(75 * 1.34).toInt()), relWidth(320), relHeight(45), "determineExtraRaceFans fans tablet") - } else { - createSafeBitmap(sourceBitmap, relX(extraRaceLocation.x, -625), relY(extraRaceLocation.y, -75), relWidth(250), relHeight(35), "determineExtraRaceFans fans phone") - } - if (croppedBitmap2 == null) { - game.printToLog("[ERROR] Failed to create cropped bitmap for extra race fans detection.", tag = tag, isError = true) - return RaceDetails(-1, predictionCheck) - } - - // Make the cropped screenshot grayscale. - Utils.bitmapToMat(croppedBitmap2, cvImage) - Imgproc.cvtColor(cvImage, cvImage, Imgproc.COLOR_BGR2GRAY) - if (debugMode) Imgcodecs.imwrite("$matchFilePath/debugExtraRaceFans_afterCrop.png", cvImage) - - // Convert the Mat directly to Bitmap and then pass it to the text reader. - var resultBitmap = createBitmap(cvImage.cols(), cvImage.rows()) - Utils.matToBitmap(cvImage, resultBitmap) - - // Thresh the grayscale cropped image to make it black and white. - val bwImage = Mat() - val threshold = sharedPreferences.getInt("threshold", 230) - Imgproc.threshold(cvImage, bwImage, threshold.toDouble(), 255.0, Imgproc.THRESH_BINARY) - if (debugMode) Imgcodecs.imwrite("$matchFilePath/debugExtraRaceFans_afterThreshold.png", bwImage) - - resultBitmap = createBitmap(bwImage.cols(), bwImage.rows()) - Utils.matToBitmap(bwImage, resultBitmap) - tessBaseAPI.setImage(resultBitmap) - - // Set the Page Segmentation Mode to '--psm 7' or "Treat the image as a single text line" according to https://tesseract-ocr.github.io/tessdoc/ImproveQuality.html#page-segmentation-method - tessBaseAPI.pageSegMode = TessBaseAPI.PageSegMode.PSM_SINGLE_LINE - - var result = "empty!" - try { - // Finally, detect text on the cropped region. - result = tessBaseAPI.utF8Text - } catch (e: Exception) { - game.printToLog("[ERROR] Cannot perform OCR with Tesseract: ${e.stackTraceToString()}", tag = tag, isError = true) - } - - tessBaseAPI.clear() - cvImage.release() - bwImage.release() - - // Format the string to be converted to an integer. - game.printToLog("[INFO] Detected number of fans from Tesseract before formatting: $result", tag = tag) - result = result - .replace(",", "") - .replace(".", "") - .replace("+", "") - .replace("-", "") - .replace(">", "") - .replace("<", "") - .replace("(", "") - .replace("人", "") - .replace("ォ", "") - .replace("fans", "").trim() - - try { - Log.d(tag, "Converting $result to integer for fans") - val cleanedResult = result.replace(Regex("[^0-9]"), "") - RaceDetails(cleanedResult.toInt(), predictionCheck) - } catch (_: NumberFormatException) { - RaceDetails(-1, predictionCheck) - } - } else { - Log.d(tag, "This race has no double prediction.") - return RaceDetails(-1, false) - } - } - - /** - * Determine the number of skill points. - * - * @return Number of skill points or -1 if not found. - */ - fun determineSkillPoints(): Int { - val (skillPointLocation, sourceBitmap) = findImage("skill_points", tries = 1) - - return if (skillPointLocation != null) { - val croppedBitmap = if (isTablet) { - createSafeBitmap(sourceBitmap, relX(skillPointLocation.x, -75), relY(skillPointLocation.y, 45), relWidth(150), relHeight(70), "determineSkillPoints tablet") - } else { - createSafeBitmap(sourceBitmap, relX(skillPointLocation.x, -70), relY(skillPointLocation.y, 28), relWidth(135), relHeight(70), "determineSkillPoints phone") - } - if (croppedBitmap == null) { - game.printToLog("[ERROR] Failed to create cropped bitmap for skill points detection.", tag = tag, isError = true) - return -1 - } - - // Make the cropped screenshot grayscale. - val cvImage = Mat() - Utils.bitmapToMat(croppedBitmap, cvImage) - Imgproc.cvtColor(cvImage, cvImage, Imgproc.COLOR_BGR2GRAY) - if (debugMode) Imgcodecs.imwrite("$matchFilePath/debugSkillPoints_afterCrop.png", cvImage) - - // Thresh the grayscale cropped image to make it black and white. - val bwImage = Mat() - val threshold = sharedPreferences.getInt("threshold", 230) - Imgproc.threshold(cvImage, bwImage, threshold.toDouble(), 255.0, Imgproc.THRESH_BINARY) - if (debugMode) Imgcodecs.imwrite("$matchFilePath/debugSkillPoints_afterThreshold.png", bwImage) - - // Create a InputImage object for Google's ML OCR. - val resultBitmap = createBitmap(bwImage.cols(), bwImage.rows()) - Utils.matToBitmap(bwImage, resultBitmap) - val inputImage: InputImage = InputImage.fromBitmap(resultBitmap, 0) - - // Use CountDownLatch to make the async operation synchronous. - var result = "" - val latch = CountDownLatch(1) - var mlkitFailed = false - - textRecognizer.process(inputImage) - .addOnSuccessListener { text -> - if (text.textBlocks.isNotEmpty()) { - for (block in text.textBlocks) { - game.printToLog("[INFO] Detected the number of skill points with Google ML Kit: ${block.text}", tag = tag) - result = block.text - } - } - latch.countDown() - } - .addOnFailureListener { - game.printToLog("[ERROR] Failed to do text detection via Google's ML Kit. Falling back to Tesseract.", tag = tag, isError = true) - mlkitFailed = true - latch.countDown() - } - - // Wait for the async operation to complete. - try { - latch.await(5, TimeUnit.SECONDS) - } catch (_: InterruptedException) { - game.printToLog("[ERROR] Google ML Kit operation timed out", tag = tag, isError = true) - } - - if (mlkitFailed || result == "") { - tessBaseAPI.setImage(resultBitmap) - - // Set the Page Segmentation Mode to '--psm 7' or "Treat the image as a single text line" according to https://tesseract-ocr.github.io/tessdoc/ImproveQuality.html#page-segmentation-method - tessBaseAPI.pageSegMode = TessBaseAPI.PageSegMode.PSM_SINGLE_LINE - - try { - // Finally, detect text on the cropped region. - result = tessBaseAPI.utF8Text - } catch (e: Exception) { - game.printToLog("[ERROR] Cannot perform OCR with Tesseract: ${e.stackTraceToString()}", tag = tag, isError = true) - } - - tessBaseAPI.clear() - } - - cvImage.release() - bwImage.release() - - game.printToLog("[INFO] Detected number of skill points before formatting: $result", tag = tag) - try { - Log.d(tag, "Converting $result to integer for skill points") - val cleanedResult = result.replace(Regex("[^0-9]"), "") - cleanedResult.toInt() - } catch (_: NumberFormatException) { - -1 - } - } else { - game.printToLog("[ERROR] Could not start the process of detecting skill points.", tag = tag, isError = true) - -1 - } - } - - /** - * Analyze the relationship bars on the Training screen for the currently selected training. Parameter is optional to allow for thread-safe operations. - * - * @param sourceBitmap Bitmap of the source image separately taken. Defaults to null. - * - * @return A list of the results for each relationship bar. - */ - fun analyzeRelationshipBars(sourceBitmap: Bitmap? = null): ArrayList { - val customRegion = intArrayOf(displayWidth - (displayWidth / 3), 0, (displayWidth / 3), displayHeight - (displayHeight / 3)) - - // Take a single screenshot first to avoid buffer overflow. - val sourceBitmap = sourceBitmap ?: getSourceBitmap() - - var allStatBlocks = mutableListOf() - - val latch = CountDownLatch(6) - - // Create arrays to store results from each thread. - val speedBlocks = arrayListOf() - val staminaBlocks = arrayListOf() - val powerBlocks = arrayListOf() - val gutsBlocks = arrayListOf() - val witBlocks = arrayListOf() - val friendshipBlocks = arrayListOf() - - // Start parallel threads for each findAll call, passing the same source bitmap. - Thread { - speedBlocks.addAll(findAllWithBitmap("stat_speed_block", sourceBitmap, region = customRegion)) - latch.countDown() - }.start() - - Thread { - staminaBlocks.addAll(findAllWithBitmap("stat_stamina_block", sourceBitmap, region = customRegion)) - latch.countDown() - }.start() - - Thread { - powerBlocks.addAll(findAllWithBitmap("stat_power_block", sourceBitmap, region = customRegion)) - latch.countDown() - }.start() - - Thread { - gutsBlocks.addAll(findAllWithBitmap("stat_guts_block", sourceBitmap, region = customRegion)) - latch.countDown() - }.start() - - Thread { - witBlocks.addAll(findAllWithBitmap("stat_wit_block", sourceBitmap, region = customRegion)) - latch.countDown() - }.start() - - Thread { - friendshipBlocks.addAll(findAllWithBitmap("stat_friendship_block", sourceBitmap, region = customRegion)) - latch.countDown() - }.start() - - // Wait for all threads to complete. - try { - latch.await(10, TimeUnit.SECONDS) - } catch (_: InterruptedException) { - game.printToLog("[ERROR] Parallel findAll operations timed out.", tag = tag, isError = true) - } - - // Combine all results. - allStatBlocks.addAll(speedBlocks) - allStatBlocks.addAll(staminaBlocks) - allStatBlocks.addAll(powerBlocks) - allStatBlocks.addAll(gutsBlocks) - allStatBlocks.addAll(witBlocks) - allStatBlocks.addAll(friendshipBlocks) - - // Filter out duplicates based on exact coordinate matches. - allStatBlocks = allStatBlocks.distinctBy { "${it.x},${it.y}" }.toMutableList() - - // Sort the combined stat blocks by ascending y-coordinate. - allStatBlocks.sortBy { it.y } - - // Define HSV color ranges. - val blueLower = Scalar(10.0, 150.0, 150.0) - val blueUpper = Scalar(25.0, 255.0, 255.0) - val greenLower = Scalar(40.0, 150.0, 150.0) - val greenUpper = Scalar(80.0, 255.0, 255.0) - val orangeLower = Scalar(100.0, 150.0, 150.0) - val orangeUpper = Scalar(130.0, 255.0, 255.0) - - val (_, maxedTemplateBitmap) = getBitmaps("stat_maxed") - val results = arrayListOf() - - for ((index, statBlock) in allStatBlocks.withIndex()) { - if (debugMode) game.printToLog("[DEBUG] Processing stat block #${index + 1} at position: (${statBlock.x}, ${statBlock.y})", tag = tag) - - val croppedBitmap = createSafeBitmap(sourceBitmap, relX(statBlock.x, -9), relY(statBlock.y, 107), 111, 13, "analyzeRelationshipBars stat block ${index + 1}") - if (croppedBitmap == null) { - game.printToLog("[ERROR] Failed to create cropped bitmap for stat block #${index + 1}.", tag = tag, isError = true) - continue - } - - val (isMaxed, _) = match(croppedBitmap, maxedTemplateBitmap!!, "stat_maxed") - if (isMaxed) { - // Skip if the relationship bar is already maxed. - if (debugMode) game.printToLog("[DEBUG] Relationship bar #${index + 1} is full.", tag = tag) - results.add(BarFillResult(100.0, 5, "orange")) - continue - } - - val barMat = Mat() - Utils.bitmapToMat(croppedBitmap, barMat) - - // Convert to RGB and then to HSV for better color detection. - val rgbMat = Mat() - Imgproc.cvtColor(barMat, rgbMat, Imgproc.COLOR_BGR2RGB) - if (debugMode) Imgcodecs.imwrite("$matchFilePath/debug_relationshipBar${index + 1}AfterRGB.png", rgbMat) - val hsvMat = Mat() - Imgproc.cvtColor(rgbMat, hsvMat, Imgproc.COLOR_RGB2HSV) - - val blueMask = Mat() - val greenMask = Mat() - val orangeMask = Mat() - - // Count the pixels for each color. - Core.inRange(hsvMat, blueLower, blueUpper, blueMask) - Core.inRange(hsvMat, greenLower, greenUpper, greenMask) - Core.inRange(hsvMat, orangeLower, orangeUpper, orangeMask) - val bluePixels = Core.countNonZero(blueMask) - val greenPixels = Core.countNonZero(greenMask) - val orangePixels = Core.countNonZero(orangeMask) - - // Sum the colored pixels. - val totalColoredPixels = bluePixels + greenPixels + orangePixels - val totalPixels = barMat.rows() * barMat.cols() - - // Estimate the fill percentage based on the total colored pixels. - val fillPercent = if (totalPixels > 0) { - (totalColoredPixels.toDouble() / totalPixels.toDouble()) * 100.0 - } else 0.0 - - // Estimate the filled segments (each segment is about 20% of the whole bar). - val filledSegments = (fillPercent / 20).coerceAtMost(5.0).toInt() - - val dominantColor = when { - orangePixels > greenPixels && orangePixels > bluePixels -> "orange" - greenPixels > bluePixels -> "green" - bluePixels > 0 -> "blue" - else -> "none" - } - - blueMask.release() - greenMask.release() - orangeMask.release() - hsvMat.release() - barMat.release() - - if (debugMode) game.printToLog("[DEBUG] Relationship bar #${index + 1} is $fillPercent% filled with $filledSegments filled segments and the dominant color is $dominantColor", tag = tag) - results.add(BarFillResult(fillPercent, filledSegments, dominantColor)) - } - - return results - } - - /** - * Determines the preferred race distance based on aptitude levels (S, A, B) for each distance type on the Full Stats popup. - * - * This function analyzes the aptitude display for four race distances: Sprint, Mile, Medium, and Long. - * It uses template matching to detect S, A, and B aptitude levels and returns the distance with the - * highest aptitude found. The priority order is S > A > B, with S aptitude being returned immediately - * since it's the best possible outcome. - * - * @return The preferred distance (Sprint, Mile, Medium, or Long) or Medium as default if no aptitude is detected. - */ - fun determinePreferredDistance(): String { - val (distanceLocation, sourceBitmap) = findImage("stat_distance", tries = 1, region = regionMiddle) - if (distanceLocation == null) { - game.printToLog("[ERROR] Could not determine the preferred distance. Setting to Medium by default.", tag = tag, isError = true) - return "Medium" - } - - val (_, statAptitudeSTemplate) = getBitmaps("stat_aptitude_S") - val (_, statAptitudeATemplate) = getBitmaps("stat_aptitude_A") - val (_, statAptitudeBTemplate) = getBitmaps("stat_aptitude_B") - - val distances = listOf("Sprint", "Mile", "Medium", "Long") - var bestAptitudeDistance = "" - var bestAptitudeLevel = -1 // -1 = none, 0 = B, 1 = A, 2 = S - - for (i in 0 until 4) { - val distance = distances[i] - val croppedBitmap = createSafeBitmap(sourceBitmap, relX(distanceLocation.x, 108 + (i * 190)), relY(distanceLocation.y, -25), 176, 52, "determinePreferredDistance distance $distance") - if (croppedBitmap == null) { - game.printToLog("[ERROR] Failed to create cropped bitmap for distance $distance.", tag = tag, isError = true) - continue - } - - when { - match(croppedBitmap, statAptitudeSTemplate!!, "stat_aptitude_S").first -> { - // S aptitude found - this is the best possible, return immediately. - return distance - } - bestAptitudeLevel < 1 && match(croppedBitmap, statAptitudeATemplate!!, "stat_aptitude_A").first -> { - // A aptitude found (pick the leftmost aptitude) - better than B, but keep looking for S. - bestAptitudeDistance = distance - bestAptitudeLevel = 1 - } - bestAptitudeLevel < 0 && match(croppedBitmap, statAptitudeBTemplate!!, "stat_aptitude_B").first -> { - // B aptitude found - only use if no A aptitude found yet. - bestAptitudeDistance = distance - bestAptitudeLevel = 0 - } - } - } - - return bestAptitudeDistance.ifEmpty { - game.printToLog("[WARNING] Could not determine the preferred distance with at least B aptitude. Setting to Medium by default.", tag = tag, isError = true) - "Medium" - } - } - - /** - * Reads the 5 stat values on the Main screen. - * - * @return The mapping of all 5 stats names to their respective integer values. - */ - fun determineStatValues(statValueMapping: MutableMap): MutableMap { - val (skillPointsLocation, sourceBitmap) = findImage("skill_points") - - if (skillPointsLocation != null) { - // Process all stats at once using the mapping - statValueMapping.forEach { (statName, _) -> - val croppedBitmap = when (statName) { - "Speed" -> createSafeBitmap(sourceBitmap, relX(skillPointsLocation.x, -862), relY(skillPointsLocation.y, 25), relWidth(98), relHeight(42), "determineStatValues Speed stat") - "Stamina" -> createSafeBitmap(sourceBitmap, relX(skillPointsLocation.x, -862 + 170), relY(skillPointsLocation.y, 25), relWidth(98), relHeight(42), "determineStatValues Stamina stat") - "Power" -> createSafeBitmap(sourceBitmap, relX(skillPointsLocation.x, -862 + 170*2), relY(skillPointsLocation.y, 25), relWidth(98), relHeight(42), "determineStatValues Power stat") - "Guts" -> createSafeBitmap(sourceBitmap, relX(skillPointsLocation.x, -862 + 170*3), relY(skillPointsLocation.y, 25), relWidth(98), relHeight(42), "determineStatValues Guts stat") - "Wit" -> createSafeBitmap(sourceBitmap, relX(skillPointsLocation.x, -862 + 170*4), relY(skillPointsLocation.y, 25), relWidth(98), relHeight(42), "determineStatValues Wit stat") - else -> { - game.printToLog("[ERROR] determineStatValue() received an incorrect stat name of $statName.", tag = tag, isError = true) - return@forEach - } - } - if (croppedBitmap == null) { - game.printToLog("[ERROR] Failed to create cropped bitmap for reading $statName stat value.", tag = tag, isError = true) - statValueMapping[statName] = -1 - return@forEach - } - - // Make the cropped screenshot grayscale. - val cvImage = Mat() - Utils.bitmapToMat(croppedBitmap, cvImage) - Imgproc.cvtColor(cvImage, cvImage, Imgproc.COLOR_BGR2GRAY) - if (debugMode) Imgcodecs.imwrite("$matchFilePath/debug${statName}StatValue_afterCrop.png", cvImage) - - val resultBitmap = createBitmap(cvImage.cols(), cvImage.rows()) - Utils.matToBitmap(cvImage, resultBitmap) - tessBaseAPI.setImage(resultBitmap) - - // Set the Page Segmentation Mode to '--psm 7' or "Treat the image as a single text line" according to https://tesseract-ocr.github.io/tessdoc/ImproveQuality.html#page-segmentation-method - tessBaseAPI.pageSegMode = TessBaseAPI.PageSegMode.PSM_SINGLE_LINE - - var result = "empty!" - try { - // Finally, detect text on the cropped region. - result = tessBaseAPI.utF8Text - } catch (e: Exception) { - game.printToLog("[ERROR] Cannot perform OCR with Tesseract: ${e.stackTraceToString()}", tag = tag, isError = true) - } - - tessBaseAPI.clear() - cvImage.release() - - game.printToLog("[INFO] Detected number of stats for $statName from Tesseract before formatting: $result", tag = tag) - if (result.lowercase().contains("max") || result.lowercase().contains("ax")) { - game.printToLog("[INFO] $statName seems to be maxed out. Setting it to 1200.", tag = tag) - statValueMapping[statName] = 1200 - } else { - try { - Log.d(tag, "Converting $result to integer for $statName stat value") - val cleanedResult = result.replace(Regex("[^0-9]"), "") - statValueMapping[statName] = cleanedResult.toInt() - } catch (_: NumberFormatException) { - statValueMapping[statName] = -1 - } - } - } - } else { - game.printToLog("[ERROR] Could not start the process of detecting stat values.", tag = tag, isError = true) - } - - return statValueMapping - } - - /** - * Performs OCR on the date region of the game screen to extract the current date string. - * - * @return The detected date string from the game screen, or empty string if detection fails. - */ - fun determineDayNumber(): String { - val (energyLocation, sourceBitmap) = findImage("energy") - var result = "" - if (energyLocation != null) { - val croppedBitmap = createSafeBitmap(sourceBitmap, relX(energyLocation.x, -268), relY(energyLocation.y, -180), relWidth(308), relHeight(35), "determineDayNumber") - if (croppedBitmap == null) { - game.printToLog("[ERROR] Failed to create cropped bitmap for day number detection.", tag = tag, isError = true) - return "" - } - - // Make the cropped screenshot grayscale. - val cvImage = Mat() - Utils.bitmapToMat(croppedBitmap, cvImage) - Imgproc.cvtColor(cvImage, cvImage, Imgproc.COLOR_BGR2GRAY) - if (debugMode) Imgcodecs.imwrite("$matchFilePath/debug_dateString_afterCrop.png", cvImage) - - // Create a InputImage object for Google's ML OCR. - val resultBitmap = createBitmap(cvImage.cols(), cvImage.rows()) - Utils.matToBitmap(cvImage, resultBitmap) - val inputImage: InputImage = InputImage.fromBitmap(resultBitmap, 0) - - // Use CountDownLatch to make the async operation synchronous. - val latch = CountDownLatch(1) - var mlkitFailed = false - - textRecognizer.process(inputImage) - .addOnSuccessListener { text -> - if (text.textBlocks.isNotEmpty()) { - for (block in text.textBlocks) { - game.printToLog("[INFO] Detected the date with Google ML Kit: ${block.text}", tag = tag) - result = block.text - } - } - latch.countDown() - } - .addOnFailureListener { - game.printToLog("[ERROR] Failed to do text detection via Google's ML Kit. Falling back to Tesseract.", tag = tag, isError = true) - mlkitFailed = true - latch.countDown() - } - - // Wait for the async operation to complete. - try { - latch.await(5, TimeUnit.SECONDS) - } catch (_: InterruptedException) { - game.printToLog("[ERROR] Google ML Kit operation timed out", tag = tag, isError = true) - } - - // Fallback to Tesseract if ML Kit failed or didn't find result. - if (mlkitFailed || result == "") { - tessBaseAPI.setImage(resultBitmap) - tessBaseAPI.pageSegMode = TessBaseAPI.PageSegMode.PSM_SINGLE_LINE - - try { - result = tessBaseAPI.utF8Text - game.printToLog("[INFO] Detected date with Tesseract: $result", tag = tag) - } catch (e: Exception) { - game.printToLog("[ERROR] Cannot perform OCR using Tesseract: ${e.stackTraceToString()}", tag = tag, isError = true) - result = "" - } - - tessBaseAPI.clear() - } - - if (debugMode) { - game.printToLog("[DEBUG] Date string detected to be at \"$result\".") - } else { - Log.d(tag, "Date string detected to be at \"$result\".") - } - } else { - game.printToLog("[ERROR] Could not start the process of detecting the date string.", tag = tag, isError = true) - } - - return result - } - - /** - * Initialize Tesseract for future OCR operations. Make sure to put your .traineddata inside the root of the /assets/ folder. - */ - private fun initTesseract() { - val externalFilesDir: File? = myContext.getExternalFilesDir(null) - val tempDirectory: String = externalFilesDir?.absolutePath + "/tesseract/tessdata/" - val newTempDirectory = File(tempDirectory) - - // If the /files/temp/ folder does not exist, create it. - if (!newTempDirectory.exists()) { - val successfullyCreated: Boolean = newTempDirectory.mkdirs() - - // If the folder was not able to be created for some reason, log the error and stop the MediaProjection Service. - if (!successfullyCreated) { - game.printToLog("[ERROR] Failed to create the /files/tesseract/tessdata/ folder.", tag = tag, isError = true) - } else { - game.printToLog("[INFO] Successfully created /files/tesseract/tessdata/ folder.", tag = tag) - } - } else { - game.printToLog("[INFO] /files/tesseract/tessdata/ folder already exists.", tag = tag) - } - - // If the traineddata is not in the application folder, copy it there from assets. - tesseractLanguages.forEach { lang -> - val trainedDataPath = File(tempDirectory, "$lang.traineddata") - if (!trainedDataPath.exists()) { - try { - game.printToLog("[INFO] Starting Tesseract initialization.", tag = tag) - val input = myContext.assets.open("$lang.traineddata") - - val output = FileOutputStream("$tempDirectory/$lang.traineddata") - - val buffer = ByteArray(1024) - var read: Int - while (input.read(buffer).also { read = it } != -1) { - output.write(buffer, 0, read) - } - - input.close() - output.flush() - output.close() - game.printToLog("[INFO] Finished Tesseract initialization.", tag = tag) - } catch (e: IOException) { - game.printToLog("[ERROR] IO EXCEPTION: ${e.stackTraceToString()}", tag = tag, isError = true) - } - } - } - } - - /** - * Determines the stat gain values from training. Parameters are optional to allow for thread-safe operations. - * - * This function uses template matching to find individual digits and the "+" symbol in the - * stat gain area of the training screen. It processes templates for digits 0-9 and the "+" - * symbol, then constructs the final integer value by analyzing the spatial arrangement - * of detected matches. - * - * @param trainingName Name of the currently selected training to determine which stats to read. - * @param sourceBitmap Bitmap of the source image separately taken. Defaults to null. - * @param skillPointsLocation Point location of the template image separately taken. Defaults to null. - * - * @return Array of 5 detected stat gain values as integers, or -1 for failed detections. - */ - fun determineStatGainFromTraining(trainingName: String, sourceBitmap: Bitmap? = null, skillPointsLocation: Point? = null): IntArray { - val templates = listOf("+", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9") - val statNames = listOf("Speed", "Stamina", "Power", "Guts", "Wit") - // Define a mapping of training types to their stat indices - val trainingToStatIndices = mapOf( - "Speed" to listOf(0, 2), - "Stamina" to listOf(1, 3), - "Power" to listOf(1, 2), - "Guts" to listOf(0, 2, 3), - "Wit" to listOf(0, 4) - ) - - val (skillPointsLocation, sourceBitmap) = if (sourceBitmap == null && skillPointsLocation == null) { - findImage("skill_points") - } else { - Pair(skillPointsLocation, sourceBitmap) - } - - val threadSafeResults = IntArray(5) - - if (skillPointsLocation != null) { - // Pre-load all template bitmaps to avoid thread contention - val templateBitmaps = mutableMapOf() - for (templateName in templates) { - myContext.assets?.open("images/$templateName.png").use { inputStream -> - templateBitmaps[templateName] = BitmapFactory.decodeStream(inputStream) - } - } - - // Process all stats in parallel using threads. - val statLatch = CountDownLatch(5) - for (i in 0 until 5) { - Thread { - try { - // Stop the Thread early if the selected Training would not offer stats for the stat to be checked. - // Speed gives Speed and Power - // Stamina gives Stamina and Guts - // Power gives Stamina and Power - // Guts gives Speed, Power and Guts - // Wits gives Speed and Wits - val validIndices = trainingToStatIndices[trainingName] ?: return@Thread - if (i !in validIndices) return@Thread - - val statName = statNames[i] - val xOffset = i * 180 // All stats are evenly spaced at 180 pixel intervals. - - val croppedBitmap = createSafeBitmap(sourceBitmap!!, relX(skillPointsLocation.x, -934 + xOffset), relY(skillPointsLocation.y, -103), relWidth(150), relHeight(82), "determineStatGainFromTraining $statName") - if (croppedBitmap == null) { - game.printToLog("[ERROR] Failed to create cropped bitmap for $statName stat gain detection.", tag = tag, isError = true) - threadSafeResults[i] = 0 - statLatch.countDown() - return@Thread - } - - // Convert to Mat and then turn it to grayscale. - val sourceMat = Mat() - Utils.bitmapToMat(croppedBitmap, sourceMat) - val sourceGray = Mat() - Imgproc.cvtColor(sourceMat, sourceGray, Imgproc.COLOR_BGR2GRAY) - - val workingMat = Mat() - sourceGray.copyTo(workingMat) - - var matchResults = mutableMapOf>() - templates.forEach { template -> - matchResults[template] = mutableListOf() - } - - for (templateName in templates) { - val templateBitmap = templateBitmaps[templateName] - if (templateBitmap != null) { - matchResults = processStatGainTemplateWithTransparency(templateName, templateBitmap, workingMat, matchResults) - } else { - game.printToLog("[ERROR] Could not load template \"$templateName\".", tag = tag, isError = true) - } - } - - // Analyze results and construct the final integer value for this region. - val finalValue = constructIntegerFromMatches(matchResults) - threadSafeResults[i] = finalValue - game.printToLog("[INFO] $statName region final constructed value: $finalValue.", tag = tag) - - // Draw final visualization with all matches for this region. - if (debugMode) { - val resultMat = Mat() - Utils.bitmapToMat(croppedBitmap, resultMat) - templates.forEachIndexed { index, templateName -> - matchResults[templateName]?.forEach { point -> - val templateBitmap = templateBitmaps[templateName] - if (templateBitmap != null) { - val templateWidth = templateBitmap.width - val templateHeight = templateBitmap.height - - // Calculate the bounding box coordinates. - val x1 = (point.x - templateWidth/2).toInt() - val y1 = (point.y - templateHeight/2).toInt() - val x2 = (point.x + templateWidth/2).toInt() - val y2 = (point.y + templateHeight/2).toInt() - - // Draw the bounding box. - Imgproc.rectangle(resultMat, Point(x1.toDouble(), y1.toDouble()), Point(x2.toDouble(), y2.toDouble()), Scalar(0.0, 0.0, 0.0), 2) - - // Add text label. - Imgproc.putText(resultMat, templateName, Point(point.x, point.y), Imgproc.FONT_HERSHEY_SIMPLEX, 0.5, Scalar(0.0, 0.0, 0.0), 1) - } - } - } - - Imgcodecs.imwrite("$matchFilePath/debug_trainingStatGain_${statNames[i]}_thread${i + 1}.png", resultMat) - } - - sourceMat.release() - sourceGray.release() - workingMat.release() - } catch (e: Exception) { - game.printToLog("[ERROR] Error processing stat ${statNames[i]}: ${e.stackTraceToString()}", tag = tag, isError = true) - threadSafeResults[i] = 0 - } finally { - statLatch.countDown() - } - }.start() - } - - // Wait for all threads to complete. - try { - statLatch.await(30, TimeUnit.SECONDS) - } catch (_: InterruptedException) { - game.printToLog("[ERROR] Stat processing timed out", tag = tag, isError = true) - } - - game.printToLog("[INFO] All 5 stat regions processed. Results: ${threadSafeResults.contentToString()}", tag = tag) - } else { - game.printToLog("[ERROR] Could not find the skill points location to start determining stat gains.", tag = tag, isError = true) - } - - return threadSafeResults - } - - /** - * Processes a single template with transparency to find all valid matches in the working matrix through a multi-stage algorithm. - * - * The algorithm uses two validation criteria: - * - Pixel match ratio: Ensures sufficient pixel-level similarity. - * - Correlation coefficient: Validates statistical correlation between template and matched region. - * - * @param templateName Name of the template being processed (used for logging and debugging). - * @param templateBitmap Bitmap of the template image (must have 4-channel RGBA format with transparency). - * @param workingMat Working matrix to search in (grayscale source image). - * @param matchResults Map to store match results, organized by template name. - * - * @return The modified matchResults mapping containing all valid matches found for this template - */ - private fun processStatGainTemplateWithTransparency(templateName: String, templateBitmap: Bitmap, workingMat: Mat, matchResults: MutableMap>): MutableMap> { - // These values have been tested for the best results against the dynamic background. - val matchConfidence = 0.9 - val minPixelMatchRatio = 0.1 - val minPixelCorrelation = 0.85 - - // Convert template to Mat and then to grayscale. - val templateMat = Mat() - val templateGray = Mat() - Utils.bitmapToMat(templateBitmap, templateMat) - Imgproc.cvtColor(templateMat, templateGray, Imgproc.COLOR_BGR2GRAY) - - // Check if template has an alpha channel (transparency). - if (templateMat.channels() != 4) { - Log.e(tag, "[ERROR] Template \"$templateName\" is not transparent and is a requirement.") - templateMat.release() - templateGray.release() - return matchResults - } - - // Extract alpha channel for the alpha mask. - val alphaChannels = ArrayList() - Core.split(templateMat, alphaChannels) - val alphaMask = alphaChannels[3] // Alpha channel is the 4th channel. - - // Create binary mask for non-transparent pixels. - val validPixels = Mat() - Core.compare(alphaMask, Scalar(0.0), validPixels, Core.CMP_GT) - - // Check transparency ratio. - val nonZeroPixels = Core.countNonZero(alphaMask) - val totalPixels = alphaMask.rows() * alphaMask.cols() - val transparencyRatio = nonZeroPixels.toDouble() / totalPixels - if (transparencyRatio < 0.1) { - Log.w(tag, "[DEBUG] Template \"$templateName\" appears to be mostly transparent!") - alphaChannels.forEach { it.release() } - validPixels.release() - alphaMask.release() - templateMat.release() - templateGray.release() - return matchResults - } - - //////////////////////////////////////////////////////////////////// - //////////////////////////////////////////////////////////////////// - - var continueSearching = true - var searchMat = Mat() - var xOffset = 0 - workingMat.copyTo(searchMat) - - while (continueSearching) { - var failedPixelMatchRatio = false - var failedPixelCorrelation = false - - // Template match with the alpha mask. - val result = Mat() - Imgproc.matchTemplate(searchMat, templateGray, result, Imgproc.TM_CCORR_NORMED, alphaMask) - val mmr = Core.minMaxLoc(result) - val matchVal = mmr.maxVal - val matchLocation = mmr.maxLoc - - if (matchVal >= matchConfidence) { - val x = matchLocation.x.toInt() - val y = matchLocation.y.toInt() - val h = templateGray.rows() - val w = templateGray.cols() - - // Validate that the match location is within bounds. - if (x >= 0 && y >= 0 && x + w <= searchMat.cols() && y + h <= searchMat.rows()) { - // Extract the matched region from the source image. - val matchedRegion = Mat(searchMat, Rect(x, y, w, h)) - - // Create masked versions of the template and matched region using only non-transparent pixels. - val templateValid = Mat() - val regionValid = Mat() - templateGray.copyTo(templateValid, validPixels) - matchedRegion.copyTo(regionValid, validPixels) - - // For the first test, compare pixel-by-pixel equality between the matched region and template to calculate match ratio. - val templateComparison = Mat() - Core.compare(matchedRegion, templateGray, templateComparison, Core.CMP_EQ) - val matchingPixels = Core.countNonZero(templateComparison) - val pixelMatchRatio = matchingPixels.toDouble() / (w * h) - if (pixelMatchRatio < minPixelMatchRatio) { - failedPixelMatchRatio = true - } - - // Extract pixel values into double arrays for correlation calculation. - val templateValidMat = Mat() - val regionValidMat = Mat() - templateValid.convertTo(templateValidMat, CvType.CV_64F) - regionValid.convertTo(regionValidMat, CvType.CV_64F) - val templateArray = DoubleArray(templateValid.total().toInt()) - val regionArray = DoubleArray(regionValid.total().toInt()) - templateValidMat.get(0, 0, templateArray) - regionValidMat.get(0, 0, regionArray) - - // For the second test, validate the match quality by performing correlation calculation. - val pixelCorrelation = calculateCorrelation(templateArray, regionArray) - if (pixelCorrelation < minPixelCorrelation) { - failedPixelCorrelation = true - } - - // If both tests passed, then the match is valid. - if (!failedPixelMatchRatio && !failedPixelCorrelation) { - val centerX = (x + xOffset) + (w / 2) - val centerY = y + (h / 2) - - // Check for overlap with existing matches within 10 pixels on both axes. - val hasOverlap = matchResults.values.flatten().any { existingPoint -> - val existingX = existingPoint.x - val existingY = existingPoint.y - - // Check if the new match overlaps with existing match within 10 pixels. - val xOverlap = kotlin.math.abs(centerX - existingX) < 10 - val yOverlap = kotlin.math.abs(centerY - existingY) < 10 - - xOverlap && yOverlap - } - - if (!hasOverlap) { - Log.d(tag, "[DEBUG] Found valid match for template \"$templateName\" at ($centerX, $centerY).") - matchResults[templateName]?.add(Point(centerX.toDouble(), centerY.toDouble())) - } - } - - // Draw a box to prevent re-detection in the next loop iteration. - Imgproc.rectangle(searchMat, Point(x.toDouble(), y.toDouble()), Point((x + w).toDouble(), (y + h).toDouble()), Scalar(0.0, 0.0, 0.0), 10) - - templateComparison.release() - matchedRegion.release() - templateValid.release() - regionValid.release() - templateValidMat.release() - regionValidMat.release() - - // Crop the Mat horizontally to exclude the supposed matched area. - val cropX = x + w - val remainingWidth = searchMat.cols() - cropX - when { - remainingWidth < templateGray.cols() -> { - continueSearching = false - } - else -> { - val newSearchMat = Mat(searchMat, Rect(cropX, 0, remainingWidth, searchMat.rows())) - searchMat.release() - searchMat = newSearchMat - xOffset += cropX - } - } - } else { - // Stop searching when the source has been traversed. - continueSearching = false - } - } else { - // No match found above threshold, stop searching for this template. - continueSearching = false - } - - result.release() - - // Safety check to prevent infinite loops. - if ((matchResults[templateName]?.size ?: 0) > 10) { - continueSearching = false - } - if (!BotService.isRunning) { - throw InterruptedException() - } - } - - //////////////////////////////////////////////////////////////////// - //////////////////////////////////////////////////////////////////// - - searchMat.release() - alphaChannels.forEach { it.release() } - validPixels.release() - alphaMask.release() - templateMat.release() - templateGray.release() - - return matchResults - } - - /** - * Constructs the final integer value from matched template locations of numbers by analyzing spatial arrangement. - * - * The function is designed for OCR-like scenarios where individual character templates - * are matched separately and need to be reconstructed into a complete number. - * - * If matchResults contains: {"+" -> [(10, 20)], "1" -> [(15, 20)], "2" -> [(20, 20)]}, it returns: 12 (from string "+12"). - * - * @param matchResults Map of template names (e.g., "0", "1", "2", "+") to their match locations. - * - * @return The constructed integer value or -1 if it failed. - */ - private fun constructIntegerFromMatches(matchResults: Map>): Int { - // Collect all matches with their template names. - val allMatches = mutableListOf>() - matchResults.forEach { (templateName, points) -> - points.forEach { point -> - allMatches.add(Pair(templateName, point)) - } - } - - if (allMatches.isEmpty()) { - if (debugMode) game.printToLog("[WARNING] No matches found to construct integer value.", tag = tag) - return 0 - } - - // Sort matches by x-coordinate (left to right). - allMatches.sortBy { it.second.x } - if (debugMode) game.printToLog("[DEBUG] Sorted matches: ${allMatches.map { "${it.first}@(${it.second.x}, ${it.second.y})" }}", tag = tag) - - // Construct the string representation and then validate the format: start with + and contain only digits after. - val constructedString = allMatches.joinToString("") { it.first } - game.printToLog("[INFO] Constructed string: \"$constructedString\".", tag = tag) - - // Extract the numeric part and convert to integer. - return try { - val numericPart = if (constructedString.startsWith("+") && constructedString.substring(1).isNotEmpty()) { - constructedString.substring(1) - } else { - constructedString - } - - val result = numericPart.toInt() - if (debugMode) game.printToLog("[DEBUG] Successfully constructed integer value: $result from \"$constructedString\".", tag = tag) - result - } catch (e: NumberFormatException) { - game.printToLog("[ERROR] Could not convert \"$constructedString\" to integer: ${e.stackTraceToString()}", tag = tag, isError = true) - 0 - } - } - - /** - * Calculates the Pearson correlation coefficient between two arrays of pixel values. - * - * The Pearson correlation coefficient measures the linear correlation between two variables, - * ranging from -1 (perfect negative correlation) to +1 (perfect positive correlation). - * A value of 0 indicates no linear correlation. - * - * @param array1 First array of pixel values from the template image. - * @param array2 Second array of pixel values from the matched region. - * @return Correlation coefficient between -1.0 and +1.0, or 0.0 if arrays are invalid - */ - private fun calculateCorrelation(array1: DoubleArray, array2: DoubleArray): Double { - if (array1.size != array2.size || array1.isEmpty()) { - return 0.0 - } - - val n = array1.size - val sum1 = array1.sum() - val sum2 = array2.sum() - val sum1Sq = array1.sumOf { it * it } - val sum2Sq = array2.sumOf { it * it } - val pSum = array1.zip(array2).sumOf { it.first * it.second } - - // Calculate the numerator: n*Σ(xy) - Σx*Σy - val num = pSum - (sum1 * sum2 / n) - // Calculate the denominator: sqrt((n*Σx² - (Σx)²) * (n*Σy² - (Σy)²)) - val den = sqrt((sum1Sq - sum1 * sum1 / n) * (sum2Sq - sum2 * sum2 / n)) - - // Return the correlation coefficient, handling division by zero. - return if (den == 0.0) 0.0 else num / den - } -} \ No newline at end of file diff --git a/app/src/main/java/com/steve1316/uma_android_automation/utils/MediaProjectionService.kt b/app/src/main/java/com/steve1316/uma_android_automation/utils/MediaProjectionService.kt deleted file mode 100644 index f0659469..00000000 --- a/app/src/main/java/com/steve1316/uma_android_automation/utils/MediaProjectionService.kt +++ /dev/null @@ -1,375 +0,0 @@ -package com.steve1316.uma_android_automation.utils - -import android.annotation.SuppressLint -import android.app.Activity -import android.app.Service -import android.content.Context -import android.content.Intent -import android.graphics.Bitmap -import android.graphics.PixelFormat -import android.hardware.display.DisplayManager -import android.hardware.display.VirtualDisplay -import android.media.Image -import android.media.Image.Plane -import android.media.ImageReader -import android.media.projection.MediaProjection -import android.media.projection.MediaProjectionManager -import android.os.Build -import android.os.Handler -import android.os.IBinder -import android.os.Looper -import android.util.DisplayMetrics -import android.util.Log -import android.view.* -import android.widget.Toast -import androidx.annotation.RequiresApi -import com.steve1316.uma_android_automation.MainActivity -import com.steve1316.uma_android_automation.R -import kotlinx.coroutines.* -import java.io.File -import java.io.FileOutputStream -import java.io.IOException -import java.util.* -import androidx.core.graphics.createBitmap - -/** - * The MediaProjection service that will control taking screenshots. - * - * Source is from https://github.com/mtsahakis/MediaProjectionDemo where the Java code was converted to Kotlin and additional logic was - * added to suit this application's purposes. - */ -class MediaProjectionService : Service() { - private var appName: String = "" - private lateinit var myContext: Context - - companion object { - private const val TAG: String = "[${MainActivity.loggerTag}]MediaProjectionService" - - private var mediaProjection: MediaProjection? = null - private var orientationChangeCallback: OrientationEventListener? = null - private lateinit var tempDirectory: String - private lateinit var threadHandler: Handler - - var displayWidth: Int = 0 - var displayHeight: Int = 0 - var displayDPI: Int = 0 - - private lateinit var virtualDisplay: VirtualDisplay - private lateinit var defaultDisplay: Display - private lateinit var windowManager: WindowManager - private var oldRotation: Int = 0 - private lateinit var imageReader: ImageReader - var isRunning: Boolean = false - - /** - * Tell the ImageReader to grab the latest acquired screenshot and process it into a Bitmap. - * - * @param saveImage Flag to check whether to save the image to a file in the temp directory or not. Defaults to false. - * @param isException Saves the screenshot as part of Exception logging or not. Defaults to false. - * @return Bitmap of the latest acquired screenshot. - */ - fun takeScreenshotNow(saveImage: Boolean = false, isException: Boolean = false): Bitmap? { - var sourceBitmap: Bitmap? = null - - val image: Image? = imageReader.acquireLatestImage() - - if (image != null) { - val planes: Array = image.planes - val buffer = planes[0].buffer - val pixelStride = planes[0].pixelStride - val rowStride = planes[0].rowStride - val rowPadding: Int = rowStride - pixelStride * displayWidth - - // Create the Bitmap. - sourceBitmap = createBitmap(displayWidth + rowPadding / pixelStride, displayHeight) - sourceBitmap.copyPixelsFromBuffer(buffer) - - // Now write the Bitmap to the specified file inside the /files/temp/ folder. This adds about 500-600ms to runtime every time this is called when Debug Mode is on. - if (saveImage) { - val fos = if (isException) { - FileOutputStream("$tempDirectory/exception.png") - } else { - FileOutputStream("$tempDirectory/source.png") - } - sourceBitmap.compress(Bitmap.CompressFormat.PNG, 100, fos) - - // Perform cleanup by closing streams and freeing up memory. - try { - fos.close() - } catch (ioe: IOException) { - ioe.printStackTrace() - } - } - - image.close() - } - - return sourceBitmap - } - - /** - * Create a new Intent to start this service. - * - * @param context The application's context. - * @param resultCode The output of this service. - * @param data The data of this service. - * @return A new Intent. - */ - fun getStartIntent(context: Context, resultCode: Int, data: Intent): Intent { - return Intent(context, MediaProjectionService::class.java).apply { - putExtra("ACTION", "START") - putExtra("RESULT_CODE", resultCode) - putExtra("DATA", data) - } - } - - /** - * Create a new Intent to stop this service. - * - * @param context The application's context. - * @return A new Intent. - */ - fun getStopIntent(context: Context): Intent { - return Intent(context, MediaProjectionService::class.java).apply { - putExtra("ACTION", "STOP") - } - } - - /** - * Checks whether the Intent is a START command. - * - * @param intent The Intent to be checked. - * @return True if it is a START command. Otherwise, it is False. - */ - private fun isStartCommand(intent: Intent): Boolean { - isRunning = true - return (intent.hasExtra("RESULT_CODE") && intent.hasExtra("DATA") && intent.hasExtra("ACTION") && Objects.equals( - intent.getStringExtra("ACTION"), "START" - )) - } - - /** - * Checks whether the Intent is a STOP command. - * - * @param intent The Intent to be checked. - * @return True if it is a STOP command. Otherwise, it is False. - */ - private fun isStopCommand(intent: Intent): Boolean { - isRunning = false - return (intent.hasExtra("ACTION") && Objects.equals(intent.getStringExtra("ACTION"), "STOP")) - } - - /** - * Gets the public flag for the initialization of the VirtualDisplay, whether or not it can allow applications to open their own displays on - * it. - * - * @return An Integer representing the flag. - */ - private fun getVirtualDisplayFlags(): Int { - return DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY or DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC - } - } - - override fun onCreate() { - super.onCreate() - - // Creates a temporary folder if it does not already exist to store source images. - val externalFilesDir: File? = getExternalFilesDir(null) - if (externalFilesDir != null) { - tempDirectory = externalFilesDir.absolutePath + "/temp/" - val newTempDirectory = File(tempDirectory) - - // If the /files/temp/ folder does not exist, create it. - if (!newTempDirectory.exists()) { - val successfullyCreated: Boolean = newTempDirectory.mkdirs() - - // If the folder was not able to be created for some reason, log the error and stop the MediaProjection Service. - if (!successfullyCreated) { - Log.e(TAG, "Failed to create the /files/temp/ folder.") - stopSelf() - } else { - Log.d(TAG, "Successfully created /files/temp/ folder.") - } - } else { - Log.d(TAG, "/files/temp/ folder already exists.") - } - } - - // Now, start a new Thread to handle processing new screenshots. - object : Thread() { - override fun run() { - Log.d(TAG, "Thread running for MediaProjection service.") - threadHandler = Handler(Looper.getMainLooper()) - Looper.prepare() - Looper.loop() - } - }.start() - } - - override fun onBind(intent: Intent?): IBinder? { - return null - } - - @RequiresApi(Build.VERSION_CODES.R) - @SuppressLint("ClickableViewAccessibility", "InflateParams") - override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { - // Save a reference to the context. - myContext = this - appName = myContext.getString(R.string.app_name) - - if (isStartCommand(intent)) { - // Create a new Notification in the foreground telling users that the MediaProjection Service is now active. - val (notification, notificationID) = NotificationUtils.getNewNotification(this) - startForeground(notificationID, notification) - - val resultCode = intent.getIntExtra("RESULT_CODE", Activity.RESULT_CANCELED) - val data: Intent? = intent.getParcelableExtra("DATA") - if (data != null) { - // Start the MediaProjection service. - startMediaProjection(resultCode, data) - - // Finally, start the Bot Service. - val botStartIntent = Intent(this, BotService::class.java) - startService(botStartIntent) - } - } else if (isStopCommand(intent)) { - // Perform cleanup on the MediaProjection service and then stop itself. - Log.d(TAG, "Received STOP Intent for MediaProjection. Stopping MediaProjection service.") - stopMediaProjection() - stopSelf() - } else { - Log.e(TAG, "Encountered unexpected Intent. Shutting down service.") - stopSelf() - } - - return START_NOT_STICKY - } - - /** - * Custom Callback for when the user rotates their device from horizontal to vertical and vice-versa. - */ - private inner class OrientationChangeCallback(context: Context) : OrientationEventListener(context) { - private val tagOrientationChangeCallback: String = "[${MainActivity.loggerTag}]OrientationChangeCallback" - - override fun onOrientationChanged(orientation: Int) { - val newRotation: Int = (getSystemService(WINDOW_SERVICE) as WindowManager).defaultDisplay.rotation - if (newRotation != oldRotation) { - Log.d(tagOrientationChangeCallback, "Device was rotated. Reconstructing the Virtual Display now...") - oldRotation = newRotation - try { - // Perform cleanup. - virtualDisplay.release() - - // Now re-create the VirtualDisplay based on the new width and height of the rotated screen. - createVirtualDisplay() - } catch (_: Exception) { - Log.e(tagOrientationChangeCallback, "Failed to perform cleanup and recreating the VirtualDisplay after device rotation.") - Toast.makeText( - myContext, "Failed to perform cleanup and recreating the VirtualDisplay after device rotation.", - Toast.LENGTH_SHORT - ).show() - } - } - } - } - - /** - * Custom Callback for when it is necessary to stop the MediaProjection. - */ - private inner class MediaProjectionStopCallback : MediaProjection.Callback() { - private val tagMediaProjectionStopCallback = "[${MainActivity.loggerTag}]MediaProjectionStopCallback" - - override fun onStop() { - threadHandler.post { - isRunning = false - - // Destroy the VirtualDisplay. - virtualDisplay.release() - - // Disable the OrientationChangeCallback. - orientationChangeCallback?.disable() - - // Then remove this listener from the MediaProjection object. - mediaProjection?.unregisterCallback(this@MediaProjectionStopCallback) - - // Finally, stop the Bot Service. - val botStopIntent = Intent(myContext, BotService::class.java) - stopService(botStopIntent) - - // Now set the MediaProjection object to null to eliminate the "Invalid media projection" error. - mediaProjection = null - - Log.d(tagMediaProjectionStopCallback, "MediaProjection Service for $appName has stopped.") - Toast.makeText(myContext, "MediaProjection Service for $appName has stopped.", Toast.LENGTH_SHORT).show() - } - } - } - - /** - * Creates and starts the MediaProjection. - * - * @param resultCode The output of this service. - * @param data The data of this service. - */ - private fun startMediaProjection(resultCode: Int, data: Intent) { - // Retrieve the MediaProjection object. - if (mediaProjection == null) { - mediaProjection = (getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager).getMediaProjection(resultCode, data) - } - - // Get the WindowManager object. - windowManager = getSystemService(WINDOW_SERVICE) as WindowManager - - // Get the DefaultDisplay object. - defaultDisplay = windowManager.defaultDisplay - - // Create the VirtualDisplay and start reading in screenshots. - createVirtualDisplay() - - // Attach the OrientationChangeCallback. - orientationChangeCallback = OrientationChangeCallback(this) - if ((orientationChangeCallback as OrientationChangeCallback).canDetectOrientation()) { - (orientationChangeCallback as OrientationChangeCallback).enable() - } - - // Attach the MediaProjectionStopCallback to the MediaProjection object. - mediaProjection?.registerCallback(MediaProjectionStopCallback(), threadHandler) - - Log.d(TAG, "MediaProjection Service for $appName is now running.") - Toast.makeText(myContext, "MediaProjection Service for $appName is now running.", Toast.LENGTH_SHORT).show() - } - - /** - * Stops the MediaProjection. - */ - private fun stopMediaProjection() { - threadHandler.post { - mediaProjection?.stop() - } - } - - /** - * Creates the VirtualDisplay and the ImageReader to start reading in screenshots. - */ - @SuppressLint("WrongConstant") - private fun createVirtualDisplay() { - // Get the full width and height of the device screen such that making a screenshot would not scale it down and creating black bars that - // would offset the screen coordinates of matches by the difference. - val metrics = DisplayMetrics() - defaultDisplay.getRealMetrics(metrics) - displayWidth = metrics.widthPixels - displayHeight = metrics.heightPixels - displayDPI = metrics.densityDpi - - Log.d(TAG, "Screen Width: $displayWidth, Screen Height: $displayHeight, Screen DPI: $displayDPI") - - // Start the ImageReader. - imageReader = ImageReader.newInstance(displayWidth, displayHeight, PixelFormat.RGBA_8888, 2) - - // Now create the VirtualDisplay. - virtualDisplay = mediaProjection?.createVirtualDisplay( - "$appName's Virtual Display", displayWidth, displayHeight, - displayDPI, getVirtualDisplayFlags(), imageReader.surface, null, threadHandler - )!! - } -} \ No newline at end of file diff --git a/app/src/main/java/com/steve1316/uma_android_automation/utils/MessageLog.kt b/app/src/main/java/com/steve1316/uma_android_automation/utils/MessageLog.kt deleted file mode 100644 index aa1eb613..00000000 --- a/app/src/main/java/com/steve1316/uma_android_automation/utils/MessageLog.kt +++ /dev/null @@ -1,122 +0,0 @@ -package com.steve1316.uma_android_automation.utils - -import android.content.Context -import android.os.Build -import android.util.Log -import com.steve1316.uma_android_automation.MainActivity -import java.io.File -import java.text.SimpleDateFormat -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter -import java.util.* - -/** - * This class is in charge of holding the Message Log to which all logging messages from the bot goes to and also saves it all into a file when the bot has finished. - */ -class MessageLog { - companion object { - private const val TAG: String = "[${MainActivity.loggerTag}]MessageLog" - var messageLog = arrayListOf() - - // Add synchronization object for thread-safe access - private val messageLogLock = Object() - - var saveCheck = false - - /** - * Save the current Message Log into a new file inside internal storage's /logs/ folder. - * - * @param context The context for the application. - */ - fun saveLogToFile(context: Context) { - cleanLogsFolder(context) - - if (!saveCheck) { - Log.d(TAG, "Now beginning process to save current Message Log to internal storage...") - - // Generate file path to save to. All message logs will be saved to the /logs/ folder inside internal storage. Create the /logs/ folder if needed. - val path = File(context.getExternalFilesDir(null)?.absolutePath + "/logs/") - if (!path.exists()) { - path.mkdirs() - } - - // Generate the file name. - val fileName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val current = LocalDateTime.now() - val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") - "log @ ${current.format(formatter)}" - } else { - val current = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date()) - val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) - "log @ ${current.format(sdf)}" - } - - // Now save the Message Log to the new text file. - Log.d(TAG, "Now saving Message Log to file named \"$fileName\" at $path") - val file = File(path, "$fileName.txt") - - if (!file.exists()) { - file.createNewFile() - file.printWriter().use { out -> - // Synchronize access to messageLog to prevent concurrent modification - synchronized(messageLogLock) { - messageLog.forEach { - out.println(it) - } - } - } - - saveCheck = true - } - } - } - - /** - * Add a message to the log in a thread-safe manner. - * - * @param message The message to add to the log. - */ - fun addMessage(message: String) { - synchronized(messageLogLock) { - messageLog.add(message) - } - } - - /** - * Clear the message log in a thread-safe manner. - */ - fun clearLog() { - synchronized(messageLogLock) { - messageLog.clear() - } - } - - /** - * Get a copy of the current message log in a thread-safe manner. - * - * @return A copy of the current message log. - */ - fun getMessageLogCopy(): List { - synchronized(messageLogLock) { - return ArrayList(messageLog) - } - } - - /** - * Clean up the logs folder if the amount of logs inside is greater than the specified amount. - * - * @param context The context for the application. - */ - private fun cleanLogsFolder(context: Context) { - val directory = File(context.getExternalFilesDir(null)?.absolutePath + "/logs/") - - // Delete all logs if the amount inside is greater than 50. - val files = directory.listFiles() - if (files != null && files.size > 50) { - files.forEach { file -> - file.delete() - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/steve1316/uma_android_automation/utils/MyAccessibilityService.kt b/app/src/main/java/com/steve1316/uma_android_automation/utils/MyAccessibilityService.kt deleted file mode 100644 index 64b54792..00000000 --- a/app/src/main/java/com/steve1316/uma_android_automation/utils/MyAccessibilityService.kt +++ /dev/null @@ -1,292 +0,0 @@ -package com.steve1316.uma_android_automation.utils - -import android.accessibilityservice.AccessibilityService -import android.accessibilityservice.GestureDescription -import android.annotation.SuppressLint -import android.content.Context -import android.content.res.Resources -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.Path -import android.os.Build -import android.util.Log -import android.view.accessibility.AccessibilityEvent -import android.widget.Toast -import com.steve1316.uma_android_automation.MainActivity -import com.steve1316.uma_android_automation.R -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking - -/** - * Contains the Accessibility service that will allow the bot to programmatically perform gestures on the screen. - */ -class MyAccessibilityService : AccessibilityService() { - private var appName: String = "" - private val tag: String = "[${MainActivity.loggerTag}]MyAccessibilityService" - private lateinit var myContext: Context - - // Define the baseline screen dimensions that the template images were made from for tap location randomization. - private val baselineWidth = 1080 - private val baselineHeight = 2340 - - companion object { - // Other classes need this static reference to this service as calling dispatchGesture() would not work. - @SuppressLint("StaticFieldLeak") - private lateinit var instance: MyAccessibilityService - - /** - * Returns a static reference to this class. - * - * @return Static reference to MyAccessibilityService. - */ - fun getInstance(): MyAccessibilityService { - if (!::instance.isInitialized) { - throw IllegalStateException("Accessibility Service not initialized. Disable and re-enable the Accessibility Service.") - } - if (!BotService.isRunning) { - throw IllegalStateException("Accessibility Service is not running. Enable the Accessibility Service.") - } - return instance - } - } - - override fun onServiceConnected() { - instance = this - myContext = this - appName = myContext.getString(R.string.app_name) - - Log.d(tag, "Accessibility Service for $appName is now running.") - Toast.makeText(myContext, "Accessibility Service for $appName now running.", Toast.LENGTH_SHORT).show() - } - - override fun onInterrupt() { - return - } - - override fun onDestroy() { - super.onDestroy() - - Log.d(tag, "Accessibility Service for $appName is now stopped.") - Toast.makeText(myContext, "Accessibility Service for $appName is now stopped.", Toast.LENGTH_SHORT).show() - } - - override fun onAccessibilityEvent(event: AccessibilityEvent?) { - return - } - - /** - * This receiver will wait the specified seconds to account for ping or loading. - */ - private fun Double.wait() { - runBlocking { - delay((this@wait * 1000).toLong()) - } - } - - /** - * Randomizes the tap location to be within the dimensions of the specified image. - * - * @param x The original x location for the tap gesture. - * @param y The original y location for the tap gesture. - * @param imageName The name of the image to acquire its dimensions for tap location randomization. - * @return Pair of integers that represent the newly randomized tap location. - */ - private fun randomizeTapLocation(x: Double, y: Double, imageName: String): Pair { - // Get the Bitmap from the template image file inside the specified folder. - val templateBitmap: Bitmap - myContext.assets?.open("images/$imageName.png").use { inputStream -> - // Get the Bitmap from the template image file and then start matching. - templateBitmap = BitmapFactory.decodeStream(inputStream) - } - - // Calculate scaling factors. - val scaleX = MediaProjectionService.displayWidth.toDouble() / baselineWidth.toDouble() - val scaleY = MediaProjectionService.displayHeight.toDouble() / baselineHeight.toDouble() - - // Scale the template dimensions to match current screen resolution. - val scaledWidth = (templateBitmap.width * scaleX).toInt() - val scaledHeight = (templateBitmap.height * scaleY).toInt() - - // Randomize the tapping location using scaled dimensions. - val x0: Int = (x - (scaledWidth / 2)).toInt() - val x1: Int = (x + (scaledWidth / 2)).toInt() - val y0: Int = (y - (scaledHeight / 2)).toInt() - val y1: Int = (y + (scaledHeight / 2)).toInt() - - var newX: Int - var newY: Int - - while (true) { - // Start acquiring randomized coordinates at least 20% and at most 80% of the scaled width and height until a valid set of coordinates has been acquired. - val newWidth: Int = ((scaledWidth * 0.2).toInt()..(scaledWidth * 0.8).toInt()).random() - val newHeight: Int = ((scaledHeight * 0.2).toInt()..(scaledHeight * 0.8).toInt()).random() - - newX = x0 + newWidth - newY = y0 + newHeight - - // If the new coordinates are within the bounds of the scaled template image, break out of the loop. - if (newX > x0 || newX < x1 || newY > y0 || newY < y1) { - break - } - } - - return Pair(newX, newY) - } - - /** - * Creates a tap gesture on the specified point on the screen. - * - * @param x The x coordinate of the point. - * @param y The y coordinate of the point. - * @param imageName The name of the image to acquire its dimensions for tap location randomization. - * @param ignoreWait Whether or not to not wait 0.5 seconds after dispatching the gesture. - * @param longPress Whether or not to long press. - * @param taps How many taps to execute. - * @return True if the tap gesture was executed successfully. False otherwise. - */ - fun tap(x: Double, y: Double, imageName: String, ignoreWait: Boolean = false, longPress: Boolean = false, taps: Int = 1): Boolean { - // Randomize the tapping location. - val (newX, newY) = randomizeTapLocation(x, y, imageName) - - // Construct the tap gesture. - val tapPath = Path().apply { - moveTo(newX.toFloat(), newY.toFloat()) - } - - val gesture: GestureDescription = if (longPress) { - // Long press for 1000ms. - GestureDescription.Builder().apply { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - addStroke(GestureDescription.StrokeDescription(tapPath, 0, 1000, true)) - } else { - addStroke(GestureDescription.StrokeDescription(tapPath, 0, 1000)) - } - }.build() - } else { - GestureDescription.Builder().apply { - addStroke(GestureDescription.StrokeDescription(tapPath, 0, 1)) - }.build() - } - - val dispatchResult = dispatchGesture(gesture, null, null) - var tries = taps - 1 - - while (tries > 0) { - dispatchGesture(gesture, null, null) - if (!ignoreWait) { - 0.5.wait() - } - - tries -= 1 - } - - if (!ignoreWait) { - 0.5.wait() - } - - return dispatchResult - } - - /** - * Creates a scroll gesture either scrolling up or down the screen depending on the given action. - * - * @param scrollDown The scrolling action, either up or down the screen. Defaults to true which is scrolling down. - * @param duration How long the scroll should take. Defaults to 100L. - * @param ignoreWait Whether or not to not wait 0.5 seconds after dispatching the gesture. - * @return True if the scroll gesture was executed successfully. False otherwise. - */ - fun scroll(scrollDown: Boolean = true, duration: Long = 500L, ignoreWait: Boolean = false): Boolean { - val scrollPath = Path() - - // Get certain portions of the screen's dimensions. - val displayMetrics = Resources.getSystem().displayMetrics - - // Set different scroll paths for different screen sizes. - val top: Float - val middle: Float - val bottom: Float - when (displayMetrics.widthPixels) { - 1600 -> { - top = (displayMetrics.heightPixels * 0.60).toFloat() - middle = (displayMetrics.widthPixels * 0.20).toFloat() - bottom = (displayMetrics.heightPixels * 0.40).toFloat() - } - 2650 -> { - top = (displayMetrics.heightPixels * 0.60).toFloat() - middle = (displayMetrics.widthPixels * 0.20).toFloat() - bottom = (displayMetrics.heightPixels * 0.40).toFloat() - } - else -> { - top = (displayMetrics.heightPixels * 0.75).toFloat() - middle = (displayMetrics.widthPixels / 2).toFloat() - bottom = (displayMetrics.heightPixels * 0.25).toFloat() - } - } - - if (scrollDown) { - // Create a Path to scroll the screen down starting from the top and swiping to the bottom. - scrollPath.apply { - moveTo(middle, top) - lineTo(middle, bottom) - } - } else { - // Create a Path to scroll the screen up starting from the bottom and swiping to the top. - scrollPath.apply { - moveTo(middle, bottom) - lineTo(middle, top) - } - } - - val gesture = GestureDescription.Builder().apply { - addStroke(GestureDescription.StrokeDescription(scrollPath, 0, duration)) - }.build() - - val dispatchResult = dispatchGesture(gesture, null, null) - if (!ignoreWait) { - 0.5.wait() - } - - if (!dispatchResult) { - Log.e(tag, "Failed to dispatch scroll gesture.") - } else { - val direction: String = if (scrollDown) { - "down" - } else { - "up" - } - Log.d(tag, "Scrolling $direction.") - } - - return dispatchResult - } - - /** - * Creates a swipe gesture from the old coordinates to the new coordinates on the screen. - * - * @param oldX The x coordinate of the old position. - * @param oldY The y coordinate of the old position. - * @param newX The x coordinate of the new position. - * @param newY The y coordinate of the new position. - * @param duration How long the swipe should take. Defaults to 500L. - * @param ignoreWait Whether or not to not wait 0.5 seconds after dispatching the gesture. - * @return True if the swipe gesture was executed successfully. False otherwise. - */ - fun swipe(oldX: Float, oldY: Float, newX: Float, newY: Float, duration: Long = 500L, ignoreWait: Boolean = false): Boolean { - // Set up the Path by swiping from the old position coordinates to the new position coordinates. - val swipePath = Path().apply { - moveTo(oldX, oldY) - lineTo(newX, newY) - } - - val gesture = GestureDescription.Builder().apply { - addStroke(GestureDescription.StrokeDescription(swipePath, 0, duration)) - }.build() - - val dispatchResult = dispatchGesture(gesture, null, null) - if (!ignoreWait) { - 0.5.wait() - } - - return dispatchResult - } -} \ No newline at end of file diff --git a/app/src/main/java/com/steve1316/uma_android_automation/utils/NotificationUtils.kt b/app/src/main/java/com/steve1316/uma_android_automation/utils/NotificationUtils.kt deleted file mode 100644 index 34cf6b31..00000000 --- a/app/src/main/java/com/steve1316/uma_android_automation/utils/NotificationUtils.kt +++ /dev/null @@ -1,165 +0,0 @@ -package com.steve1316.uma_android_automation.utils - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.os.Build -import androidx.core.app.NotificationCompat -import com.steve1316.uma_android_automation.MainActivity -import com.steve1316.uma_android_automation.R - -/** - * Contains the utility functions for creating a Notification. - * - * Source is from https://github.com/mtsahakis/MediaProjectionDemo where the Java code was converted to Kotlin and additional logic was added to - * suit this application's purposes. - */ -class NotificationUtils { - companion object { - private lateinit var notificationManager: NotificationManager - private const val NOTIFICATION_ID: Int = 1 - private const val CHANNEL_ID: String = "STATUS" - private const val CONTENT_TITLE = "Status" - - /** - * Creates the NotificationChannel and the Notification object. - * - * @param context The application context. - * @return Pair object containing the Notification object and its ID string. - */ - fun getNewNotification(context: Context): Pair { - // Create the NotificationChannel. - createNewNotificationChannel(context) - - // Create the Notification. - val newNotification = createNewNotification(context) - - // Get the NotificationManager and then send the new Notification to it. - notificationManager.notify(NOTIFICATION_ID, newNotification) - - return Pair(newNotification, NOTIFICATION_ID) - } - - /** - * Create a new NotificationChannel. - * - * https://developer.android.com/training/notify-user/channels - * - * @param context The application context. - */ - private fun createNewNotificationChannel(context: Context) { - notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - // Create the NotificationChannel. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channelName = context.getString(R.string.app_name) - val mChannel = NotificationChannel(CHANNEL_ID, channelName, NotificationManager.IMPORTANCE_HIGH) - mChannel.description = "Displays status of $channelName, whether it is running or not." - - // Register the channel with the system; you can't change the importance or other notification behaviors after this. - notificationManager.createNotificationChannel(mChannel) - } - } - - /** - * Create a new Notification. - * - * @param context The application context. - * @return A new Notification object. - */ - private fun createNewNotification(context: Context): Notification { - // Create a PendingIntent to send the user back to the application if they tap the notification itself. - val contentIntent = Intent(context, MainActivity::class.java) - val contentPendingIntent = PendingIntent.getActivity(context, NOTIFICATION_ID, contentIntent, PendingIntent.FLAG_UPDATE_CURRENT) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - // Create a STOP Intent for the MediaProjection service. - val stopIntent = Intent(context, StopServiceReceiver::class.java) - - // Create a PendingIntent in order to add a action button to stop the MediaProjection service in the notification. - val stopPendingIntent: PendingIntent = PendingIntent.getBroadcast(context, System.currentTimeMillis().toInt(), stopIntent, - PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE) - - return NotificationCompat.Builder(context, CHANNEL_ID).apply { - setSmallIcon(R.drawable.ic_baseline_control_camera_24) - setContentTitle(CONTENT_TITLE) - setContentText("Bot is ready to go.") - setContentIntent(contentPendingIntent) - addAction(R.drawable.stop_circle_filled, context.getString(R.string.stop_process), stopPendingIntent) - priority = NotificationManager.IMPORTANCE_HIGH - setCategory(Notification.CATEGORY_SERVICE) - setOngoing(true) - setShowWhen(true) - }.build() - } else { - return NotificationCompat.Builder(context, CHANNEL_ID).apply { - setSmallIcon(R.drawable.ic_baseline_control_camera_24) - setContentTitle(CONTENT_TITLE) - setContentText("Bot is ready to go.") - setContentIntent(contentPendingIntent) - priority = NotificationManager.IMPORTANCE_HIGH - setCategory(Notification.CATEGORY_SERVICE) - setOngoing(true) - setShowWhen(true) - }.build() - } - } - - /** - * Updates the Notification content text. - * - * @param context The application context. - * @param isRunning Boolean for whether or not the bot process is currently running. - * @param message Optional message to append to the Notification text body. Defaults to empty string. - */ - fun updateNotification(context: Context, isRunning: Boolean, message: String = "") { - var contentText = "Bot process is stopped" - if (message != "") { - contentText = message - } else if (isRunning) { - contentText = "Bot process is running" - } - - // Create a PendingIntent to send the user back to the application if they tap the notification itself. - val contentIntent = Intent(context, MainActivity::class.java) - val contentPendingIntent = PendingIntent.getActivity(context, NOTIFICATION_ID, contentIntent, PendingIntent.FLAG_UPDATE_CURRENT) - - val newNotification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - // Create a STOP Intent for the MediaProjection service. - val stopIntent = Intent(context, StopServiceReceiver::class.java) - - // Create a PendingIntent in order to add a action button to stop the MediaProjection service in the notification. - val stopPendingIntent: PendingIntent = PendingIntent.getBroadcast(context, System.currentTimeMillis().toInt(), stopIntent, - PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE) - - NotificationCompat.Builder(context, CHANNEL_ID).apply { - setSmallIcon(R.drawable.ic_baseline_control_camera_24) - setContentTitle(CONTENT_TITLE) - setContentText(contentText) - setContentIntent(contentPendingIntent) - addAction(R.drawable.stop_circle_filled, context.getString(R.string.accessibility_service_action), stopPendingIntent) - priority = NotificationManager.IMPORTANCE_HIGH - setCategory(Notification.CATEGORY_SERVICE) - setOngoing(true) - setShowWhen(true) - }.build() - } else { - NotificationCompat.Builder(context, CHANNEL_ID).apply { - setSmallIcon(R.drawable.ic_baseline_control_camera_24) - setContentTitle(CONTENT_TITLE) - setContentText(contentText) - setContentIntent(contentPendingIntent) - priority = NotificationManager.IMPORTANCE_HIGH - setCategory(Notification.CATEGORY_SERVICE) - setOngoing(true) - setShowWhen(true) - }.build() - } - - notificationManager.notify(NOTIFICATION_ID, newNotification) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/steve1316/uma_android_automation/utils/SettingsPrinter.kt b/app/src/main/java/com/steve1316/uma_android_automation/utils/SettingsPrinter.kt deleted file mode 100644 index dce1d933..00000000 --- a/app/src/main/java/com/steve1316/uma_android_automation/utils/SettingsPrinter.kt +++ /dev/null @@ -1,219 +0,0 @@ -package com.steve1316.uma_android_automation.utils - -import android.content.Context -import android.content.SharedPreferences -import androidx.preference.PreferenceManager - -/** - * Utility class for printing SharedPreferences settings in a consistent format. - * Can be used by both HomeFragment and Game.kt to display current bot configuration. - */ -object SettingsPrinter { - - /** - * Print all current SharedPreferences settings for debugging purposes. - * - * @param context The application context - * @param printToLog Function to handle logging - */ - fun printCurrentSettings(context: Context, printToLog: ((String) -> Unit)? = null): String { - val sharedPreferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) - - // Main Settings - val campaign: String = sharedPreferences.getString("campaign", "")!! - val enableFarmingFans = sharedPreferences.getBoolean("enableFarmingFans", false) - val daysToRunExtraRaces: Int = sharedPreferences.getInt("daysToRunExtraRaces", 4) - val enableSkillPointCheck: Boolean = sharedPreferences.getBoolean("enableSkillPointCheck", false) - val skillPointCheck: Int = sharedPreferences.getInt("skillPointCheck", 750) - val enablePopupCheck: Boolean = sharedPreferences.getBoolean("enablePopupCheck", false) - val disableRaceRetries: Boolean = sharedPreferences.getBoolean("disableRaceRetries", false) - val enableStopOnMandatoryRace: Boolean = sharedPreferences.getBoolean("enableStopOnMandatoryRace", false) - val enableForceRacing: Boolean = sharedPreferences.getBoolean("enableForceRacing", false) - val enablePrioritizeEnergyOptions: Boolean = sharedPreferences.getBoolean("enablePrioritizeEnergyOptions", false) - - // Training Settings - val trainingBlacklist: Set = sharedPreferences.getStringSet("trainingBlacklist", setOf()) as Set - var statPrioritization: List = sharedPreferences.getString("statPrioritization", "Speed|Stamina|Power|Wit|Guts")!!.split("|") - val maximumFailureChance: Int = sharedPreferences.getInt("maximumFailureChance", 15) - val disableTrainingOnMaxedStat: Boolean = sharedPreferences.getBoolean("disableTrainingOnMaxedStat", true) - val focusOnSparkStatTarget: Boolean = sharedPreferences.getBoolean("focusOnSparkStatTarget", false) - - // Training Stat Targets - val sprintSpeedTarget = sharedPreferences.getInt("trainingSprintStatTarget_speedStatTarget", 900) - val sprintStaminaTarget = sharedPreferences.getInt("trainingSprintStatTarget_staminaStatTarget", 300) - val sprintPowerTarget = sharedPreferences.getInt("trainingSprintStatTarget_powerStatTarget", 600) - val sprintGutsTarget = sharedPreferences.getInt("trainingSprintStatTarget_gutsStatTarget", 300) - val sprintWitTarget = sharedPreferences.getInt("trainingSprintStatTarget_witStatTarget", 300) - - val mileSpeedTarget = sharedPreferences.getInt("trainingMileStatTarget_speedStatTarget", 900) - val mileStaminaTarget = sharedPreferences.getInt("trainingMileStatTarget_staminaStatTarget", 300) - val milePowerTarget = sharedPreferences.getInt("trainingMileStatTarget_powerStatTarget", 600) - val mileGutsTarget = sharedPreferences.getInt("trainingMileStatTarget_gutsStatTarget", 300) - val mileWitTarget = sharedPreferences.getInt("trainingMileStatTarget_witStatTarget", 300) - - val mediumSpeedTarget = sharedPreferences.getInt("trainingMediumStatTarget_speedStatTarget", 800) - val mediumStaminaTarget = sharedPreferences.getInt("trainingMediumStatTarget_staminaStatTarget", 450) - val mediumPowerTarget = sharedPreferences.getInt("trainingMediumStatTarget_powerStatTarget", 550) - val mediumGutsTarget = sharedPreferences.getInt("trainingMediumStatTarget_gutsStatTarget", 300) - val mediumWitTarget = sharedPreferences.getInt("trainingMediumStatTarget_witStatTarget", 300) - - val longSpeedTarget = sharedPreferences.getInt("trainingLongStatTarget_speedStatTarget", 700) - val longStaminaTarget = sharedPreferences.getInt("trainingLongStatTarget_staminaStatTarget", 600) - val longPowerTarget = sharedPreferences.getInt("trainingLongStatTarget_powerStatTarget", 450) - val longGutsTarget = sharedPreferences.getInt("trainingLongStatTarget_gutsStatTarget", 300) - val longWitTarget = sharedPreferences.getInt("trainingLongStatTarget_witStatTarget", 300) - - // Training Event Settings - val character = sharedPreferences.getString("character", "Please select one in the Training Event Settings")!! - val selectAllCharacters = sharedPreferences.getBoolean("selectAllCharacters", true) - val supportList = sharedPreferences.getString("supportList", "")?.split("|")!! - val selectAllSupportCards = sharedPreferences.getBoolean("selectAllSupportCards", true) - - // OCR Optimization Settings - val threshold: Int = sharedPreferences.getInt("threshold", 230) - val enableAutomaticRetry: Boolean = sharedPreferences.getBoolean("enableAutomaticRetry", true) - val ocrConfidence: Int = sharedPreferences.getInt("ocrConfidence", 80) - - // Debug Options - val debugMode: Boolean = sharedPreferences.getBoolean("debugMode", false) - val confidence: Int = sharedPreferences.getInt("confidence", 80) - val customScale: Int = sharedPreferences.getInt("customScale", 100) - val debugModeStartTemplateMatchingTest: Boolean = sharedPreferences.getBoolean("debugMode_startTemplateMatchingTest", false) - val debugModeStartSingleTrainingFailureOCRTest: Boolean = sharedPreferences.getBoolean("debugMode_startSingleTrainingFailureOCRTest", false) - val debugModeStartComprehensiveTrainingFailureOCRTest: Boolean = sharedPreferences.getBoolean("debugMode_startComprehensiveTrainingFailureOCRTest", false) - val hideComparisonResults: Boolean = sharedPreferences.getBoolean("hideComparisonResults", true) - - if (statPrioritization.isEmpty() || statPrioritization[0] == "") { - statPrioritization = listOf("Speed", "Stamina", "Power", "Wit", "Guts") - } - - //////////////////////////////////////////////////////////////////////////////////////////////////// - //////////////////////////////////////////////////////////////////////////////////////////////////// - - // Construct display strings. - val campaignString: String = if (campaign != "") { - "🎯 $campaign" - } else { - "⚠️ Please select one in the Select Campaign option" - } - - val characterString: String = if (selectAllCharacters) { - "👥 All Characters Selected" - } else if (character == "" || character.contains("Please select")) { - "⚠️ Please select one in the Training Event Settings" - } else { - "👤 $character" - } - - val supportCardListString: String = if (selectAllSupportCards) { - "🃏 All Support Cards Selected" - } else if (supportList.isEmpty() || supportList[0] == "") { - "⚠️ None Selected" - } else { - "�� ${supportList.joinToString(", ")}" - } - - val trainingBlacklistString: String = if (trainingBlacklist.isEmpty()) { - "✅ No Trainings blacklisted" - } else { - val defaultTrainingOrder = listOf("Speed", "Stamina", "Power", "Guts", "Wit") - val sortedBlacklist = trainingBlacklist.sortedBy { defaultTrainingOrder.indexOf(it) } - "🚫 ${sortedBlacklist.joinToString(", ")}" - } - - val statPrioritizationString: String = if (statPrioritization.isEmpty() || statPrioritization[0] == "") { - "�� Using Default Stat Prioritization: Speed, Stamina, Power, Guts, Wit" - } else { - "📊 Stat Prioritization: ${statPrioritization.joinToString(", ")}" - } - - val focusOnSparkString: String = if (focusOnSparkStatTarget) { - "✨ Focus on Sparks for Stat Targets: ✅" - } else { - "✨ Focus on Sparks for Stat Targets: ❌" - } - - val sprintTargetsString = "Sprint: \n\t\tSpeed: $sprintSpeedTarget\t\tStamina: $sprintStaminaTarget\t\tPower: $sprintPowerTarget\n\t\tGuts: $sprintGutsTarget\t\t\tWit: $sprintWitTarget" - val mileTargetsString = "Mile: \n\t\tSpeed: $mileSpeedTarget\t\tStamina: $mileStaminaTarget\t\tPower: $milePowerTarget\n\t\tGuts: $mileGutsTarget\t\t\tWit: $mileWitTarget" - val mediumTargetsString = "Medium: \n\t\tSpeed: $mediumSpeedTarget\t\tStamina: $mediumStaminaTarget\t\tPower: $mediumPowerTarget\n\t\tGuts: $mediumGutsTarget\t\t\tWit: $mediumWitTarget" - val longTargetsString = "Long: \n\t\tSpeed: $longSpeedTarget\t\tStamina: $longStaminaTarget\t\tPower: $longPowerTarget\n\t\tGuts: $longGutsTarget\t\t\tWit: $longWitTarget" - - //////////////////////////////////////////////////////////////////////////////////////////////////// - //////////////////////////////////////////////////////////////////////////////////////////////////// - - // Build the settings string. - val settingsString = buildString { - appendLine("Campaign Selected: $campaignString") - appendLine() - appendLine("---------- Training Event Options ----------") - appendLine("Character Selected: $characterString") - appendLine("Support(s) Selected: $supportCardListString") - appendLine() - appendLine("---------- Training Options ----------") - appendLine("Training Blacklist: $trainingBlacklistString") - appendLine(statPrioritizationString) - appendLine("Maximum Failure Chance Allowed: $maximumFailureChance%") - appendLine("Disable Training on Maxed Stat: ${if (disableTrainingOnMaxedStat) "✅" else "❌"}") - appendLine(focusOnSparkString) - appendLine() - appendLine("---------- Training Stat Targets by Distance ----------") - appendLine(sprintTargetsString) - appendLine(mileTargetsString) - appendLine(mediumTargetsString) - appendLine(longTargetsString) - appendLine() - appendLine("---------- Tesseract OCR Optimization ----------") - appendLine("OCR Threshold: $threshold") - appendLine("Enable Automatic OCR retry: ${if (enableAutomaticRetry) "✅" else "❌"}") - appendLine("Minimum OCR Confidence: $ocrConfidence") - appendLine() - appendLine("---------- Racing Options ----------") - appendLine("Prioritize Farming Fans: ${if (enableFarmingFans) "✅" else "❌"}") - appendLine("Modulo Days to Farm Fans: ${if (enableFarmingFans) "📅 $daysToRunExtraRaces days" else "❌"}") - appendLine("Disable Race Retries: ${if (disableRaceRetries) "✅" else "❌"}") - appendLine("Stop on Mandatory Race: ${if (enableStopOnMandatoryRace) "✅" else "❌"}") - appendLine("Force Racing Every Day: ${if (enableForceRacing) "✅" else "❌"}") - appendLine() - appendLine("---------- Misc Options ----------") - appendLine("Skill Point Check: ${if (enableSkillPointCheck) "✅ Stop on $skillPointCheck Skill Points or more" else "❌"}") - appendLine("Popup Check: ${if (enablePopupCheck) "✅" else "❌"}") - appendLine("Prioritize Energy Options: ${if (enablePrioritizeEnergyOptions) "✅" else "❌"}") - appendLine() - appendLine("---------- Debug Options ----------") - appendLine("Debug Mode: ${if (debugMode) "✅" else "❌"}") - appendLine("Minimum Template Match Confidence: $confidence") - appendLine("Custom Scale: ${customScale.toDouble() / 100.0}") - appendLine("Start Template Matching Test: ${if (debugModeStartTemplateMatchingTest) "✅" else "❌"}") - appendLine("Start Single Training Failure OCR Test: ${if (debugModeStartSingleTrainingFailureOCRTest) "✅" else "❌"}") - appendLine("Start Comprehensive Training Failure OCR Test: ${if (debugModeStartComprehensiveTrainingFailureOCRTest) "✅" else "❌"}") - appendLine("Hide String Comparison Results: ${if (hideComparisonResults) "✅" else "❌"}") - } - - //////////////////////////////////////////////////////////////////////////////////////////////////// - //////////////////////////////////////////////////////////////////////////////////////////////////// - - // Use the provided printToLog function if available. Otherwise return the string. - if (printToLog != null) { - printToLog("\n[SETTINGS] Current Bot Configuration:") - printToLog("=====================================") - settingsString.split("\n").forEach { line -> - if (line.isNotEmpty()) { - printToLog(line) - } - } - printToLog("=====================================\n") - } - - return settingsString - } - - /** - * Get the formatted settings string for display in UI components. - * - * @param context The application context - * @return Formatted string containing all current settings - */ - fun getSettingsString(context: Context): String { - return printCurrentSettings(context) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/steve1316/uma_android_automation/utils/StopServiceReceiver.kt b/app/src/main/java/com/steve1316/uma_android_automation/utils/StopServiceReceiver.kt deleted file mode 100644 index ec3372ad..00000000 --- a/app/src/main/java/com/steve1316/uma_android_automation/utils/StopServiceReceiver.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.steve1316.uma_android_automation.utils - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent - -/** - * Receives the Intent to stop the MediaProjection Service via the Notification action button. - * - * Source is from https://stackoverflow.com/questions/41359337/android-notification-pendingintent-to-stop-service - */ -class StopServiceReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent?) { - val service = Intent(context, MediaProjectionService::class.java) - context.stopService(service) - } -} \ No newline at end of file diff --git a/app/src/main/res/anim/play_button_animation.xml b/app/src/main/res/anim/play_button_animation.xml deleted file mode 100644 index 33faa3a0..00000000 --- a/app/src/main/res/anim/play_button_animation.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/anim/play_button_animation_alt.xml b/app/src/main/res/anim/play_button_animation_alt.xml deleted file mode 100644 index d529115e..00000000 --- a/app/src/main/res/anim/play_button_animation_alt.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/anim/stop_button_animation.xml b/app/src/main/res/anim/stop_button_animation.xml deleted file mode 100644 index 7d647844..00000000 --- a/app/src/main/res/anim/stop_button_animation.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_baseline_control_camera_24.xml b/app/src/main/res/drawable/ic_baseline_control_camera_24.xml deleted file mode 100644 index a04059e0..00000000 --- a/app/src/main/res/drawable/ic_baseline_control_camera_24.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_baseline_error_outline_24.xml b/app/src/main/res/drawable/ic_baseline_error_outline_24.xml deleted file mode 100644 index adc3b698..00000000 --- a/app/src/main/res/drawable/ic_baseline_error_outline_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_home_24.xml b/app/src/main/res/drawable/ic_baseline_home_24.xml deleted file mode 100644 index 3a4c7dac..00000000 --- a/app/src/main/res/drawable/ic_baseline_home_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_settings_24.xml b/app/src/main/res/drawable/ic_baseline_settings_24.xml deleted file mode 100644 index 41a82ede..00000000 --- a/app/src/main/res/drawable/ic_baseline_settings_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_github_icon.xml b/app/src/main/res/drawable/ic_github_icon.xml deleted file mode 100644 index e835f8cc..00000000 --- a/app/src/main/res/drawable/ic_github_icon.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml deleted file mode 100644 index e475d768..00000000 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/play_circle_filled.xml b/app/src/main/res/drawable/play_circle_filled.xml deleted file mode 100644 index 7bafe72b..00000000 --- a/app/src/main/res/drawable/play_circle_filled.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/side_nav_bar.xml b/app/src/main/res/drawable/side_nav_bar.xml deleted file mode 100644 index 6d81870b..00000000 --- a/app/src/main/res/drawable/side_nav_bar.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/stop_circle_filled.xml b/app/src/main/res/drawable/stop_circle_filled.xml deleted file mode 100644 index 9a52d631..00000000 --- a/app/src/main/res/drawable/stop_circle_filled.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index 751a5c60..00000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/app_bar_main.xml b/app/src/main/res/layout/app_bar_main.xml deleted file mode 100644 index 450b3999..00000000 --- a/app/src/main/res/layout/app_bar_main.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/bot_actions.xml b/app/src/main/res/layout/bot_actions.xml deleted file mode 100644 index 6a93e093..00000000 --- a/app/src/main/res/layout/bot_actions.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/content_main.xml b/app/src/main/res/layout/content_main.xml deleted file mode 100644 index 04b67fd5..00000000 --- a/app/src/main/res/layout/content_main.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml deleted file mode 100644 index 1b7af248..00000000 --- a/app/src/main/res/layout/fragment_home.xml +++ /dev/null @@ -1,61 +0,0 @@ - - - -