Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
92102b6
Refactor database.ts and add more docstrings
steve1316 Nov 24, 2025
4d5e88f
Set the background color of the Snackbar in the Training Settings pag…
steve1316 Nov 24, 2025
85017b2
Apply formatting
steve1316 Nov 24, 2025
8c0ea87
Fix bug where mood recovery could not proceed if dating was already c…
steve1316 Nov 24, 2025
a8dc6a5
Specify POST_NOTIFICATIONS permission in AndroidManifest.xml
steve1316 Nov 24, 2025
0deeb2f
Add two new commands to the package.json file for quicker testing of …
steve1316 Nov 24, 2025
4e14223
Add a new option to the Training Settings page to allow the user to s…
steve1316 Nov 24, 2025
3cbc6e3
Update the manual stat cap to be used in the Training class
steve1316 Nov 24, 2025
5d0314d
Add a new option to the Training Settings page to allow the user to t…
steve1316 Nov 24, 2025
6b76a5f
Update the Training class to force Wit training during the Finale ins…
steve1316 Nov 24, 2025
60dbf7c
Add a new option in the Misc section of the Settings page to stop the…
steve1316 Nov 24, 2025
e9e2723
Implement logic to stop the bot before the Finals on turn 72 if the s…
steve1316 Nov 24, 2025
59304db
Update the introMessage with the app name and version states in the d…
steve1316 Nov 24, 2025
3384bf0
Add image asset for scenario validation
steve1316 Nov 24, 2025
e128c5c
Add logic to perform one-time scenario validation check
steve1316 Nov 24, 2025
c76662d
Add new settings to the MessageLog settings string
steve1316 Nov 25, 2025
a890b75
Fix bug where "Finale Underway" was not being parsed correctly by Gam…
steve1316 Nov 25, 2025
b828142
Shift priority weight for event chains for TrainingEvent class
steve1316 Nov 25, 2025
eaa411f
Fix bug where risky training was not being started when the initial f…
steve1316 Nov 25, 2025
df78366
Update updateDate() and checkFinals() to better detect the Finals in-…
steve1316 Nov 25, 2025
2362e2b
Update game data up to Agnes Digital
steve1316 Nov 25, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

