From 92102b6f58f9d2f5863fe67004b59d3d876a29aa Mon Sep 17 00:00:00 2001 From: steve1316 Date: Sun, 23 Nov 2025 16:51:17 -0800 Subject: [PATCH 01/21] Refactor database.ts and add more docstrings --- src/lib/database.ts | 498 ++++++++++++++++++++------------------------ 1 file changed, 226 insertions(+), 272 deletions(-) diff --git a/src/lib/database.ts b/src/lib/database.ts index 8a895433..0e6230fa 100644 --- a/src/lib/database.ts +++ b/src/lib/database.ts @@ -41,14 +41,65 @@ export interface DatabaseProfile { * Stores settings as key-value pairs organized by category for efficient querying. */ export class DatabaseManager { + private DATABASE_NAME = "settings.db" + private STRING_ONLY_SETTINGS = ["racingPlan", "racingPlanData"] + private TABLE_SETTINGS = "settings" + private TABLE_RACES = "races" + private TABLE_PROFILES = "profiles" + private db: SQLite.SQLiteDatabase | null = null private isInitializing = false private initializationPromise: Promise | null = null private isTransactionActive = false private transactionQueue: Array<() => Promise> = [] + /** + * Serialize a value to a string for storage. + * + * @param value - The value to serialize. + * @returns The serialized string value. + */ + private serializeValue(value: any): string { + return typeof value === "string" ? value : JSON.stringify(value) + } + + /** + * Deserialize a value from a string, handling string-only settings. + * + * @param key - The setting key to check if it should remain as a string. + * @param value - The string value to deserialize. + * @returns The deserialized value. + */ + private deserializeValue(key: string, value: string): any { + if (this.STRING_ONLY_SETTINGS.includes(key)) { + return value + } + try { + return JSON.parse(value) + } catch { + return value + } + } + + // ============================================================================ + // Initialization and Migration Methods + // ============================================================================ + + /** + * Ensure the database is initialized, throwing an error if not. + * + * @throws Error if database is not initialized. + */ + private ensureInitialized(): void { + if (!this.db) { + throw new Error("Database not initialized") + } + } + /** * Initialize the database and create tables if they don't exist. + * + * @returns A promise that resolves when the database is initialized. */ async initialize(): Promise { const endTiming = startTiming("database_initialize", "database") @@ -79,10 +130,15 @@ export class DatabaseManager { } } + /** + * Perform the database initialization. + * + * @returns A promise that resolves when the database is initialized. + */ private async _performInitialization(): Promise { try { logWithTimestamp("Starting database initialization...") - this.db = await SQLite.openDatabaseAsync("settings.db", { + this.db = await SQLite.openDatabaseAsync(this.DATABASE_NAME, { useNewConnection: true, }) logWithTimestamp("Database opened successfully") @@ -94,7 +150,7 @@ export class DatabaseManager { // Create settings table. logWithTimestamp("Creating settings table...") await this.db.execAsync(` - CREATE TABLE IF NOT EXISTS settings ( + CREATE TABLE IF NOT EXISTS ${this.TABLE_SETTINGS} ( id INTEGER PRIMARY KEY AUTOINCREMENT, category TEXT NOT NULL, key TEXT NOT NULL, @@ -108,7 +164,7 @@ export class DatabaseManager { // Create races table. logWithTimestamp("Creating races table...") await this.db.execAsync(` - CREATE TABLE IF NOT EXISTS races ( + CREATE TABLE IF NOT EXISTS ${this.TABLE_RACES} ( id INTEGER PRIMARY KEY AUTOINCREMENT, key TEXT UNIQUE NOT NULL, name TEXT NOT NULL, @@ -130,7 +186,7 @@ export class DatabaseManager { // Create profiles table. logWithTimestamp("Creating profiles table...") await this.db.execAsync(` - CREATE TABLE IF NOT EXISTS profiles ( + CREATE TABLE IF NOT EXISTS ${this.TABLE_PROFILES} ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, settings TEXT NOT NULL, @@ -140,26 +196,26 @@ export class DatabaseManager { `) logWithTimestamp("Profiles table created successfully.") - // Migrate existing profiles from old format (training_settings + trainingStatTarget_settings) to new format (settings JSON). + // Migrate existing profiles from old to new schema. await this.migrateProfilesSchema() // Create indexes for faster queries. logWithTimestamp("Creating indexes...") await this.db.execAsync(` CREATE INDEX IF NOT EXISTS idx_settings_category_key - ON settings(category, key) + ON ${this.TABLE_SETTINGS}(category, key) `) await this.db.execAsync(` CREATE INDEX IF NOT EXISTS idx_races_turn_number - ON races(turnNumber) + ON ${this.TABLE_RACES}(turnNumber) `) await this.db.execAsync(` CREATE INDEX IF NOT EXISTS idx_races_name_formatted - ON races(nameFormatted) + ON ${this.TABLE_RACES}(nameFormatted) `) await this.db.execAsync(` CREATE INDEX IF NOT EXISTS idx_profiles_name - ON profiles(name) + ON ${this.TABLE_PROFILES}(name) `) logWithTimestamp("Indexes created successfully.") @@ -182,7 +238,7 @@ export class DatabaseManager { } try { - const tableInfo = await this.db.getAllAsync<{ name: string; type: string }>("PRAGMA table_info(profiles)") + const tableInfo = await this.db.getAllAsync<{ name: string; type: string }>(`PRAGMA table_info(${this.TABLE_PROFILES})`) const hasTrainingSettings = tableInfo.some((col) => col.name === "training_settings") const hasTrainingStatTarget = tableInfo.some((col) => col.name === "trainingStatTarget_settings") const hasSettings = tableInfo.some((col) => col.name === "settings") @@ -192,92 +248,119 @@ export class DatabaseManager { logWithTimestamp("[DB] Migrating profiles table to new settings format...") // Create new table with settings column. - await this.db.execAsync(` - CREATE TABLE IF NOT EXISTS profiles_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT UNIQUE NOT NULL, - settings TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `) + await this.createProfilesMigrationTable() // Migrate existing data: combine training and training stat target settings into a single settings JSON. - if (hasTrainingSettings && hasTrainingStatTarget) { - // Both columns exist - combine them into settings JSON. - await this.db.execAsync(` - INSERT INTO profiles_new (id, name, settings, created_at, updated_at) - SELECT - id, - name, - json_object('training', json(training_settings), 'trainingStatTarget', json(trainingStatTarget_settings)) as settings, - created_at, - updated_at - FROM profiles - `) - } else if (hasTrainingSettings) { - // Only training settings exist - create settings with just training settings. - await this.db.execAsync(` - INSERT INTO profiles_new (id, name, settings, created_at, updated_at) - SELECT - id, - name, - json_object('training', json(training_settings)) as settings, - created_at, - updated_at - FROM profiles - `) - } else if (hasTrainingStatTarget) { - // Only training stat target settings exist - create settings with just training stat target settings. - await this.db.execAsync(` - INSERT INTO profiles_new (id, name, settings, created_at, updated_at) - SELECT - id, - name, - json_object('trainingStatTarget', json(trainingStatTarget_settings)) as settings, - created_at, - updated_at - FROM profiles - `) - } - - // Drop old table and rename new one. - await this.db.execAsync("DROP TABLE profiles") - await this.db.execAsync("ALTER TABLE profiles_new RENAME TO profiles") + await this.migrateProfilesData(hasTrainingSettings, hasTrainingStatTarget) - // Recreate index. - await this.db.execAsync(` - CREATE INDEX IF NOT EXISTS idx_profiles_name - ON profiles(name) - `) + // Complete the migration by replacing the old table. + await this.completeProfilesMigration() logWithTimestamp("[DB] Successfully migrated profiles table to new settings format.") } } catch (error) { + // Allow app to continue even if migration fails. logErrorWithTimestamp("[DB] Failed to migrate profiles table:", error) - // Don't throw - allow app to continue even if migration fails. } } + /** + * Create the new profiles table for migration. + * + * @returns A promise that resolves when the new profiles table is created. + */ + private async createProfilesMigrationTable(): Promise { + if (!this.db) { + return + } + await this.db.execAsync(` + CREATE TABLE IF NOT EXISTS ${this.TABLE_PROFILES}_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + settings TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `) + } + + /** + * Migrate profiles data from old schema to new schema. + * + * @param hasTrainingSettings - Whether the training_settings column exists. + * @param hasTrainingStatTarget - Whether the trainingStatTarget_settings column exists. + * @returns A promise that resolves when the profiles are migrated. + */ + private async migrateProfilesData(hasTrainingSettings: boolean, hasTrainingStatTarget: boolean): Promise { + if (!this.db) { + return + } + + // Build json_object() dynamically based on which columns exist. + const jsonObjectParts: string[] = [] + if (hasTrainingSettings) { + jsonObjectParts.push("'training', json(training_settings)") + } + if (hasTrainingStatTarget) { + jsonObjectParts.push("'trainingStatTarget', json(trainingStatTarget_settings)") + } + + const jsonObjectSql = `json_object(${jsonObjectParts.join(", ")})` + + await this.db.execAsync(` + INSERT INTO ${this.TABLE_PROFILES}_new (id, name, settings, created_at, updated_at) + SELECT + id, + name, + ${jsonObjectSql} as settings, + created_at, + updated_at + FROM ${this.TABLE_PROFILES} + `) + } + + /** + * Complete the profiles migration by replacing the old table with the new one. + * + * @returns A promise that resolves when the migration is completed. + */ + private async completeProfilesMigration(): Promise { + if (!this.db) { + return + } + await this.db.execAsync(`DROP TABLE ${this.TABLE_PROFILES}`) + await this.db.execAsync(`ALTER TABLE ${this.TABLE_PROFILES}_new RENAME TO ${this.TABLE_PROFILES}`) + await this.db.execAsync(` + CREATE INDEX IF NOT EXISTS idx_profiles_name + ON ${this.TABLE_PROFILES}(name) + `) + } + + // ============================================================================ + // Settings Methods + // ============================================================================ + /** * Save settings to database by category and key. + * + * @param category - The category of the setting to save. + * @param key - The key of the setting to save. + * @param value - The value of the setting to save. + * @param suppressLogging - Whether to suppress logging of the setting being saved. + * @returns A promise that resolves when the setting is saved. */ async saveSetting(category: string, key: string, value: any, suppressLogging: boolean = false): Promise { const endTiming = startTiming("database_save_setting", "database") - if (!this.db) { - logErrorWithTimestamp("Database is null when trying to save setting.") - endTiming({ status: "error", error: "database_not_initialized" }) - throw new Error("Database not initialized") - } + this.ensureInitialized() try { - const valueString = typeof value === "string" ? value : JSON.stringify(value) + const valueString = this.serializeValue(value) if (!suppressLogging) { logWithTimestamp(`[DB] Saving setting: ${category}.${key} = ${valueString.substring(0, 100)}...`) } - await this.db.runAsync( - `INSERT OR REPLACE INTO settings (category, key, value, updated_at) + await this.db!.runAsync( + `INSERT OR REPLACE INTO ${this.TABLE_SETTINGS} (category, key, value, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)`, [category, key, valueString] ) @@ -292,10 +375,18 @@ export class DatabaseManager { } } + // ============================================================================ + // Utility Methods + // ============================================================================ + /** - * Execute a database operation with proper transaction management to prevent nested transactions. + * Execute a database operation with queue management to prevent concurrent operations. + * Note: This is a queue system, not SQLite transactions. SQLite transactions are handled within the operations. + * + * @param operation - The operation to execute. + * @returns A promise that resolves when the operation is executed. */ - private async executeWithTransaction(operation: () => Promise): Promise { + private async executeWithQueue(operation: () => Promise): Promise { return new Promise((resolve, reject) => { const executeOperation = async () => { if (this.isTransactionActive) { @@ -333,15 +424,14 @@ export class DatabaseManager { /** * Save multiple settings in a single transaction for better performance. + * + * @param settings - The settings to save. + * @returns A promise that resolves when the settings are saved. */ async saveSettingsBatch(settings: Array<{ category: string; key: string; value: any }>): Promise { const endTiming = startTiming("database_save_settings_batch", "database") - if (!this.db) { - logErrorWithTimestamp("Database is null when trying to save settings batch.") - endTiming({ status: "error", error: "database_not_initialized" }) - throw new Error("Database not initialized") - } + this.ensureInitialized() if (settings.length === 0) { endTiming({ status: "skipped", reason: "no_settings" }) @@ -349,18 +439,18 @@ export class DatabaseManager { } try { - await this.executeWithTransaction(async () => { + await this.executeWithQueue(async () => { logWithTimestamp(`[DB] Saving ${settings.length} settings in batch.`) await this.db!.runAsync("BEGIN TRANSACTION") const stmt = await this.db!.prepareAsync( - `INSERT OR REPLACE INTO settings (category, key, value, updated_at) + `INSERT OR REPLACE INTO ${this.TABLE_SETTINGS} (category, key, value, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)` ) // Execute all settings in batch. for (const setting of settings) { - const valueString = typeof setting.value === "string" ? setting.value : JSON.stringify(setting.value) + const valueString = this.serializeValue(setting.value) await stmt.executeAsync([setting.category, setting.key, valueString]) } @@ -390,51 +480,24 @@ export class DatabaseManager { } } - /** - * Flush the SQLite database to the Kotlin layer. - */ - async flushSQLiteForKotlin(): Promise { - const endTiming = startTiming("database_flush_to_kotlin", "database") - - if (this.db) { - await this.db.execAsync("PRAGMA wal_checkpoint(FULL);") - logWithTimestamp("[DB] Successfully flushed SQLite database to Kotlin layer.") - endTiming({ status: "success" }) - } else { - logErrorWithTimestamp("Database is null when trying to flush to Kotlin layer.") - endTiming({ status: "error", error: "database_not_initialized" }) - throw new Error("Database is null when trying to flush to Kotlin layer.") - } - } - /** * Load a specific setting from database. + * + * @param category - The category of the setting to load. + * @param key - The key of the setting to load. + * @returns The value of the setting, or null if not found. */ async loadSetting(category: string, key: string): Promise { - if (!this.db) { - throw new Error("Database not initialized") - } + this.ensureInitialized() try { - const result = await this.db.getFirstAsync("SELECT * FROM settings WHERE category = ? AND key = ?", [category, key]) + const result = await this.db!.getFirstAsync(`SELECT * FROM ${this.TABLE_SETTINGS} WHERE category = ? AND key = ?`, [category, key]) if (!result) { return null } - // Settings that should remain as JSON strings (not parsed into objects) - const stringOnlySettings = ["racingPlan", "racingPlanData"] - - if (stringOnlySettings.includes(key)) { - return result.value - } else { - // Try to parse as JSON and fallback to string. - try { - return JSON.parse(result.value) - } catch { - return result.value - } - } + return this.deserializeValue(key, result.value) } catch (error) { logErrorWithTimestamp(`[DB] Failed to load setting ${category}.${key}:`, error) throw error @@ -443,36 +506,23 @@ export class DatabaseManager { /** * Load all settings from database. + * + * @returns A promise that resolves with a record of settings organized by category and key. */ async loadAllSettings(): Promise>> { const endTiming = startTiming("database_load_all_settings", "database") - if (!this.db) { - endTiming({ status: "error", error: "database_not_initialized" }) - throw new Error("Database not initialized") - } + this.ensureInitialized() try { - const results = await this.db.getAllAsync("SELECT * FROM settings ORDER BY category, key") + const results = await this.db!.getAllAsync(`SELECT * FROM ${this.TABLE_SETTINGS} ORDER BY category, key`) const settings: Record> = {} for (const result of results) { if (!settings[result.category]) { settings[result.category] = {} } - - // Settings that should remain as JSON strings (not parsed into objects) - const stringOnlySettings = ["racingPlan", "racingPlanData"] - - if (stringOnlySettings.includes(result.key)) { - settings[result.category][result.key] = result.value - } else { - try { - settings[result.category][result.key] = JSON.parse(result.value) - } catch { - settings[result.category][result.key] = result.value - } - } + settings[result.category][result.key] = this.deserializeValue(result.key, result.value) } endTiming({ status: "success", totalSettings: results.length, categoriesCount: Object.keys(settings).length }) @@ -484,59 +534,20 @@ export class DatabaseManager { } } - /** - * Save a race to the database. - */ - async saveRace(race: Omit): Promise { - const endTiming = startTiming("database_save_race", "database") - - if (!this.db) { - logErrorWithTimestamp("Database is null when trying to save race.") - endTiming({ status: "error", error: "database_not_initialized" }) - throw new Error("Database not initialized") - } - - try { - logWithTimestamp(`[DB] Saving race: ${race.name} (${race.turnNumber})`) - await this.db.runAsync( - `INSERT OR REPLACE INTO races (key, name, date, raceTrack, course, direction, grade, terrain, distanceType, distanceMeters, fans, turnNumber, nameFormatted) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - race.key, - race.name, - race.date, - race.raceTrack, - race.course, - race.direction, - race.grade, - race.terrain, - race.distanceType, - race.distanceMeters, - race.fans, - race.turnNumber, - race.nameFormatted, - ] - ) - logWithTimestamp(`[DB] Successfully saved race: ${race.name}`) - endTiming({ status: "success", raceName: race.name }) - } catch (error) { - logErrorWithTimestamp(`[DB] Failed to save race ${race.name} (turn ${race.turnNumber}):`, error) - endTiming({ status: "error", raceName: race.name, error: error instanceof Error ? error.message : String(error) }) - throw error - } - } + // ============================================================================ + // Races Methods + // ============================================================================ /** * Save multiple races using prepared statements for better performance and security. + * + * @param races - The races to save. + * @returns A promise that resolves when the races are saved. */ async saveRacesBatch(races: Array>): Promise { const endTiming = startTiming("database_save_races_batch", "database") - if (!this.db) { - logErrorWithTimestamp("Database is null when trying to save races batch.") - endTiming({ status: "error", error: "database_not_initialized" }) - throw new Error("Database not initialized") - } + this.ensureInitialized() if (races.length === 0) { endTiming({ status: "skipped", reason: "no_races" }) @@ -544,12 +555,12 @@ export class DatabaseManager { } try { - await this.executeWithTransaction(async () => { + await this.executeWithQueue(async () => { logWithTimestamp(`[DB] Saving ${races.length} races using prepared statement.`) await this.db!.runAsync("BEGIN TRANSACTION") const stmt = await this.db!.prepareAsync( - `INSERT OR REPLACE INTO races (key, name, date, raceTrack, course, direction, grade, terrain, distanceType, distanceMeters, fans, turnNumber, nameFormatted) + `INSERT OR REPLACE INTO ${this.TABLE_RACES} (key, name, date, raceTrack, course, direction, grade, terrain, distanceType, distanceMeters, fans, turnNumber, nameFormatted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) @@ -598,63 +609,18 @@ export class DatabaseManager { } } - /** - * Load all races from database. - */ - async loadAllRaces(): Promise { - const endTiming = startTiming("database_load_all_races", "database") - - if (!this.db) { - endTiming({ status: "error", error: "database_not_initialized" }) - throw new Error("Database not initialized") - } - - try { - const results = await this.db.getAllAsync("SELECT * FROM races ORDER BY turnNumber, name") - endTiming({ status: "success", totalRaces: results.length }) - return results - } catch (error) { - logErrorWithTimestamp("[DB] Failed to load all races:", error) - endTiming({ status: "error", error: error instanceof Error ? error.message : String(error) }) - throw error - } - } - - /** - * Load races by turn number. - */ - async loadRacesByTurnNumber(turnNumber: number): Promise { - const endTiming = startTiming("database_load_races_by_turn", "database") - - if (!this.db) { - endTiming({ status: "error", error: "database_not_initialized" }) - throw new Error("Database not initialized") - } - - try { - const results = await this.db.getAllAsync("SELECT * FROM races WHERE turnNumber = ? ORDER BY name", [turnNumber]) - endTiming({ status: "success", turnNumber, racesCount: results.length }) - return results - } catch (error) { - logErrorWithTimestamp(`[DB] Failed to load races for turn ${turnNumber}:`, error) - endTiming({ status: "error", turnNumber, error: error instanceof Error ? error.message : String(error) }) - throw error - } - } - /** * Clear all races from the database. + * + * @returns A promise that resolves when the races are cleared. */ async clearRaces(): Promise { const endTiming = startTiming("database_clear_races", "database") - if (!this.db) { - endTiming({ status: "error", error: "database_not_initialized" }) - throw new Error("Database not initialized") - } + this.ensureInitialized() try { - await this.db.runAsync("DELETE FROM races") + await this.db!.runAsync(`DELETE FROM ${this.TABLE_RACES}`) logWithTimestamp("[DB] Successfully cleared all races.") endTiming({ status: "success" }) } catch (error) { @@ -666,6 +632,8 @@ export class DatabaseManager { /** * Check if the database is properly initialized. + * + * @returns True if the database is initialized, false otherwise. */ isInitialized(): boolean { return this.db !== null @@ -679,21 +647,22 @@ export class DatabaseManager { this.isTransactionActive = false } + // ============================================================================ + // Profiles Methods + // ============================================================================ + /** * Get all profiles from the database. * - * @returns A promise that resolves when all profiles are loaded. + * @returns A promise that resolves with an array of all profiles. */ async getAllProfiles(): Promise { const endTiming = startTiming("database_get_all_profiles", "database") - if (!this.db) { - endTiming({ status: "error", error: "database_not_initialized" }) - throw new Error("Database not initialized") - } + this.ensureInitialized() try { - const results = await this.db.getAllAsync("SELECT * FROM profiles ORDER BY name") + const results = await this.db!.getAllAsync(`SELECT * FROM ${this.TABLE_PROFILES} ORDER BY name`) endTiming({ status: "success", totalProfiles: results.length }) return results } catch (error) { @@ -707,18 +676,15 @@ export class DatabaseManager { * Get a single profile by ID. * * @param id - The ID of the profile to load. - * @returns The profile with the given ID, or null if not found. + * @returns A promise that resolves with the profile of the given ID, or null if not found. */ async getProfile(id: number): Promise { const endTiming = startTiming("database_get_profile", "database") - if (!this.db) { - endTiming({ status: "error", error: "database_not_initialized" }) - throw new Error("Database not initialized") - } + this.ensureInitialized() try { - const result = await this.db.getFirstAsync("SELECT * FROM profiles WHERE id = ?", [id]) + const result = await this.db!.getFirstAsync(`SELECT * FROM ${this.TABLE_PROFILES} WHERE id = ?`, [id]) endTiming({ status: "success", found: !!result }) return result || null } catch (error) { @@ -732,16 +698,12 @@ export class DatabaseManager { * Save a profile (create or update). * * @param profile - The profile to save. - * @returns A promise that resolves when the profile is saved. + * @returns A promise that resolves with the ID of the saved profile. */ async saveProfile(profile: { id?: number; name: string; settings: any }): Promise { const endTiming = startTiming("database_save_profile", "database") - if (!this.db) { - logErrorWithTimestamp("Database is null when trying to save profile.") - endTiming({ status: "error", error: "database_not_initialized" }) - throw new Error("Database not initialized") - } + this.ensureInitialized() try { const settingsJson = JSON.stringify(profile.settings) @@ -750,8 +712,8 @@ export class DatabaseManager { // Update existing profile. logWithTimestamp(`[DB] Updating profile: ${profile.name} (id: ${profile.id})`) try { - await this.db.runAsync( - `UPDATE profiles + await this.db!.runAsync( + `UPDATE ${this.TABLE_PROFILES} SET name = ?, settings = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [profile.name, settingsJson, profile.id] @@ -766,8 +728,8 @@ export class DatabaseManager { const existingProfile = await this.getProfile(profile.id) if (existingProfile && existingProfile.name === profile.name) { // Name is the same, just update settings. - await this.db.runAsync( - `UPDATE profiles + await this.db!.runAsync( + `UPDATE ${this.TABLE_PROFILES} SET settings = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [settingsJson, profile.id] @@ -782,8 +744,8 @@ export class DatabaseManager { } else { // Create new profile. logWithTimestamp(`[DB] Creating profile: ${profile.name}`) - const result = await this.db.runAsync( - `INSERT INTO profiles (name, settings, created_at, updated_at) + const result = await this.db!.runAsync( + `INSERT INTO ${this.TABLE_PROFILES} (name, settings, created_at, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`, [profile.name, settingsJson] ) @@ -808,15 +770,11 @@ export class DatabaseManager { async deleteProfile(id: number): Promise { const endTiming = startTiming("database_delete_profile", "database") - if (!this.db) { - logErrorWithTimestamp("Database is null when trying to delete profile.") - endTiming({ status: "error", error: "database_not_initialized" }) - throw new Error("Database not initialized") - } + this.ensureInitialized() try { logWithTimestamp(`[DB] Deleting profile with id: ${id}`) - await this.db.runAsync("DELETE FROM profiles WHERE id = ?", [id]) + await this.db!.runAsync(`DELETE FROM ${this.TABLE_PROFILES} WHERE id = ?`, [id]) logWithTimestamp(`[DB] Successfully deleted profile with id: ${id}`) endTiming({ status: "success", profileId: id }) } catch (error) { @@ -829,12 +787,10 @@ export class DatabaseManager { /** * Get the current active profile name from settings. * - * @returns The current active profile name, or null if no profile is active. + * @returns A promise that resolves with the current active profile name, or null if no profile is active. */ async getCurrentProfileName(): Promise { - if (!this.db) { - throw new Error("Database not initialized") - } + this.ensureInitialized() try { const profileName = await this.loadSetting("misc", "currentProfileName") @@ -849,19 +805,17 @@ export class DatabaseManager { * Set the current active profile name in settings. * * @param profileName - The name of the profile to set as active. - * @returns A promise that resolves when the current active profile name is set. + * @returns A promise that resolves when the current active profile name is set or null if no profile is active. */ async setCurrentProfileName(profileName: string | null): Promise { - if (!this.db) { - throw new Error("Database not initialized") - } + this.ensureInitialized() try { if (profileName) { await this.saveSetting("misc", "currentProfileName", profileName, true) } else { // Delete the setting if profileName is null. - await this.db.runAsync("DELETE FROM settings WHERE category = ? AND key = ?", ["misc", "currentProfileName"]) + await this.db!.runAsync(`DELETE FROM ${this.TABLE_SETTINGS} WHERE category = ? AND key = ?`, ["misc", "currentProfileName"]) } } catch (error) { logErrorWithTimestamp("[DB] Failed to save current profile name:", error) From 4d5e88f09a6bcfffb9fcd9087c4959384072569a Mon Sep 17 00:00:00 2001 From: steve1316 Date: Sun, 23 Nov 2025 16:51:57 -0800 Subject: [PATCH 02/21] Set the background color of the Snackbar in the Training Settings page to red --- src/pages/TrainingSettings/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/TrainingSettings/index.tsx b/src/pages/TrainingSettings/index.tsx index ee4fb05e..faa670e3 100644 --- a/src/pages/TrainingSettings/index.tsx +++ b/src/pages/TrainingSettings/index.tsx @@ -791,7 +791,7 @@ const TrainingSettings = () => { setSnackbarVisible(false) }, }} - style={{ marginBottom: 20 }} + style={{ backgroundColor: "red", borderRadius: 10 }} duration={4000} > {snackbarMessage} From 85017b2f6f0b7e9ba47376961135a600f19618a8 Mon Sep 17 00:00:00 2001 From: steve1316 Date: Sun, 23 Nov 2025 16:52:12 -0800 Subject: [PATCH 03/21] Apply formatting --- src/pages/Settings/index.tsx | 4 +--- src/pages/TrainingSettings/index.tsx | 7 +++---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/pages/Settings/index.tsx b/src/pages/Settings/index.tsx index 377be0bc..58951e59 100644 --- a/src/pages/Settings/index.tsx +++ b/src/pages/Settings/index.tsx @@ -100,9 +100,7 @@ const Settings = () => { /> {bsc.settings.general.scenario === "Unity Cup" && ( - - ⚠️ Unity Cup Warning: Running extra races via fan farming being enabled is highly discouraged in order to focus more on unity trainings. - + ⚠️ Unity Cup Warning: Running extra races via fan farming being enabled is highly discouraged in order to focus more on unity trainings. )} {!bsc.settings.general.scenario && ( diff --git a/src/pages/TrainingSettings/index.tsx b/src/pages/TrainingSettings/index.tsx index faa670e3..a917fd58 100644 --- a/src/pages/TrainingSettings/index.tsx +++ b/src/pages/TrainingSettings/index.tsx @@ -363,9 +363,7 @@ const TrainingSettings = () => { {bsc.settings.general.scenario === "Unity Cup" && ( - - ⚠️ Unity Cup Note: Unity trainings will take priority over stat prioritization up till Senior Year. - + ⚠️ Unity Cup Note: Unity trainings will take priority over stat prioritization up till Senior Year. )} @@ -490,7 +488,8 @@ const TrainingSettings = () => { Set the preferred race distance for training targets. "Auto" will automatically determine based on character aptitudes reading from left to right (S {">"} A priority). {"\n\n"} - For example, if Gold Ship has an aptitude of A for both Medium and Long, Auto will use Medium as the preferred distance. Whereas if Medium is A and Long is S, then Auto will instead use Long as the preferred distance. + For example, if Gold Ship has an aptitude of A for both Medium and Long, Auto will use Medium as the preferred distance. Whereas if Medium is A and Long is S, then Auto + will instead use Long as the preferred distance. From 8c0ea87b794965a7bc9e2d98d2c042712f195ad3 Mon Sep 17 00:00:00 2001 From: steve1316 Date: Sun, 23 Nov 2025 16:54:04 -0800 Subject: [PATCH 04/21] Fix bug where mood recovery could not proceed if dating was already completed --- .../com/steve1316/uma_android_automation/bot/Game.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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 index 99b1b90a..a90fc1b9 100644 --- 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 @@ -661,12 +661,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 From a8dc6a53e77f0ce7936705990e6d308bff5adff5 Mon Sep 17 00:00:00 2001 From: steve1316 Date: Sun, 23 Nov 2025 16:54:34 -0800 Subject: [PATCH 05/21] Specify POST_NOTIFICATIONS permission in AndroidManifest.xml --- android/app/src/main/AndroidManifest.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1509d9cd..b6b9f443 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -14,6 +14,9 @@ tools:ignore="ScopedStorage" /> + + + From 0deeb2ff13c43b429338e14768cc83141859e9c1 Mon Sep 17 00:00:00 2001 From: steve1316 Date: Sun, 23 Nov 2025 16:54:46 -0800 Subject: [PATCH 06/21] Add two new commands to the package.json file for quicker testing of the app --- package.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/package.json b/package.json index 5b09d037..21fd41c5 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "clean": "cd android && ./gradlew --stop && ./gradlew clean --stacktrace", "build:debug": "cd android && ./gradlew --stop && ./gradlew assembleDebug", "build:release": "cd android && ./gradlew --stop && ./gradlew assembleRelease", + "build:debug:bundle": "node android/generate-bundle.js && cd android && ./gradlew --stop && ./gradlew assembleDebug", + "build:release:bundle": "node android/generate-bundle.js && cd android && ./gradlew --stop && ./gradlew assembleRelease", "build:debug:clean": "cd android && ./gradlew --stop && ./gradlew clean && ./gradlew assembleDebug", "build:release:clean": "cd android && ./gradlew --stop && ./gradlew clean && ./gradlew assembleRelease", "lint": "expo lint", From 4e14223a50aae65200d4d2d8fa257b6dae40fa08 Mon Sep 17 00:00:00 2001 From: steve1316 Date: Sun, 23 Nov 2025 17:02:55 -0800 Subject: [PATCH 07/21] Add a new option to the Training Settings page to allow the user to set a custom stat cap to stop training on --- src/context/BotStateContext.tsx | 2 ++ src/pages/TrainingSettings/index.tsx | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/context/BotStateContext.tsx b/src/context/BotStateContext.tsx index 76e60865..a54f2c6d 100644 --- a/src/context/BotStateContext.tsx +++ b/src/context/BotStateContext.tsx @@ -56,6 +56,7 @@ export interface Settings { statPrioritization: string[] maximumFailureChance: number disableTrainingOnMaxedStat: boolean + manualStatCap: number focusOnSparkStatTarget: boolean enableRainbowTrainingBonus: boolean preferredDistanceOverride: string @@ -211,6 +212,7 @@ export const defaultSettings: Settings = { statPrioritization: ["Speed", "Stamina", "Power", "Wit", "Guts"], maximumFailureChance: 20, disableTrainingOnMaxedStat: true, + manualStatCap: 1200, focusOnSparkStatTarget: false, enableRainbowTrainingBonus: false, preferredDistanceOverride: "Auto", diff --git a/src/pages/TrainingSettings/index.tsx b/src/pages/TrainingSettings/index.tsx index a917fd58..9205912e 100644 --- a/src/pages/TrainingSettings/index.tsx +++ b/src/pages/TrainingSettings/index.tsx @@ -47,6 +47,7 @@ const TrainingSettings = () => { const { maximumFailureChance, disableTrainingOnMaxedStat, + manualStatCap, focusOnSparkStatTarget, enableRainbowTrainingBonus, preferredDistanceOverride, @@ -376,6 +377,21 @@ const TrainingSettings = () => { description="When enabled, training will be skipped for stats that have reached their maximum value." className="my-2" /> + {disableTrainingOnMaxedStat && ( + updateTrainingSetting("manualStatCap", value)} + min={1000} + max={2000} + step={10} + label="Manual Stat Cap" + labelUnit="" + showValue={true} + showLabels={true} + description="Set a custom stat cap for all stats. Training will be skipped when any stat reaches this value (if 'Disable Training on Maxed Stats' is enabled)." + /> + )} From 3cbc6e30776ff4eba92be9ed13b9d15327166067 Mon Sep 17 00:00:00 2001 From: steve1316 Date: Sun, 23 Nov 2025 17:03:18 -0800 Subject: [PATCH 08/21] Update the manual stat cap to be used in the Training class --- .../java/com/steve1316/uma_android_automation/bot/Training.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 index ebd0c88a..67c7b915 100644 --- 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 @@ -97,6 +97,7 @@ 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 manualStatCap: Int = SettingsHelper.getIntSetting("training", "manualStatCap") private val statTargetsByDistance: MutableMap = mutableMapOf( "Sprint" to intArrayOf(0, 0, 0, 0, 0), "Mile" to intArrayOf(0, 0, 0, 0, 0), @@ -105,7 +106,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 //////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////// From 5d0314da8a95cc0710a6e902eee79f672c707a9f Mon Sep 17 00:00:00 2001 From: steve1316 Date: Sun, 23 Nov 2025 17:22:16 -0800 Subject: [PATCH 09/21] Add a new option to the Training Settings page to allow the user to train Wit during the Final instead of recovering energy --- src/context/BotStateContext.tsx | 2 ++ src/pages/TrainingSettings/index.tsx | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/context/BotStateContext.tsx b/src/context/BotStateContext.tsx index a54f2c6d..6d4e1eef 100644 --- a/src/context/BotStateContext.tsx +++ b/src/context/BotStateContext.tsx @@ -64,6 +64,7 @@ export interface Settings { enableRiskyTraining: boolean riskyTrainingMinStatGain: number riskyTrainingMaxFailureChance: number + trainWitDuringFinale: boolean } // Training Stat Target settings @@ -220,6 +221,7 @@ export const defaultSettings: Settings = { enableRiskyTraining: false, riskyTrainingMinStatGain: 30, riskyTrainingMaxFailureChance: 30, + trainWitDuringFinale: false, }, trainingStatTarget: { trainingSprintStatTarget_speedStatTarget: 900, diff --git a/src/pages/TrainingSettings/index.tsx b/src/pages/TrainingSettings/index.tsx index 9205912e..3c94e0a7 100644 --- a/src/pages/TrainingSettings/index.tsx +++ b/src/pages/TrainingSettings/index.tsx @@ -55,6 +55,7 @@ const TrainingSettings = () => { enableRiskyTraining, riskyTrainingMinStatGain, riskyTrainingMaxFailureChance, + trainWitDuringFinale, } = trainingSettings useEffect(() => { @@ -473,6 +474,17 @@ const TrainingSettings = () => { /> + + updateTrainingSetting("trainWitDuringFinale", checked)} + label="Train Wit During Finale" + description="When enabled, the bot will train Wit during URA finale turns (73, 74, 75) instead of recovering energy or mood, even if the failure chance is high." + className="my-2" + /> + + Date: Sun, 23 Nov 2025 17:22:35 -0800 Subject: [PATCH 10/21] Update the Training class to force Wit training during the Finale instead of recovering energy if the setting is enabled --- .../uma_android_automation/bot/Training.kt | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) 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 index 67c7b915..75c3c804 100644 --- 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 @@ -97,6 +97,7 @@ 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 = mutableMapOf( "Sprint" to intArrayOf(0, 0, 0, 0, 0), @@ -235,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. From 60dbf7c9f7c8aa3ec3593b8bf8ee6c980c2b2502 Mon Sep 17 00:00:00 2001 From: steve1316 Date: Sun, 23 Nov 2025 19:45:24 -0800 Subject: [PATCH 11/21] Add a new option in the Misc section of the Settings page to stop the bot before the Finals on turn 72 --- src/context/BotStateContext.tsx | 2 ++ src/pages/Settings/index.tsx | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/context/BotStateContext.tsx b/src/context/BotStateContext.tsx index 6d4e1eef..2edced46 100644 --- a/src/context/BotStateContext.tsx +++ b/src/context/BotStateContext.tsx @@ -10,6 +10,7 @@ export interface Settings { skillPointCheck: number enablePopupCheck: boolean enableCraneGameAttempt: boolean + enableStopBeforeFinals: boolean } // Racing settings @@ -128,6 +129,7 @@ export const defaultSettings: Settings = { skillPointCheck: 750, enablePopupCheck: false, enableCraneGameAttempt: false, + enableStopBeforeFinals: false, }, racing: { enableFarmingFans: false, diff --git a/src/pages/Settings/index.tsx b/src/pages/Settings/index.tsx index 58951e59..6621b570 100644 --- a/src/pages/Settings/index.tsx +++ b/src/pages/Settings/index.tsx @@ -222,6 +222,19 @@ const Settings = () => { className="mt-4" /> + { + bsc.setSettings({ + ...bsc.settings, + general: { ...bsc.settings.general, enableStopBeforeFinals: checked }, + }) + }} + label="Stop before Finals" + description="Stops the bot on turn 72 so you can purchase skills before the final races." + className="mt-4" + /> + { From e9e272305a07dffade72014f8930db6057713b6a Mon Sep 17 00:00:00 2001 From: steve1316 Date: Sun, 23 Nov 2025 19:46:03 -0800 Subject: [PATCH 12/21] Implement logic to stop the bot before the Finals on turn 72 if the setting is enabled --- .../uma_android_automation/bot/Campaign.kt | 6 ++++ .../uma_android_automation/bot/Game.kt | 33 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/android/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 index 2a6296e2..7d7404ca 100644 --- a/android/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 @@ -44,6 +44,12 @@ open class Campaign(val game: Game) { //////////////////////////////////////////////// // Most bot operations start at the Main screen. if (game.checkMainScreen()) { + // 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. 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 index a90fc1b9..1363b4f3 100644 --- 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 @@ -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") //////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////// @@ -67,6 +68,7 @@ class Game(val myContext: Context) { private var inheritancesDone = 0 private var needToUpdateAptitudes: Boolean = true private var recreationDateCompleted: Boolean = false + private var stopBeforeFinalsInitialTurnNumber: Int = -1 data class Date( val year: Int, @@ -438,6 +440,37 @@ class Game(val myContext: Context) { } } + /** + * 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 + } + /** * Checks if the bot has a injury. * From 59304dbc59b48f12d500fbc71c813f78cf1eb9dc Mon Sep 17 00:00:00 2001 From: steve1316 Date: Sun, 23 Nov 2025 20:34:34 -0800 Subject: [PATCH 13/21] Update the introMessage with the app name and version states in the dependency --- src/components/MessageLog/index.tsx | 2 +- src/pages/Home/index.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/MessageLog/index.tsx b/src/components/MessageLog/index.tsx index e8f8d9f8..dcb66c1b 100644 --- a/src/components/MessageLog/index.tsx +++ b/src/components/MessageLog/index.tsx @@ -321,7 +321,7 @@ ${longTargetsString} // Only include settings string if enabled and no logs exist yet. return bsc.settings.misc.enableSettingsDisplay ? `${baseMessage}\n\n${formattedSettingsString}` : baseMessage - }, [bsc.settings.misc.enableSettingsDisplay, formattedSettingsString, mlc.messageLog.length]) + }, [bsc.appName, bsc.appVersion, bsc.settings.misc.enableSettingsDisplay, formattedSettingsString, mlc.messageLog.length]) // Process log messages with color coding and virtualization. const processedMessages = useMemo((): LogMessage[] => { diff --git a/src/pages/Home/index.tsx b/src/pages/Home/index.tsx index 44db731f..ad43ee85 100644 --- a/src/pages/Home/index.tsx +++ b/src/pages/Home/index.tsx @@ -71,7 +71,7 @@ const Home = () => { const appName = Application.applicationName || "App" var version = Application.nativeApplicationVersion || "0.0.0" version += " (" + (Application.nativeBuildVersion || "0") + ")" - logWithTimestamp("Android app version is " + version) + logWithTimestamp(`Android app ${appName} version is ${version}`) bsc.setAppName(appName) bsc.setAppVersion(version) } From 3384bf0897da24d05827b2070d170c9929f9daa6 Mon Sep 17 00:00:00 2001 From: steve1316 Date: Sun, 23 Nov 2025 20:35:38 -0800 Subject: [PATCH 14/21] Add image asset for scenario validation - Unity Cup for now. --- .../main/assets/images/unitycup_date_text.png | Bin 0 -> 8479 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 android/app/src/main/assets/images/unitycup_date_text.png diff --git a/android/app/src/main/assets/images/unitycup_date_text.png b/android/app/src/main/assets/images/unitycup_date_text.png new file mode 100644 index 0000000000000000000000000000000000000000..d77c3926194169af0af8678529bfd62a24227b17 GIT binary patch literal 8479 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 N002ovPDHLkV1gyF56J)k literal 0 HcmV?d00001 From e128c5cc2c0b86662bb6465eadfbe8cced2d5c5f Mon Sep 17 00:00:00 2001 From: steve1316 Date: Sun, 23 Nov 2025 20:35:52 -0800 Subject: [PATCH 15/21] Add logic to perform one-time scenario validation check --- .../uma_android_automation/bot/Campaign.kt | 6 ++++ .../uma_android_automation/bot/Game.kt | 30 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/android/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 index 7d7404ca..f126b23a 100644 --- a/android/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 @@ -44,6 +44,12 @@ 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.") 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 index 1363b4f3..ab87cdb3 100644 --- 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 @@ -69,6 +69,7 @@ class Game(val myContext: Context) { private var needToUpdateAptitudes: Boolean = true private var recreationDateCompleted: Boolean = false private var stopBeforeFinalsInitialTurnNumber: Int = -1 + private var scenarioCheckPerformed: Boolean = false data class Date( val year: Int, @@ -314,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. From c76662dea74e833cfe4511b2c74e4b770cdeddee Mon Sep 17 00:00:00 2001 From: steve1316 Date: Tue, 25 Nov 2025 10:50:24 -0800 Subject: [PATCH 16/21] Add new settings to the MessageLog settings string --- src/components/MessageLog/index.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/components/MessageLog/index.tsx b/src/components/MessageLog/index.tsx index dcb66c1b..21f1b609 100644 --- a/src/components/MessageLog/index.tsx +++ b/src/components/MessageLog/index.tsx @@ -253,6 +253,8 @@ const MessageLog = () => { 📏 Preferred Distance Override: ${settings.training.preferredDistanceOverride === "Auto" ? "Auto" : settings.training.preferredDistanceOverride} 🌈 Enable Rainbow Training Bonus: ${settings.training.enableRainbowTrainingBonus ? "✅" : "❌"} ☀️ Must Rest Before Summer: ${settings.training.mustRestBeforeSummer ? "✅" : "❌"} +📏 Manual Stat Cap: ${settings.training.manualStatCap} +🎯 Train Wit During Finale: ${settings.training.trainWitDuringFinale ? "✅" : "❌"} ---------- Training Stat Targets by Distance ---------- ${sprintTargetsString} @@ -280,11 +282,15 @@ ${longTargetsString} 📅 Look Ahead Days: ${settings.racing.lookAheadDays} days ⏰ Smart Racing Check Interval: ${settings.racing.smartRacingCheckInterval} days 🎯 Race Strategy Override: ${settings.racing.enableRaceStrategyOverride ? `✅ (From ${settings.racing.originalRaceStrategy} to ${settings.racing.juniorYearRaceStrategy})` : "❌"} +📊 Minimum Quality Threshold: ${settings.racing.minimumQualityThreshold} +⏱️ Time Decay Factor: ${settings.racing.timeDecayFactor} +📈 Improvement Threshold: ${settings.racing.improvementThreshold} ---------- Misc Options ---------- 🔍 Skill Point Check: ${settings.general.enableSkillPointCheck ? `Stop on ${settings.general.skillPointCheck} Skill Points or more` : "❌"} 🔍 Popup Check: ${settings.general.enablePopupCheck ? "✅" : "❌"} 🔍 Enable Crane Game Attempt: ${settings.general.enableCraneGameAttempt ? "✅" : "❌"} +🛑 Stop Before Finals: ${settings.general.enableStopBeforeFinals ? "✅" : "❌"} ---------- Debug Options ---------- 🐛 Debug Mode: ${settings.debug.enableDebugMode ? "✅" : "❌"} From a890b7540b52b36652106c56d23d8d32a5ab42f7 Mon Sep 17 00:00:00 2001 From: steve1316 Date: Tue, 25 Nov 2025 10:50:49 -0800 Subject: [PATCH 17/21] Fix bug where "Finale Underway" was not being parsed correctly by GameDateParser --- .../utils/GameDateParser.kt | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) 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 index cb05fdb3..b3df9cc0 100644 --- 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 @@ -91,6 +91,31 @@ class GameDateParser { "Dec" to 12 ) + // Check if the date string indicates "Finale Underway" and determine which Finals phase it is. + if (dateString.lowercase().contains("finale") || dateString.lowercase().contains("underway")) { + val sourceBitmap = imageUtils.getSourceBitmap() + val turnNumber = when { + imageUtils.findImageWithBitmap("date_final_qualifier", sourceBitmap, suppressError = true, region = imageUtils.regionTopHalf, customConfidence = 0.9) != null -> { + MessageLog.i(tag, "[DATE-PARSER] Detected Finale Qualifier (Turn 73) from date string: $dateString.") + 73 + } + imageUtils.findImageWithBitmap("date_final_semifinal", sourceBitmap, suppressError = true, region = imageUtils.regionTopHalf, customConfidence = 0.9) != null -> { + MessageLog.i(tag, "[DATE-PARSER] Detected Finale Semifinal (Turn 74) from date string: $dateString.") + 74 + } + imageUtils.findImageWithBitmap("date_final_finals", sourceBitmap, suppressError = true, region = imageUtils.regionTopHalf, customConfidence = 0.9) != null -> { + MessageLog.i(tag, "[DATE-PARSER] Detected Finale Finals (Turn 75) from date string: $dateString.") + 75 + } + else -> { + MessageLog.w(tag, "[DATE-PARSER] Could not determine Finals phase from date string: $dateString. Defaulting to Finale Qualifier (Turn 73).") + 73 + } + } + // Finals occur at Senior Year Late Dec, only the turn number varies. + return Game.Date(3, "Late", 12, turnNumber) + } + // Split the input string by whitespace. val parts = dateString.trim().split(" ") if (parts.size < 3) { From b828142267c5b4cf6da089962c09b42a3da3e973 Mon Sep 17 00:00:00 2001 From: steve1316 Date: Tue, 25 Nov 2025 10:51:23 -0800 Subject: [PATCH 18/21] Shift priority weight for event chains for TrainingEvent class --- .../bot/TrainingEvent.kt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) 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 index e5485b21..0ee77e38 100644 --- 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 @@ -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("/")) { @@ -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 From eaa411fc643d83447c07ae2e4414172bdc3bafc3 Mon Sep 17 00:00:00 2001 From: steve1316 Date: Tue, 25 Nov 2025 10:51:55 -0800 Subject: [PATCH 19/21] Fix bug where risky training was not being started when the initial failure chance was not being checked with the risky training threshold --- .../uma_android_automation/bot/Training.kt | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) 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 index 75c3c804..2cb42b72 100644 --- 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 @@ -305,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() From df783665bc3583a8584b3fb027bc13bd438ba927 Mon Sep 17 00:00:00 2001 From: steve1316 Date: Tue, 25 Nov 2025 10:54:29 -0800 Subject: [PATCH 20/21] Update updateDate() and checkFinals() to better detect the Finals in-game date --- .../uma_android_automation/bot/Game.kt | 41 +++++++++++-------- .../uma_android_automation/bot/Racing.kt | 4 +- 2 files changed, 26 insertions(+), 19 deletions(-) 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 index ab87cdb3..2f4fd22b 100644 --- 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 @@ -68,6 +68,7 @@ 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 @@ -300,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() { @@ -359,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 && @@ -459,15 +458,25 @@ 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 + } + } } /** @@ -592,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. 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 index 4a0e2b31..b585891c 100644 --- 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 @@ -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) { @@ -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 } From 2362e2b662fe17ebf7bd660d1a2bc89b0b4f6b1b Mon Sep 17 00:00:00 2001 From: steve1316 Date: Tue, 25 Nov 2025 10:54:34 -0800 Subject: [PATCH 21/21] Update game data up to Agnes Digital --- src/data/characters.json | 220 +++++++++++++++++++++++++++++++++++++-- src/data/supports.json | 66 +++++++----- 2 files changed, 255 insertions(+), 31 deletions(-) diff --git a/src/data/characters.json b/src/data/characters.json index f36f7071..607e3211 100644 --- a/src/data/characters.json +++ b/src/data/characters.json @@ -1,4 +1,212 @@ { + "Agnes Digital": { + "Teach Us Your Ways, Digital!": [ + "Wit +20", + "Power +20" + ], + "I \"Simp\"ly Wanna Help...": [ + "Speed +10\nPower +10", + "Stamina +10\nGuts +10" + ], + "Give Freely, for She Is My Oshi!": [ + "Stamina +10\nSkill points +15\n(random) Get Hot Topic status", + "Power +20\n(random) Get Hot Topic status" + ], + "The Endless Possibilities of Moe": [ + "Speed +10", + "Stamina +10" + ], + "When Fantasy Becomes Real": [ + "Power +5\nGuts +5", + "Energy +5\nWit +5" + ], + "The Cafeteria is Basically a Collab Cafe NGL": [ + "Top Pick hint +1", + "Acceleration hint +1" + ], + "Umamusume Quiz ☆ –Relationships Arc–": [ + "Speed +15\nHomestretch Haste hint +1", + "Speed +5" + ], + "Umamusume Quiz ☆ –Preferences Arc–": [ + "Stamina +5", + "Stamina +15\nKyoto Racecourse ○ hint +1" + ], + "Umamusume Quiz ☆ –Roots Arc–": [ + "Power +15\nMile Corners ○ hint +1", + "Power +5" + ], + "I'd Be Honored to Trade!": [ + "Energy +10\nSkill points +5", + "Randomly either\n----------\nEnergy +30\nSkill points +10\nor (~10%)\nor (~10%)\nEnergy +30\nSkill points +10\nSpeed -5\nPower +5\nGet Slow Metabolism status" + ], + "The Oshi Report –Induction Arc–": [ + "Stamina +10", + "Speed +10" + ], + "The Oshi Report –Peak Arc–": [ + "Stamina +10", + "Wit +10" + ], + "The Oshi Report –Recognition Arc–": [ + "Guts +10", + "Speed +10", + "Skill points +30" + ], + "The Best Life Is the Simp Life": [ + "Speed +10", + "Guts +10" + ], + "Oshi Proximity Alert?!": [ + "Power +10", + "Energy +10" + ], + "All-Round Ruler": [ + "Speed +20\nPower +20\nSkill points +30\nUpdrafters hint +1\nUnyielding Spirit hint +1" + ], + "After the Takarazuka Kinen: On Fire Like No One's Ever Seen": [ + "1 random stat +4/+5/+10\nSkill points +25/+40/+45" + ], + "Bonus at start": [ + "Skill points +120" + ], + "Failed training (Get Well Soon!)": [ + "Mood -1\nLast trained stat -5\n(random) Get Practice Poor status", + "Randomly either\n----------\nMood -1\nLast trained stat -10\n(random) Get Practice Poor status\n----------\n\n----------\nGet Practice Perfect ○ status" + ], + "Failed training (Don't Overdo It!)": [ + "Energy +10\nMood -2\nLast trained stat -10\n2 random stats -10\n(random) Get Practice Poor status", + "Randomly either\n----------\nMood -3\nLast trained stat -10\n2 random stats -10\nGet Practice Poor status\n----------\n\n----------\nEnergy +10\nGet Practice Perfect ○ status" + ], + "Extra Training": [ + "Energy -5\nLast trained stat +5\n(random) Heal a negative status effect\nYayoi Akikawa bond +5", + "Energy +5" + ], + "At Summer Camp (Year 2)": [ + "Power +10", + "Guts +10" + ], + "Fan Letter": [ + "Mood +1\nSkill points +30" + ], + "Dance Lesson": [ + "Power +10", + "Wit +10" + ], + "New Year's Resolutions": [ + "Power +10", + "Energy +20", + "Skill points +20" + ], + "New Year's Shrine Visit": [ + "Energy +30", + "All stats +5", + "Skill points +35" + ], + "Acupuncture (Just an Acupuncturist, No Worries! ☆)": [ + "Randomly either\n----------\nAll stats +20\nor (~70%)\nor (~70%)\nMood -2\nAll stats -15\nGet Night Owl status", + "Randomly either\n----------\nObtain Corner Recovery ○ skill\nObtain Straightaway Recovery skill\nor (~55%)\nor (~55%)\nEnergy -20\nMood -2", + "Randomly either\n----------\nMaximum Energy +12\nEnergy +40\nHeal all negative status effects\nor (~30%)\nor (~30%)\nEnergy -20\nMood -2\nGet Practice Poor status", + "Randomly either\n----------\nEnergy +20\nMood +1\nGet Charming ○ status\nor (~15%)\nor (~15%)\nEnergy -10/-20\nMood -1\n(random) Get Practice Poor status", + "Energy +10" + ], + "Victory! (G1)\n1st": [ + "Energy -15\n1 random stat +10\nSkill points +45\n(random) Hint for a skill related to the race", + "Energy -5/-20\n1 random stat +10\nSkill points +45\n(random) Hint for a skill related to the race" + ], + "Victory! (G2/G3)\n1st": [ + "Energy -15\n1 random stat +8\nSkill points +35\n(random) Hint for a skill related to the race", + "Energy -5/-20\n1 random stat +8\nSkill points +35\n(random) Hint for a skill related to the race" + ], + "Victory! (Pre/OP)\n1st": [ + "Energy -15\n1 random stat +5\nSkill points +30\n(random) Hint for a skill related to the race", + "Energy -5/-20\n1 random stat +5\nSkill points +30\n(random) Hint for a skill related to the race" + ], + "Solid Showing (G1)\n2nd-5th": [ + "Energy -20\n1 random stat +8\nSkill points +45\n(random) Hint for a skill related to the race", + "Energy -10/-30\n1 random stat +8\nSkill points +45\n(random) Hint for a skill related to the race" + ], + "Solid Showing (G2/G3)\n2nd-5th": [ + "Energy -20\n1 random stat +5\nSkill points +35\n(random) Hint for a skill related to the race", + "Energy -10/-30\n1 random stat +5\nSkill points +35\n(random) Hint for a skill related to the race" + ], + "Solid Showing (Pre/OP)\n2nd-5th": [ + "Energy -20\n1 random stat +3\nSkill points +30\n(random) Hint for a skill related to the race", + "Energy -10/-30\n1 random stat +3\nSkill points +30\n(random) Hint for a skill related to the race" + ], + "Defeat (G1)\n6th or worse": [ + "Energy -25\n1 random stat +4\nSkill points +25\n(random) Hint for a skill related to the race", + "Energy -15/-35\n1 random stat +4\nSkill points +25\n(random) Hint for a skill related to the race" + ], + "Defeat (G2/G3)\n6th or worse": [ + "Energy -25\n1 random stat +3\nSkill points +20\n(random) Hint for a skill related to the race", + "Energy -15/-35\n1 random stat +3\nSkill points +20\n(random) Hint for a skill related to the race" + ], + "Defeat (Pre/OP)\n6th or worse": [ + "Energy -25\nSkill points +10\n(random) Hint for a skill related to the race", + "Energy -15/-35\nSkill points +10\n(random) Hint for a skill related to the race" + ], + "Etsuko's Elated Coverage (G1)": [ + "Energy -15\nMood +1\n1 random stat +10\nSkill points +45\nFans +500\n(random) Hint for a skill related to the race\nEtsuko Otonashi bond +15" + ], + "Etsuko's Elated Coverage (G2/G3)": [ + "Energy -15\nMood +1\n1 random stat +8\nSkill points +35\nFans +500\n(random) Hint for a skill related to the race\nEtsuko Otonashi bond +15" + ], + "Etsuko's Elated Coverage (Pre/OP)": [ + "Energy -15\nMood +1\n1 random stat +5\nSkill points +30\nFans +500\n(random) Hint for a skill related to the race\nEtsuko Otonashi bond +15" + ], + "Etsuko's Exhaustive Coverage (G1)": [ + "Randomly either\n----------\nEnergy -25\nMood -1\n1 random stat +4\nSkill points +25\nEtsuko Otonashi bond -10\n----------\n\n----------\nEnergy -15\nMood +1\n1 random stat +4\nSkill points +25\nEtsuko Otonashi bond +15", + "Energy -20\n1 random stat +4\nSkill points +25\nEtsuko Otonashi bond +10" + ], + "Etsuko's Exhaustive Coverage (G2/G3)": [ + "Randomly either\n----------\nEnergy -25\nMood -1\n1 random stat +3\nSkill points +20\nEtsuko Otonashi bond -10\n----------\n\n----------\nEnergy -15\nMood +1\n1 random stat +3\nSkill points +20\nEtsuko Otonashi bond +15", + "Energy -20\n1 random stat +3\nSkill points +20\nEtsuko Otonashi bond +10" + ], + "Etsuko's Exhaustive Coverage (Pre/OP)": [ + "Randomly either\n----------\nEnergy -25\nMood -1\nSkill points +10\nEtsuko Otonashi bond -10\n----------\n\n----------\nEnergy -15\nMood +1\nSkill points +10\nEtsuko Otonashi bond +15", + "Energy -20\nSkill points +10\nEtsuko Otonashi bond +10" + ], + "No Backseating!": [ + "Wit +5" + ], + "What Comes of Connecting": [ + "Power +5" + ], + "Takarazuka Showdown!": [ + "Nothing happens" + ], + "First-Rate Terms": [ + "Guts +5" + ], + "I'm Rooting For You!": [ + "Speed +5" + ], + "This Time, Whatever It Takes": [ + "Power +10" + ], + "Repost to Apply": [ + "Wit -5\nMood -1" + ], + "Master Trainer": [ + "Power +10\n(random) Get Practice Perfect ○ status" + ], + "World-Class All-Rounder": [ + "All stats +5\nSkill points +5" + ], + "A Raffle Is a Raffle": [ + "Mood -1" + ], + "I Smell Moe": [ + "Energy -10\nMood -1" + ], + "Lucid Daydreaming!": [ + "All stats +5\nSkill points +30\nMood +2\n(random) Get Fast Learner status" + ], + "One More Song! Just One More!": [ + "Randomly either\n----------\nEnergy +15\nMood -1\nGuts -5\n----------\n\n----------\nEnergy +15\nGuts -5\nGet Slacker status\n----------\n\n----------\nEnergy +15\nMood -1\nGuts -5\nGet Slacker status" + ] + }, "Agnes Tachyon": { "Expression of Conviction": [ "Stamina +20", @@ -862,7 +1070,7 @@ "Mood +1\nWit +5" ], "Number One and the Coolest": [ - "All stats +15\nSkill points +50\nUnyielding Spirit hint +2\nHead-On hint +2\nNimble Navigator hint +1" + "All stats +15\nSkill points +50\nUnyielding Spirit hint +2\nHead-On hint +2" ], "The Japanese Derby": [ "Standard race rewards\nMood +1" @@ -3873,10 +4081,6 @@ "Prudent Positioning hint +2\n(random) Get Hot Topic status", "Wit +10\nSkill points +15\n(random) Get Hot Topic status" ], - "Queen of the Island": [ - "Speed +10", - "Stamina +10" - ], "It's Called a Sea Pineapple!": [ "Skill points +30", "Stamina +10" @@ -4065,6 +4269,10 @@ "Elegance": [ "Stamina +10\nPower +10\n(random) Get Practice Perfect ○ status", "Wit +20\n(random) Get Practice Perfect ○ status" + ], + "Queen of the Island": [ + "Speed +10", + "Stamina +10" ] }, "Mejiro Ryan": { @@ -7580,7 +7788,7 @@ "Standard race rewards\nNimble Navigator hint +1" ], "After the Yasuda Kinen: Consecutive Celebration": [ - "All stats +3\nSkill points +45\nNimble Navigator hint +1\nGet Practice Perfect ○ status\nYayoi Akikawa bond +4" + "All stats +3\nPower +20\nSkill points +65\nNimble Navigator hint +1\nGet Practice Perfect ○ status\nYayoi Akikawa bond +4" ], "Bonus at start": [ "Skill points +120" diff --git a/src/data/supports.json b/src/data/supports.json index 0670a43e..59601014 100644 --- a/src/data/supports.json +++ b/src/data/supports.json @@ -1,4 +1,31 @@ { + "Ikuno Dictus": { + "(❯) That's How U Solve the Case": [ + "Maximum Energy +4\nStamina +15\nWit +5\nIkuno Dictus bond +10" + ], + "(❯❯) Are U the Problem?": [ + "Stamina +15\nGuts +15\nGo with the Flow hint +1\nIkuno Dictus bond +10", + "Energy +20\nSpeed +10\nSkill points +10\nEvent chain ended" + ], + "(❯❯❯) Just U and I": [ + "Stamina +30\nGuts +10\nSkill points +20\nThe Bigger Picture hint +3" + ], + "Ikuno-Style Flawless Method": [ + "Wit +10\nIkuno Dictus bond +5", + "Skill points +30\nIkuno Dictus bond +5" + ], + "Ikuno-Style Management": [ + "Stamina +20\nIkuno Dictus bond +5", + "Trick (Rear) hint +1\nIkuno Dictus bond +5" + ], + "(❯) Ikuno-Style Friendship": [ + "Wit +10\nSkill points +20" + ], + "(❯❯) Ikuno-Style Support": [ + "Wit +15\nFrenzied Front Runners hint +3\nIkuno Dictus bond +5", + "Wit +15\nFrenzied End Closers hint +3\nIkuno Dictus bond +5" + ] + }, "Rice Shower": { "(❯) Rainy Day, Floating Flowers": [ "Power +10\nRice Shower bond +5", @@ -155,7 +182,7 @@ ], "(❯) Some Very Green Friends": [ "Speed +5\nSkill points +10\nLucky Seven hint +1\nSweep Tosho bond +5", - "Mood -1\nMaverick ○ hint +5" + "Mood -1\nMaverick ○ hint +5\nEvent chain ended" ], "(❯❯) Premeditated Mischief": [ "Speed +10\nSkill points +20\nLevelheaded hint +1\nSweep Tosho bond +5", @@ -305,8 +332,8 @@ }, "King Halo": { "(❯) A Captivating Invitation": [ - "Power +10\nKing Halo bond +5", - "Guts +10\nKing Halo bond +5" + "Power +10\nStop Right There! hint +1\nKing Halo bond +5", + "Guts +10\nUma Stan hint +1\nKing Halo bond +5" ], "(❯❯) Dancer's Pride": [ "Energy -5\nStamina +15\nPower +15\nSkill points +20\nKing Halo bond +5", @@ -491,8 +518,8 @@ }, "Yukino Bijin": { "(❯) Cozy Memories of Wanko Soba": [ - "Mood +1\nYukino Bijin bond +5", - "Maximum Energy +4\nYukino Bijin bond +5" + "Mood +1\nInner Post Proficiency ○ hint +1\nYukino Bijin bond +5", + "Maximum Energy +4\nHydrate hint +1\nYukino Bijin bond +5" ], "(❯❯) The Class Rep's Intense Crash Course": [ "Mood +2\nPower +10\nYukino Bijin bond +5\nHeal a negative status effect", @@ -698,7 +725,7 @@ "Power +10\nSkill points +15\nVodka bond +5" ], "(❯❯) A Vow to the Setting Sun": [ - "Power +15\nSkill points +15\nUpdrafters hint +1\nVodka bond +5" + "Power +15\nSkill points +15\nUpdrafters hint +2\nVodka bond +5" ], "(❯❯❯) Put That on the Record, Okay?": [ "Power +25\nSkill points +30\nBreath of Fresh Air hint +2\nVodka bond +5" @@ -790,6 +817,12 @@ "Reminiscent Clover": [ "Corner Adept ○ hint +1\nFine Motion bond +5", "Guts +15\nFine Motion bond +5" + ], + "(❯) What Even Is Normal?": [ + "Energy +15\nPower +5\nSkill points +10" + ], + "(❯❯) Clovers For You Too ♪": [ + "Skill points +20\nSteadfast hint +2" ] }, "Ines Fujin": { @@ -889,11 +922,11 @@ }, "Smart Falcon": { "(❯) Always on Stage ☆": [ - "Wit +10\nSmart Falcon bond +5", + "Wit +10\nGroundwork hint +1\nSmart Falcon bond +5", "Energy +25\nFocus hint +1\nSmart Falcon bond +5\nEvent chain ended" ], "(❯❯) Shining Always and Everywhere ☆": [ - "Speed +5\nPower +5" + "Speed +10\nPower +10\nTop Pick hint +1" ], "(❯❯❯) My Umadol Way ☆": [ "Randomly either\n----------\nMaximum Energy +4\nStamina +5\nPower +5\nWit +5\nPrudent Positioning hint +1\n----------\n\n----------\nMaximum Energy +4\nStamina +15\nPower +15\nWit +15\nCenter Stage hint +3" @@ -1119,23 +1152,6 @@ "Medium Straightaways ○ hint +1\nZenno Rob Roy bond +5" ] }, - "Ikuno Dictus": { - "(❯) Ikuno-Style Friendship": [ - "Wit +10\nSkill points +20" - ], - "(❯❯) Ikuno-Style Support": [ - "Wit +15\nFrenzied Front Runners hint +3\nIkuno Dictus bond +5", - "Wit +15\nFrenzied End Closers hint +3\nIkuno Dictus bond +5" - ], - "Ikuno-Style Flawless Method": [ - "Wit +10\nIkuno Dictus bond +5", - "Skill points +30\nIkuno Dictus bond +5" - ], - "Ikuno-Style Management": [ - "Stamina +20\nIkuno Dictus bond +5", - "Trick (Rear) hint +1\nIkuno Dictus bond +5" - ] - }, "Daitaku Helios": { "(❯) #BFF #Party!": [ "Power +10\nDaitaku Helios bond +5",