<!--Grants permission to post notifications.-->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<!--Grants permission to download and update the application's Firebase Machine Learning OCR.-->
<uses-permission android:name="android.permission.INTERNET" />

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ open class Campaign(val game: Game) {
////////////////////////////////////////////////
// Most bot operations start at the Main screen.
if (game.checkMainScreen()) {
// Perform scenario validation check.
if (!game.validateScenario()) {
MessageLog.i(TAG, "\n[END] Stopping bot due to scenario validation failure.")
break
}

// Check if bot should stop before the finals.
if (game.checkFinalsStop()) {
MessageLog.i(TAG, "\n[END] Stopping bot before the finals.")
break
}

var needToRace = false
if (!game.racing.encounteredRacingPopup) {
// Refresh the stat values in memory.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ class Game(val myContext: Context) {
val skillPointsRequired: Int = SettingsHelper.getIntSetting("general", "skillPointCheck")
private val enablePopupCheck: Boolean = SettingsHelper.getBooleanSetting("general", "enablePopupCheck")
private val enableCraneGameAttempt: Boolean = SettingsHelper.getBooleanSetting("general", "enableCraneGameAttempt")
private val enableStopBeforeFinals: Boolean = SettingsHelper.getBooleanSetting("general", "enableStopBeforeFinals")

////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////
Expand All @@ -67,6 +68,9 @@ class Game(val myContext: Context) {
private var inheritancesDone = 0
private var needToUpdateAptitudes: Boolean = true
private var recreationDateCompleted: Boolean = false
private var isFinals: Boolean = false
private var stopBeforeFinalsInitialTurnNumber: Int = -1
private var scenarioCheckPerformed: Boolean = false

data class Date(
val year: Int,
Expand Down Expand Up @@ -297,8 +301,7 @@ class Game(val myContext: Context) {
fun startDateOCRTest() {
MessageLog.i(TAG, "\n[TEST] Now beginning the Date OCR test on the Main screen.")
MessageLog.i(TAG, "[TEST] Note that this test is dependent on having the correct scale.")
val finalsLocation = imageUtils.findImage("race_select_extra_locked_uma_finals", tries = 1, suppressError = true, region = imageUtils.regionBottomHalf).first
updateDate(isFinals = (finalsLocation != null))
updateDate()
}

fun startAptitudesDetectionTest() {
Expand All @@ -312,6 +315,35 @@ class Game(val myContext: Context) {
////////////////////////////////////////////////////////////////////////////////////////////////////
// Helper functions to check what screen the bot is at.

/**
* Validates that the game's current scenario matches the selected scenario in the app.
* This check only runs once per bot session.
*
* @return True if validation passes and bot should continue. False if scenario mismatch detected and bot should stop.
*/
fun validateScenario(): Boolean {
if (scenarioCheckPerformed) {
return true
}

if (imageUtils.findImage("unitycup_date_text", tries = 1, region = imageUtils.regionTopHalf, suppressError = true).first != null) {
// Unity Cup image was detected, so the game is on Unity Cup scenario.
if (scenario != "Unity Cup") {
MessageLog.e(TAG, "\n[ERROR] Scenario mismatch detected: Game is on Unity Cup but app is configured for $scenario. Stopping bot to prevent confusion.")
notificationMessage = "Scenario mismatch detected: Game is on Unity Cup but app is configured for $scenario. Please select the correct scenario in the app settings."
scenarioCheckPerformed = true
return false
} else {
MessageLog.i(TAG, "[INFO] Scenario validation confirmed for Unity Cup.")
}
} else {
// Unity Cup image was not detected, so the game is on URA Finale scenario.
MessageLog.i(TAG, "[INFO] Scenario validation confirmed for URA Finale.")
}
scenarioCheckPerformed = true
return true
}

/**
* 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.
Expand All @@ -327,8 +359,7 @@ class Game(val myContext: Context) {
MessageLog.i(TAG, "Bot is at the Main screen.")

// Perform updates here if necessary.
val finalsLocation = imageUtils.findImageWithBitmap("race_select_extra_locked_uma_finals", sourceBitmap, suppressError = true, region = imageUtils.regionBottomHalf)
updateDate(isFinals = (finalsLocation != null))
updateDate()
if (needToUpdateAptitudes) updateAptitudes()
true
} else if (!enablePopupCheck && imageUtils.findImageWithBitmap("cancel", sourceBitmap, region = imageUtils.regionBottomHalf) != null &&
Expand Down Expand Up @@ -427,15 +458,56 @@ class Game(val myContext: Context) {
*/
fun checkFinals(): Boolean {
MessageLog.i(TAG, "\nChecking 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) {
MessageLog.i(TAG, "It is currently the Finals.")
updateDate(isFinals = true)
true
} else {
MessageLog.i(TAG, "It is not the Finals yet.")
false
if (isFinals) {
return true
} else if (currentDate.turnNumber < 72) {
MessageLog.i(TAG, "It is not the Finals yet as the turn number is less than 72.")
return false
} else {
return if (
imageUtils.findImage("date_final_qualifier", tries = 1, suppressError = true, region = imageUtils.regionTopHalf).first != null ||
imageUtils.findImage("date_final_semifinal", tries = 1, suppressError = true, region = imageUtils.regionTopHalf).first != null ||
imageUtils.findImage("date_final_finals", tries = 1, suppressError = true, region = imageUtils.regionTopHalf).first != null
) {
MessageLog.i(TAG, "It is currently the Finals.")
isFinals = true
true
} else {
MessageLog.i(TAG, "It is not the Finals yet as the date images for the Finals were not detected.")
false
}
}
}

/**
* Checks if the bot should stop before the finals on turn 72.
*
* @return True if the bot should stop. Otherwise false.
*/
fun checkFinalsStop(): Boolean {
if (!enableStopBeforeFinals) {
return false
} else if (currentDate.turnNumber > 72) {
// If already past turn 72, skip the check to prevent re-checking.
return false
}

MessageLog.i(TAG, "\n[FINALS] Checking if bot should stop before the finals.")
val sourceBitmap = imageUtils.getSourceBitmap()

// Check if turn is 72, but only stop if we progressed to turn 72 during this run.
if (currentDate.turnNumber == 72 && stopBeforeFinalsInitialTurnNumber != -1) {
MessageLog.i(TAG, "[FINALS] Detected turn 72. Stopping bot before the finals.")
notificationMessage = "Stopping bot before the finals on turn 72."
return true
}

// Track initial turn number on first check to avoid stopping if bot starts on turn 72.
if (stopBeforeFinalsInitialTurnNumber == -1) {
stopBeforeFinalsInitialTurnNumber = currentDate.turnNumber
}

return false
}

/**
Expand Down Expand Up @@ -529,12 +601,10 @@ class Game(val myContext: Context) {

/**
* Updates the stored date in memory by keeping track of the current year, phase, month and current turn number.
*
* @param isFinals If true, checks for Finals date images instead of parsing a date string. Defaults to false.
*/
fun updateDate(isFinals: Boolean = false) {
fun updateDate() {
MessageLog.i(TAG, "\n[DATE] Updating the current date.")
if (isFinals) {
if (checkFinals()) {
// During Finals, check for Finals-specific date images.
// The Finals occur at turns 73, 74, and 75.
// Date will be kept at Senior Year Late Dec, only the turn number will be updated.
Expand Down Expand Up @@ -661,12 +731,16 @@ class Game(val myContext: Context) {
// Do the date if it is unlocked.
if (!handleRecreationDate(recoverMoodIfCompleted = true)) {
// Otherwise, recover mood as normal.
findAndTapImage("cancel", tries = 1, region = imageUtils.regionBottomHalf, suppressError = true)
wait(1.0)
if (!findAndTapImage("recover_mood", sourceBitmap, tries = 1, region = imageUtils.regionBottomHalf, suppressError = true)) {
findAndTapImage("recover_energy_summer", sourceBitmap, tries = 1, region = imageUtils.regionBottomHalf, suppressError = true)
} else if (imageUtils.findImage("recreation_umamusume", region = imageUtils.regionMiddle, suppressError = true).first != null) {
// At this point, the button was already pressed and the Recreation popup is now open.
MessageLog.i(TAG, "[MOOD] Recreation date is already completed. Recovering mood with the Umamusume now...")
findAndTapImage("recreation_umamusume", region = imageUtils.regionMiddle)
} else {
// Otherwise, dismiss the popup that says to confirm recreation if the user has not set it to skip the confirmation in their in-game settings.
findAndTapImage("ok", region = imageUtils.regionMiddle, suppressError = true)
}
findAndTapImage("ok", region = imageUtils.regionMiddle, suppressError = true)
}

racing.raceRepeatWarningCheck = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -768,7 +768,7 @@ class Racing (private val game: Game) {

// Check for common restrictions that apply to both smart and standard racing via screen checks.
val sourceBitmap = game.imageUtils.getSourceBitmap()
if (game.imageUtils.findImageWithBitmap("race_select_extra_locked_uma_finals", sourceBitmap, region = game.imageUtils.regionBottomHalf) != null) {
if (game.checkFinals()) {
MessageLog.i(TAG, "[RACE] It is UMA Finals right now so there will be no extra races. Stopping extra race check.")
return false
} else if (game.imageUtils.findImageWithBitmap("race_select_extra_locked", sourceBitmap, region = game.imageUtils.regionBottomHalf) != null) {
Expand Down Expand Up @@ -935,7 +935,7 @@ class Racing (private val game: Game) {
private fun selectRaceStrategy() {
if (!enableRaceStrategyOverride) {
return
} else if (!firstTimeRacing && !hasAppliedStrategyOverride && game.currentDate.year != 1) {
} else if ((!firstTimeRacing && !hasAppliedStrategyOverride && game.currentDate.year != 1) || game.checkFinals()) {
return
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ class Training(private val game: Game) {
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 trainWitDuringFinale: Boolean = SettingsHelper.getBooleanSetting("training", "trainWitDuringFinale")
private val manualStatCap: Int = SettingsHelper.getIntSetting("training", "manualStatCap")
private val statTargetsByDistance: MutableMap<String, IntArray> = mutableMapOf(
"Sprint" to intArrayOf(0, 0, 0, 0, 0),
"Mile" to intArrayOf(0, 0, 0, 0, 0),
Expand All @@ -105,7 +107,8 @@ class Training(private val game: Game) {
)
var preferredDistance: String = ""
var firstTrainingCheck = true
private val currentStatCap = 1200
private val currentStatCap: Int
get() = if (disableTrainingOnMaxedStat) manualStatCap else 1200

////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////
Expand Down Expand Up @@ -233,15 +236,34 @@ class Training(private val game: Game) {
analyzeTrainings()

if (trainingMap.isEmpty()) {
MessageLog.i(TAG, "[TRAINING] Backing out of Training and returning on the Main screen.")
game.findAndTapImage("back", region = game.imageUtils.regionBottomHalf)
game.wait(1.0)

if (game.checkMainScreen()) {
MessageLog.i(TAG, "[TRAINING] Will recover energy due to either failure chance was high enough to do so or no failure chances were detected via OCR.")
game.recoverEnergy()
// Check if we should force Wit training during the Finale instead of recovering energy.
if (trainWitDuringFinale && game.currentDate.turnNumber in 73..75) {
MessageLog.i(TAG, "[TRAINING] There is not enough energy for training to be done but the setting to train Wit during the Finale is enabled. Forcing Wit training...")
// Directly attempt to tap Wit training.
if (game.findAndTapImage("training_wit", region = game.imageUtils.regionBottomHalf, taps = 3)) {
MessageLog.i(TAG, "[TRAINING] Successfully forced Wit training during the Finale instead of recovering energy.")
firstTrainingCheck = false
} else {
MessageLog.w(TAG, "[WARNING] Could not find Wit training button. Falling back to recovering energy...")
game.findAndTapImage("back", region = game.imageUtils.regionBottomHalf)
game.wait(1.0)
if (game.checkMainScreen()) {
game.recoverEnergy()
} else {
MessageLog.w(TAG, "[WARNING] Could not head back to the Main screen in order to recover energy.")
}
}
} else {
MessageLog.i(TAG, "[ERROR] Could not head back to the Main screen in order to recover energy.")
MessageLog.i(TAG, "[TRAINING] Backing out of Training and returning on the Main screen.")
game.findAndTapImage("back", region = game.imageUtils.regionBottomHalf)
game.wait(1.0)

if (game.checkMainScreen()) {
MessageLog.i(TAG, "[TRAINING] Will recover energy due to either failure chance was high enough to do so or no failure chances were detected via OCR.")
game.recoverEnergy()
} else {
MessageLog.w(TAG, "[WARNING] Could not head back to the Main screen in order to recover energy.")
}
}
} else {
// Now select the training option with the highest weight.
Expand Down Expand Up @@ -283,8 +305,18 @@ class Training(private val game: Game) {
return
}

if (test || failureChance <= maximumFailureChance) {
if (!test) MessageLog.i(TAG, "[TRAINING] $failureChance% within acceptable range of ${maximumFailureChance}%. Proceeding to acquire all other percentages and total stat increases...")
// Check if failure chance is acceptable: either within regular threshold or within risky threshold (if enabled).
val isWithinRegularThreshold = failureChance <= maximumFailureChance
val isWithinRiskyThreshold = enableRiskyTraining && failureChance <= riskyTrainingMaxFailureChance

if (test || isWithinRegularThreshold || isWithinRiskyThreshold) {
if (!test) {
if (isWithinRegularThreshold) {
MessageLog.i(TAG, "[TRAINING] $failureChance% within acceptable range of ${maximumFailureChance}%. Proceeding to acquire all other percentages and total stat increases...")
} else if (isWithinRiskyThreshold) {
MessageLog.i(TAG, "[TRAINING] $failureChance% exceeds regular threshold (${maximumFailureChance}%) but is within risky training threshold (${riskyTrainingMaxFailureChance}%). Proceeding to acquire all other percentages and total stat increases...")
}
}

// List to store all training analysis results for parallel processing.
val analysisResults = mutableListOf<TrainingAnalysisResult>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,15 @@ class TrainingEvent(private val game: Game) {
if (line.lowercase().contains("can start dating")) {
MessageLog.i(TAG, "[TRAINING_EVENT] Adding weight for option #${optionSelected + 1} of 100 to unlock recreation/dating for this support.")
selectionWeight[optionSelected] += 100
} else if (line.lowercase().contains("event chain ended")) {
MessageLog.i(TAG, "[TRAINING_EVENT] Adding weight for option #${optionSelected + 1} of -200 for event chain ending.")
selectionWeight[optionSelected] += -300
} else if (line.lowercase().contains("(random)")) {
MessageLog.i(TAG, "[TRAINING_EVENT] Adding weight for option #${optionSelected + 1} of -10 for random reward.")
selectionWeight[optionSelected] += -10
} else if (line.lowercase().contains("randomly")) {
MessageLog.i(TAG, "[TRAINING_EVENT] Adding weight for option #${optionSelected + 1} of 50 for random options.")
selectionWeight[optionSelected] += 50
} else if (line.lowercase().contains("energy")) {
val finalEnergyValue = try {
val energyValue = if (formattedLine.contains("/")) {
Expand Down Expand Up @@ -311,15 +320,6 @@ class TrainingEvent(private val game: Game) {
} else if (line.lowercase().contains("bond")) {
MessageLog.i(TAG, "[TRAINING_EVENT] Adding weight for option #${optionSelected + 1} of 20 for bond.")
selectionWeight[optionSelected] += 20
} else if (line.lowercase().contains("event chain ended")) {
MessageLog.i(TAG, "[TRAINING_EVENT] Adding weight for option #${optionSelected + 1} of -200 for event chain ending.")
selectionWeight[optionSelected] += -200
} else if (line.lowercase().contains("(random)")) {
MessageLog.i(TAG, "[TRAINING_EVENT] Adding weight for option #${optionSelected + 1} of -10 for random reward.")
selectionWeight[optionSelected] += -10
} else if (line.lowercase().contains("randomly")) {
MessageLog.i(TAG, "[TRAINING_EVENT] Adding weight for option #${optionSelected + 1} of 50 for random options.")
selectionWeight[optionSelected] += 50
} else if (line.lowercase().contains("hint")) {
MessageLog.i(TAG, "[TRAINING_EVENT] Adding weight for option #${optionSelected + 1} of 25 for skill hint(s).")
selectionWeight[optionSelected] += 25
Expand Down
Loading