diff --git a/app/build.gradle b/app/build.gradle index 111722c4..60d51bda 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -197,6 +197,18 @@ dependencies { implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0' + implementation 'com.github.sephiroth74:NumberSlidingPicker:v1.0.3' + + implementation 'com.github.tony19:logback-android:2.0.0' + implementation('com.github.toomasr:sgf4j:sgf4j-parser-0.0.6') { + // Force to use android logging + exclude group: 'org.apache.logging.log4j', module: 'log4j-slf4j-impl' + exclude group: 'org.apache.logging.log4j', module: 'log4j-api' + exclude group: 'org.apache.logging.log4j', module: 'log4j-core' + } + + implementation 'com.vmadalin:easypermissions-ktx:1.0.0' + testImplementation 'com.nhaarman:mockito-kotlin-kt1.1:1.6.0' testImplementation 'junit:junit:4.13.2' testImplementation "io.insert-koin:koin-test-junit4:$koin_version" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a23274dc..7ca3c3bc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,8 @@ + + + + + + + + + + + + + + + + + + + + , komi: Float? = null, - maxVisits: Int? = null, + maxVisits: Int? = settingsRepository.maxVisits, includeOwnership: Boolean? = null, includeMovesOwnership: Boolean? = null, includePolicy: Boolean? = null diff --git a/app/src/main/java/io/zenandroid/onlinego/data/model/local/SgfData.kt b/app/src/main/java/io/zenandroid/onlinego/data/model/local/SgfData.kt new file mode 100644 index 00000000..146f3bfa --- /dev/null +++ b/app/src/main/java/io/zenandroid/onlinego/data/model/local/SgfData.kt @@ -0,0 +1,27 @@ +package io.zenandroid.onlinego.data.model.local + +import android.util.Log +import io.zenandroid.onlinego.data.model.Position +import io.zenandroid.onlinego.data.model.ogs.GameData +import io.zenandroid.onlinego.data.model.ogs.OGSGame +import io.zenandroid.onlinego.data.model.ogs.Phase +import io.zenandroid.onlinego.data.ogs.TimeControl +import io.zenandroid.onlinego.utils.toEpochMicros + +data class SgfData( + val name: String?, + var position: Position?, + var handicap: Int?, + val rules: String? +) { + companion object { + fun fromString(game: String): SgfData { + return SgfData( + name = null, + handicap = null, + rules = null, + position = null + ) + } + } +} diff --git a/app/src/main/java/io/zenandroid/onlinego/data/repositories/SettingsRepository.kt b/app/src/main/java/io/zenandroid/onlinego/data/repositories/SettingsRepository.kt index 735e9066..3ea89862 100644 --- a/app/src/main/java/io/zenandroid/onlinego/data/repositories/SettingsRepository.kt +++ b/app/src/main/java/io/zenandroid/onlinego/data/repositories/SettingsRepository.kt @@ -9,6 +9,8 @@ private const val BOARD_THEME = "board_theme" private const val SHOW_RANKS = "show_ranks" private const val SHOW_COORDINATES = "show_coordinates" private const val SOUND = "sound" +private const val MAX_VISITS = "max_ai_visits" +private const val DETAILED_ANALYSIS = "detailed_analysis" class SettingsRepository { private val prefs = PreferenceManager.getDefaultSharedPreferences(OnlineGoApplication.instance.baseContext) @@ -44,4 +46,12 @@ class SettingsRepository { var sound: Boolean get() = prefs.getBoolean(SOUND, true) set(value) = prefs.edit().putBoolean(SOUND, value).apply() -} \ No newline at end of file + + var maxVisits: Int + get() = prefs.getInt(MAX_VISITS, 30) + set(value) = prefs.edit().putInt(MAX_VISITS, value).apply() + + var detailedAnalysis: Boolean + get() = prefs.getBoolean(DETAILED_ANALYSIS, false) + set(value) = prefs.edit().putBoolean(DETAILED_ANALYSIS, value).apply() +} diff --git a/app/src/main/java/io/zenandroid/onlinego/ui/composables/BoardComposable.kt b/app/src/main/java/io/zenandroid/onlinego/ui/composables/BoardComposable.kt index 5277cab0..e1ba73a0 100644 --- a/app/src/main/java/io/zenandroid/onlinego/ui/composables/BoardComposable.kt +++ b/app/src/main/java/io/zenandroid/onlinego/ui/composables/BoardComposable.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.res.imageResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp +import io.zenandroid.onlinego.OnlineGoApplication import io.zenandroid.onlinego.data.model.BoardTheme import io.zenandroid.onlinego.data.model.Cell import io.zenandroid.onlinego.data.model.Position @@ -35,6 +36,7 @@ import io.zenandroid.onlinego.data.model.StoneType import io.zenandroid.onlinego.data.model.ogs.PlayCategory import io.zenandroid.onlinego.data.repositories.SettingsRepository import io.zenandroid.onlinego.gamelogic.RulesManager.isPass +import io.zenandroid.onlinego.utils.recordException import org.koin.core.context.GlobalContext import kotlin.math.ceil import kotlin.math.roundToInt @@ -68,6 +70,12 @@ fun Board( // Board background image, it is either a jpg or a svg val backgroundImage: ImageBitmap? = boardTheme.backgroundImage?.let { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + boardTheme.backgroundImage.run { + Exception("blep: $this, ${OnlineGoApplication.instance.getDrawable(this)}") + .let(::recordException) + } + } ImageBitmap.imageResource(id = boardTheme.backgroundImage) } val backgroundColor: Color? = boardTheme.backgroundColor?.let { diff --git a/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/AiGameAction.kt b/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/AiGameAction.kt index ca1dc6c1..13b1d0c8 100644 --- a/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/AiGameAction.kt +++ b/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/AiGameAction.kt @@ -4,9 +4,10 @@ import io.zenandroid.onlinego.data.model.Cell import io.zenandroid.onlinego.data.model.Position import io.zenandroid.onlinego.data.model.katago.KataGoResponse.Response import io.zenandroid.onlinego.data.model.katago.MoveInfo +import io.zenandroid.onlinego.data.model.local.SgfData sealed class AiGameAction { - object ViewReady: AiGameAction() + class ViewReady(val loadData: SgfData?, val savedData: String?): AiGameAction() object ViewPaused: AiGameAction() class RestoredState(val state: AiGameState): AiGameAction() object CantRestoreState: AiGameAction() @@ -16,6 +17,7 @@ sealed class AiGameAction { class NewGame( val size: Int, val youPlayBlack: Boolean, + val youPlayWhite: Boolean, val handicap: Int ): AiGameAction() @@ -28,11 +30,12 @@ sealed class AiGameAction { class NewPosition(val newPos: Position): AiGameAction() class AIMove(val newPos: Position, val aiAnalisis: Response, val selectedMove: MoveInfo): AiGameAction() + object NextPlayerChanged: AiGameAction() object AIError: AiGameAction() class AIHint(val aiAnalisis: Response): AiGameAction() class AIOwnershipResponse(val aiAnalisis: Response): AiGameAction() object HideOwnership: AiGameAction() - class ScoreComputed(val newPos: Position, val whiteScore: Float, val blackScore: Int, val aiWon: Boolean, val aiAnalisis: Response): AiGameAction() + class ScoreComputed(val newPos: Position, val whiteScore: Float, val blackScore: Int, val whiteWon: Boolean, val aiAnalisis: Response): AiGameAction() // User actions @@ -46,6 +49,7 @@ sealed class AiGameAction { object UserAskedForOwnership: AiGameAction() class UserTriedSuicidalMove(val coordinate: Cell): AiGameAction() class UserTriedKoMove(val coordinate: Cell): AiGameAction() + object ToggleAIBlack: AiGameAction() + object ToggleAIWhite: AiGameAction() - -} \ No newline at end of file +} diff --git a/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/AiGameFragment.kt b/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/AiGameFragment.kt index f3c7d5d2..e4526c57 100644 --- a/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/AiGameFragment.kt +++ b/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/AiGameFragment.kt @@ -1,7 +1,12 @@ package io.zenandroid.onlinego.ui.screens.localai +import android.app.Activity.RESULT_OK +import android.content.Intent import android.graphics.Color +import android.Manifest.permission +import android.net.Uri import android.os.Bundle +import android.os.StrictMode import android.util.Log import android.view.LayoutInflater import android.view.View @@ -16,16 +21,28 @@ import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.transition.DrawableCrossFadeFactory import com.jakewharton.rxbinding3.view.clicks +import com.toomasr.sgf4j.Sgf +import com.toomasr.sgf4j.parser.Game +import com.toomasr.sgf4j.parser.GameNode +import com.vmadalin.easypermissions.* import io.reactivex.Observable import io.reactivex.subjects.PublishSubject import io.zenandroid.onlinego.R +import io.zenandroid.onlinego.data.model.Cell +import io.zenandroid.onlinego.data.model.local.SgfData +import io.zenandroid.onlinego.data.model.Position +import io.zenandroid.onlinego.data.model.StoneType import io.zenandroid.onlinego.data.repositories.SettingsRepository import io.zenandroid.onlinego.data.repositories.UserSessionRepository import io.zenandroid.onlinego.databinding.FragmentAigameBinding +import io.zenandroid.onlinego.gamelogic.RulesManager +import io.zenandroid.onlinego.gamelogic.Util.getSGFCoordinates import io.zenandroid.onlinego.mvi.MviView import io.zenandroid.onlinego.ui.screens.localai.AiGameAction.DismissNewGameDialog import io.zenandroid.onlinego.ui.screens.localai.AiGameAction.NewGame import io.zenandroid.onlinego.ui.screens.localai.AiGameAction.ShowNewGameDialog +import io.zenandroid.onlinego.ui.screens.localai.AiGameAction.ToggleAIBlack +import io.zenandroid.onlinego.ui.screens.localai.AiGameAction.ToggleAIWhite import io.zenandroid.onlinego.ui.screens.localai.AiGameAction.UserAskedForHint import io.zenandroid.onlinego.ui.screens.localai.AiGameAction.UserAskedForOwnership import io.zenandroid.onlinego.ui.screens.localai.AiGameAction.UserPressedNext @@ -36,6 +53,11 @@ import io.zenandroid.onlinego.ui.screens.localai.AiGameAction.ViewReady import io.zenandroid.onlinego.utils.analyticsReportScreen import io.zenandroid.onlinego.utils.processGravatarURL import io.zenandroid.onlinego.utils.showIf +import java.io.BufferedReader +import java.io.File +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response import org.koin.android.ext.android.get import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel @@ -45,6 +67,8 @@ class AiGameFragment : Fragment(), MviView { private val viewModel: AiGameViewModel by viewModel() private val settingsRepository: SettingsRepository by inject() private var bottomSheet: NewGameBottomSheet? = null + private var setupSgf: SgfData? = null + private var savedSgf: String? = null private lateinit var binding: FragmentAigameBinding private val internalActions = PublishSubject.create() @@ -68,9 +92,16 @@ class AiGameFragment : Fragment(), MviView { binding.hintButton.clicks() .map { UserAskedForHint }, binding.ownershipButton.clicks() - .map { UserAskedForOwnership } + .map { UserAskedForOwnership }, + binding.nameButtonLeft.clicks() + .map { ToggleAIBlack }, + binding.nameButtonRight.clicks() + .map { ToggleAIWhite } ) - ).startWith(ViewReady) + ).startWith(ViewReady(setupSgf, savedSgf).also { + setupSgf = null + savedSgf = null + }) override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { binding = FragmentAigameBinding.inflate(inflater, container, false) @@ -86,11 +117,14 @@ class AiGameFragment : Fragment(), MviView { override fun onResume() { super.onResume() analyticsReportScreen("AiGame") + + getArguments()?.getString("SGF_LOCAL")?.let { loadSGF(Uri.parse(it)) } + getArguments()?.getString("SGF_REMOTE")?.let { loadSGF(Uri.parse(it)) } + binding.board.apply { drawCoordinates = settingsRepository.showCoordinates } - view?.doOnLayout { binding.iconContainerLeft.radius = binding.iconContainerLeft.width / 2f binding.iconContainerRight.radius = binding.iconContainerRight.width / 2f @@ -118,6 +152,7 @@ class AiGameFragment : Fragment(), MviView { fadeOutRemovedStones = state.showFinalTerritory drawAiEstimatedOwnership = state.showAiEstimatedTerritory ownership = state.aiAnalysis?.ownership + hintBasis = if(state.showHints) state.aiAnalysis?.rootInfo else null hints = if(state.showHints) state.aiAnalysis?.moveInfos else null state.position?.let { position = it @@ -130,10 +165,44 @@ class AiGameFragment : Fragment(), MviView { binding.hintButton.showIf(state.hintButtonVisible) binding.ownershipButton.showIf(state.ownershipButtonVisible) + binding.nameButtonLeft.setText(if(state.enginePlaysBlack) "KataGo" else "Player") + binding.nameButtonRight.setText(if(state.enginePlaysWhite) "KataGo" else "Player") if(state.newGameDialogShown && bottomSheet?.isShowing != true) { - bottomSheet = NewGameBottomSheet(requireContext()) { size, youPlayBlack, handicap -> - internalActions.onNext(NewGame(size, youPlayBlack, handicap)) - }.apply { + bottomSheet = NewGameBottomSheet(requireContext(), { size, youPlayBlack, youPlayWhite, handicap -> + internalActions.onNext(NewGame(size, youPlayBlack, youPlayWhite, handicap)) + }, { + if (EasyPermissions.hasPermissions(requireContext(), permission.READ_EXTERNAL_STORAGE)) { + var chooseFile = Intent(Intent.ACTION_GET_CONTENT) + chooseFile.setType("application/x-go-sgf") + chooseFile = Intent.createChooser(chooseFile, "Choose a file") + startActivityForResult(chooseFile, 1) + internalActions.onNext(DismissNewGameDialog) + } else { + EasyPermissions.requestPermissions( + host = this, + rationale = "App needs Read Storage permission to load files", + requestCode = -1, + permission.READ_EXTERNAL_STORAGE + ) + } + }, { + if (EasyPermissions.hasPermissions(requireContext(), permission.WRITE_EXTERNAL_STORAGE)) { + var chooseFile = Intent(Intent.ACTION_CREATE_DOCUMENT); + chooseFile.addCategory(Intent.CATEGORY_OPENABLE) + chooseFile.setType("application/x-go-sgf") + chooseFile.putExtra(Intent.EXTRA_TITLE, "go.sgf") + saveSGF(state) + startActivityForResult(chooseFile, 2) + internalActions.onNext(DismissNewGameDialog) + } else { + EasyPermissions.requestPermissions( + host = this, + rationale = "App needs Write Storage permission to load files", + requestCode = -1, + permission.WRITE_EXTERNAL_STORAGE + ) + } + }).apply { setOnCancelListener { internalActions.onNext(DismissNewGameDialog) } @@ -150,13 +219,13 @@ class AiGameFragment : Fragment(), MviView { binding.winrateProgressBar.progress = winrateAsPercentage.toInt() } state.position?.let { - binding.prisonersLeft.text = if(state.enginePlaysBlack) it.blackCaptureCount.toString() else it.whiteCaptureCount.toString() - binding.prisonersRight.text = if(state.enginePlaysBlack) it.whiteCaptureCount.toString() else it.blackCaptureCount.toString() - binding.komiLeft.text = if(state.enginePlaysBlack) "" else it.komi.toString() - binding.komiRight.text = if(state.enginePlaysBlack) it.komi.toString() else "" + binding.prisonersLeft.text = it.blackCaptureCount.toString() + binding.prisonersRight.text = it.whiteCaptureCount.toString() + binding.komiLeft.text = "-" + binding.komiRight.text = it.komi.toString() } - binding.colorIndicatorLeft.setColorFilter(if(state.enginePlaysBlack) Color.BLACK else Color.WHITE) - binding.colorIndicatorRight.setColorFilter(if(state.enginePlaysBlack) Color.WHITE else Color.BLACK) + binding.colorIndicatorLeft.setColorFilter(Color.BLACK) + binding.colorIndicatorRight.setColorFilter(Color.WHITE) state.chatText?.let { binding.chatBubble.visibility = VISIBLE @@ -170,4 +239,126 @@ class AiGameFragment : Fragment(), MviView { binding.scoreleadLabel.text = "Score prediction: $leader leads by $lead" } } -} \ No newline at end of file + + private fun loadSGF(data: Uri) { + val stream = when(data.getScheme()) { + "http", "https" -> OkHttpClient.Builder() + .cookieJar(get().cookieJar) + .followRedirects(true) + .build().let { client -> + val threadPolicy = StrictMode.ThreadPolicy.Builder().permitAll().build() + StrictMode.setThreadPolicy(threadPolicy) // UI thread for this intent only + + val request = Request.Builder().url(data.toString()).build() + val response = client.newCall(request).execute() + response?.body?.byteStream() + } + else -> requireContext().getContentResolver().openInputStream(data) + } + val text = stream?.bufferedReader()?.use(BufferedReader::readText) + Log.d("AiGameFragment", "onLoad(\"${data}\") = \"${text}\"") + val sgf = text?.let { Sgf.createFromString(it) } + Log.d("AiGameFragment", "SGF ${sgf.toString()}") + + val size = sgf?.getProperty("SZ")?.split(":")?.let { it.plus(it) }?.take(2) + var pos = Position(size!![0].toInt(), size!![1].toInt()) + sgf?.getProperty("KM")?.toFloat()?.let { pos = pos.copy(komi = it) } + var handi = sgf?.getProperty("HA")?.toInt() + var name = sgf?.getProperty("GN") ?: data.getPath() + var move = sgf?.getRootNode()?.getNextNode() + while (move != null) { + Log.d("AiGameFragment", "makeMove(\"${move}\")") + val colour = when(move.getColor()) { + "W" -> StoneType.WHITE + "B" -> StoneType.BLACK + else -> null + }!! + if (pos.nextToMove != colour) { + pos = RulesManager.makeMove(pos, pos.nextToMove, Cell(-1, -1))!!.let { + it.copy( + nextToMove = it.nextToMove.opponent + ) + } + } + pos = RulesManager.makeMove(pos, colour, + if (move.getMoveString().isNullOrBlank()) { + Cell(-1, -1) + } else { + move.getCoords().let { + Cell(it[0], it[1]) + } + } + )!! + pos = pos.copy(nextToMove = pos.nextToMove.opponent) + move = move.getNextNode() + } + Log.d("AiGameFragment", "loadPosition(\"${pos}\")") + setupSgf = SgfData( + position = pos, + handicap = handi, + name = name, + rules = null + ) + } + + private fun saveSGF(state: AiGameState) { + val game = Game() + state.handicap?.let { game.addProperty("HA", it.toString()) } + state.position?.let { + if(it.boardWidth == it.boardHeight) { + game.addProperty("SZ", "${it.boardWidth}") + } else { + game.addProperty("SZ", "${it.boardWidth}:${it.boardHeight}") + } + it.komi?.let { game.addProperty("KM", it.toString()) } + + val positions = state.history + Log.d("AiGameFragment", "Serializing ${positions.size} moves") + + var cursor = game.getRootNode() + positions.reversed().forEach { position -> + Log.d("AiGameFragment", "Serializing ${position}") + position.lastMove?.let { + if (it.x == -1 || it.y == -1) { + Log.d("AiGameFragment", "mkPass") + } else { + val node = GameNode(cursor) + if (cursor == null) game.setRootNode(node) + else cursor.addChild(node) + cursor = node + val colour = when(position.lastPlayerToMove) { + StoneType.WHITE -> "W" + StoneType.BLACK -> "B" + else -> null + }!! + node.addProperty(colour, getSGFCoordinates(it)) + Log.d("AiGameFragment", "mkNode(${node})") + } + } + } + } + Log.d("AiGameFragment", "sgf(${game.getGeneratedSgf()})") + savedSgf = game.getGeneratedSgf() + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if(requestCode == 1 && resultCode == RESULT_OK) + data?.getData()?.let { + loadSGF(it) + } + if(requestCode == 2 && resultCode == RESULT_OK) + data?.getData()?.let { + Log.d("AiGameFragment", "onSave(\"${it}\") = \"${savedSgf}\"") + val stream = requireContext().getContentResolver().openOutputStream(it) + stream?.bufferedWriter()?.use { out -> out.write(savedSgf!!) } + } + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + + // EasyPermissions handles the request result. + EasyPermissions.onRequestPermissionsResult(requestCode, permissions, grantResults, this) + } +} diff --git a/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/AiGameReducer.kt b/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/AiGameReducer.kt index d5392b3b..ea41882d 100644 --- a/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/AiGameReducer.kt +++ b/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/AiGameReducer.kt @@ -1,6 +1,8 @@ package io.zenandroid.onlinego.ui.screens.localai import android.util.Log +import io.zenandroid.onlinego.data.model.Position +import io.zenandroid.onlinego.data.model.StoneType import io.zenandroid.onlinego.gamelogic.RulesManager import io.zenandroid.onlinego.gamelogic.RulesManager.isGameOver import io.zenandroid.onlinego.mvi.Reducer @@ -17,10 +19,13 @@ import io.zenandroid.onlinego.ui.screens.localai.AiGameAction.GenerateAiMove import io.zenandroid.onlinego.ui.screens.localai.AiGameAction.HideOwnership import io.zenandroid.onlinego.ui.screens.localai.AiGameAction.NewGame import io.zenandroid.onlinego.ui.screens.localai.AiGameAction.NewPosition +import io.zenandroid.onlinego.ui.screens.localai.AiGameAction.NextPlayerChanged import io.zenandroid.onlinego.ui.screens.localai.AiGameAction.PromptUserForMove import io.zenandroid.onlinego.ui.screens.localai.AiGameAction.RestoredState import io.zenandroid.onlinego.ui.screens.localai.AiGameAction.ScoreComputed import io.zenandroid.onlinego.ui.screens.localai.AiGameAction.ShowNewGameDialog +import io.zenandroid.onlinego.ui.screens.localai.AiGameAction.ToggleAIBlack +import io.zenandroid.onlinego.ui.screens.localai.AiGameAction.ToggleAIWhite import io.zenandroid.onlinego.ui.screens.localai.AiGameAction.UserAskedForHint import io.zenandroid.onlinego.ui.screens.localai.AiGameAction.UserAskedForOwnership import io.zenandroid.onlinego.ui.screens.localai.AiGameAction.UserHotTrackedCoordinate @@ -50,9 +55,30 @@ class AiGameReducer : Reducer { ViewPaused, UserPressedBack, UserPressedPass -> { state } - ViewReady -> state.copy( - chatText = "Give me a second, I'm getting ready..." - ) + is ViewReady -> state.copy( + chatText = "Give me a second, I'm getting ready...", + ).let { + action.loadData?.let { data -> + Log.d("AiGameReducer", "Game Loaded") + it.copy( + position = data.position, + boardSize = when(data.position?.boardWidth) { + data.position?.boardHeight -> data.position?.boardWidth + else -> null //nonsquare + }!!, + handicap = data.handicap ?: 0, + enginePlaysBlack = false, + enginePlaysWhite = false, + chatText = "Game Loaded!", + redoPosStack = emptyList() + ) + } ?: action.savedData?.let { data -> + Log.d("AiGameReducer", "Game Saved") + it.copy( + chatText = "Game Saved!", + ) + } ?: it + } is NewPosition -> { val newVariation = if(state.history.lastOrNull() == action.newPos) state.history else state.history + action.newPos state.copy( @@ -63,13 +89,13 @@ class AiGameReducer : Reducer { boardIsInteractive = false, showHints = false, chatText = when { - newVariation.isGameOver() && state.aiWon == true -> "Game ended because of two passes. Final score is black ${state.finalBlackScore?.toInt()} to white ${state.finalWhiteScore}. Looks like I win this time." - newVariation.isGameOver() && state.aiWon == false -> "Game ended because of two passes. Final score is black ${state.finalBlackScore?.toInt()} to white ${state.finalWhiteScore}. Congrats, looks like you got the better of me." - newVariation.isGameOver() && state.aiWon == null -> "Game ended because of two passes. Hang on, I'm computing the final score." + newVariation.isGameOver() && state.whiteWon == true -> "Game ended because of two passes. Final score is black ${state.finalBlackScore?.toInt()} to white ${state.finalWhiteScore}. White wins!" + newVariation.isGameOver() && state.whiteWon == false -> "Game ended because of two passes. Final score is black ${state.finalBlackScore?.toInt()} to white ${state.finalWhiteScore}. Black wins!" + newVariation.isGameOver() && state.whiteWon == null -> "Game ended because of two passes. Hang on, I'm computing the final score." else -> state.chatText }, showAiEstimatedTerritory = false, - showFinalTerritory = newVariation.isGameOver() && state.aiWon != null, + showFinalTerritory = newVariation.isGameOver() && state.whiteWon != null, hintButtonVisible = !newVariation.isGameOver(), ownershipButtonVisible = !newVariation.isGameOver() ) @@ -81,11 +107,11 @@ class AiGameReducer : Reducer { passButtonEnabled = false, redoPosStack = emptyList(), boardIsInteractive = false, - chatText = if (action.aiWon) "Game ended because of two passes. Final score is black ${action.blackScore} to white ${action.whiteScore}. Looks like I win this time." - else "Game ended because of two passes. Final score is black ${action.blackScore} to white ${action.whiteScore}. Congrats, looks like you got the better of me.", - finalWhiteScore = action.whiteScore, + chatText = if (action.whiteWon) "Game ended because of two passes. Final score is black ${action.blackScore} to white ${action.whiteScore}. White wins!" + else "Game ended because of two passes. Final score is black ${action.blackScore} to white ${action.whiteScore}. Black wins!", + finalWhiteScore = action.whiteScore.toFloat(), finalBlackScore = action.blackScore.toFloat(), - aiWon = action.aiWon, + whiteWon = action.whiteWon, previousButtonEnabled = true, showAiEstimatedTerritory = false, showFinalTerritory = true, @@ -104,9 +130,9 @@ class AiGameReducer : Reducer { aiAnalysis = action.aiAnalisis, aiQuickEstimation = action.selectedMove, chatText = when { - newVariation.isGameOver() && state.aiWon == true -> "Game ended because of two passes. Final score is black ${state.finalBlackScore?.toInt()} to white ${state.finalWhiteScore}. Looks like I win this time." - newVariation.isGameOver() && state.aiWon == false -> "Game ended because of two passes. Final score is black ${state.finalBlackScore?.toInt()} to white ${state.finalWhiteScore}. Congrats, looks like you got the better of me." - newVariation.isGameOver() && state.aiWon == null -> "Game ended because of two passes. Hang on, I'm computing the final score." + newVariation.isGameOver() && state.whiteWon == true -> "Game ended because of two passes. Final score is black ${state.finalBlackScore?.toInt()} to white ${state.finalWhiteScore}. White wins!" + newVariation.isGameOver() && state.whiteWon == false -> "Game ended because of two passes. Final score is black ${state.finalBlackScore?.toInt()} to white ${state.finalWhiteScore}. Black wins!" + newVariation.isGameOver() && state.whiteWon == null -> "Game ended because of two passes. Hang on, I'm computing the final score." else -> state.chatText } ) @@ -123,11 +149,18 @@ class AiGameReducer : Reducer { passButtonEnabled = true, previousButtonEnabled = state.history.size > 2, chatText = when { - state.engineStarted && state.position?.lastMove?.x == -1 -> "Pass! If you agree the game is over you should pass as well." - state.position != null && state.engineStarted -> "Your turn!" + state.engineStarted && state.position?.lastMove?.x == -1 -> "Pass! Another pass will conclude the game." + state.engineStarted && state.position?.nextToMove == StoneType.WHITE -> "White's turn!" + state.engineStarted && state.position?.nextToMove == StoneType.BLACK -> "Black's turn!" else -> state.chatText } ) + NextPlayerChanged -> state.copy( + boardIsInteractive = !state.boardIsInteractive, + passButtonEnabled = !state.passButtonEnabled, + previousButtonEnabled = !state.previousButtonEnabled, + nextButtonEnabled = !state.nextButtonEnabled + ) is UserHotTrackedCoordinate -> state.copy( candidateMove = action.coordinate ) @@ -146,12 +179,15 @@ class AiGameReducer : Reducer { chatText = "An error occurred communicating with the AI" ) UserPressedPrevious -> { - val newHistory = state.history.dropLast(2) + val newHistory = if(aiMovedLast(state) && !aiOnlyGame(state)) state.history.dropLast(2) + else state.history.dropLast(1) + val removedHistory = if(aiMovedLast(state) && !aiOnlyGame(state)) state.history.takeLast(2) + else state.history.takeLast(1) state.copy( position = newHistory.lastOrNull(), - redoPosStack = state.redoPosStack + state.history.takeLast(2), + redoPosStack = state.redoPosStack + removedHistory, history = newHistory, - previousButtonEnabled = newHistory.size > 2, + previousButtonEnabled = newHistory.size > 1, showHints = false, hintButtonVisible = true, ownershipButtonVisible = true, @@ -161,7 +197,7 @@ class AiGameReducer : Reducer { boardIsInteractive = true, passButtonEnabled = true, chatText = "Ok, let's try again. Your turn!", - aiWon = null, + whiteWon = null, finalBlackScore = null, finalWhiteScore = null ) @@ -188,9 +224,10 @@ class AiGameReducer : Reducer { boardSize = action.size, handicap = action.handicap, enginePlaysBlack = !action.youPlayBlack, + enginePlaysWhite = !action.youPlayWhite, newGameDialogShown = false, showHints = false, - aiWon = null, + whiteWon = null, finalWhiteScore = null, finalBlackScore = null, showFinalTerritory = false, @@ -238,6 +275,12 @@ class AiGameReducer : Reducer { chatText = "Ok, your turn", boardIsInteractive = true ) + ToggleAIBlack -> state.copy( + enginePlaysBlack = !state.enginePlaysBlack + ) + ToggleAIWhite -> state.copy( + enginePlaysWhite = !state.enginePlaysWhite + ) is EngineWouldNotStart -> state.copy( boardIsInteractive = false, hintButtonVisible = false, @@ -246,4 +289,12 @@ class AiGameReducer : Reducer { ) } } -} \ No newline at end of file + + private fun aiMovedLast(state: AiGameState): Boolean = + (state.position?.lastPlayerToMove == StoneType.BLACK && state.enginePlaysBlack) || + (state.position?.lastPlayerToMove == StoneType.WHITE && state.enginePlaysWhite) + + + private fun aiOnlyGame(state: AiGameState): Boolean = + state.enginePlaysBlack || state.enginePlaysWhite +} diff --git a/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/AiGameState.kt b/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/AiGameState.kt index d0f3fd4c..0498443f 100644 --- a/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/AiGameState.kt +++ b/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/AiGameState.kt @@ -13,6 +13,7 @@ data class AiGameState( val history: List = emptyList(), val boardSize: Int = 19, val enginePlaysBlack: Boolean = false, + val enginePlaysWhite: Boolean = false, val handicap: Int = 0, val boardIsInteractive: Boolean = false, val candidateMove: Cell? = null, @@ -29,8 +30,8 @@ data class AiGameState( val ownershipButtonVisible: Boolean = false, val finalWhiteScore: Float? = null, val finalBlackScore: Float? = null, - val aiWon: Boolean? = null, + val whiteWon: Boolean? = null, val stateRestorePending: Boolean = true, val aiAnalysis: Response? = null, val aiQuickEstimation: MoveInfo? = null, -) \ No newline at end of file +) diff --git a/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/NewGameBottomSheet.kt b/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/NewGameBottomSheet.kt index c96017c4..b57ca6a7 100644 --- a/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/NewGameBottomSheet.kt +++ b/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/NewGameBottomSheet.kt @@ -9,10 +9,11 @@ import io.zenandroid.onlinego.R import io.zenandroid.onlinego.databinding.BottomSheetNewAiGameBinding private const val SIZE = "AI_GAME_SIZE" -private const val COLOR = "AI_GAME_COLOR" private const val HANDICAP = "AI_GAME_HANDICAP" +private const val AI_BLACK = "AI_GAME_PLAY_BLACK" +private const val AI_WHITE = "AI_GAME_PLAY_WHITE" -class NewGameBottomSheet(context: Context, private val onOk: (Int, Boolean, Int) -> Unit) : BottomSheetDialog(context) { +class NewGameBottomSheet(context: Context, private val onOk: (Int, Boolean, Boolean, Int) -> Unit, private val onLoad: () -> Unit, private val onSave: () -> Unit) : BottomSheetDialog(context) { private val prefs = PreferenceManager.getDefaultSharedPreferences(context)!! private lateinit var binding: BottomSheetNewAiGameBinding @@ -32,9 +33,22 @@ class NewGameBottomSheet(context: Context, private val onOk: (Int, Boolean, Int) else -> 19 } val youPlayBlack = binding.blackButton.isChecked + val youPlayWhite = binding.whiteButton.isChecked val handicap = binding.handicapSlider.value.toInt() saveSettings() - onOk.invoke(selectedSize, youPlayBlack, handicap) + onOk.invoke(selectedSize, youPlayBlack, youPlayWhite, handicap) + } + + binding.loadButton.setOnClickListener { + dismiss() + saveSettings() + onLoad() + } + + binding.saveButton.setOnClickListener { + dismiss() + saveSettings() + onSave() } binding.handicapSlider.addOnChangeListener { _, value, _ -> @@ -68,12 +82,12 @@ class NewGameBottomSheet(context: Context, private val onOk: (Int, Boolean, Int) } binding.sizeToggleGroup.check(checkedSizeButtonId) - val checkedColorButtonId = when(prefs.getInt(COLOR, 0)) { - 0 -> R.id.blackButton - 1 -> R.id.whiteButton - else -> -1 + if(prefs.getBoolean(AI_WHITE, false)) { + binding.colorToggleGroup.check(R.id.whiteButton) + } + if(prefs.getBoolean(AI_BLACK, false)) { + binding.colorToggleGroup.check(R.id.blackButton) } - binding.colorToggleGroup.check(checkedColorButtonId) binding.handicapSlider.value = prefs.getFloat(HANDICAP, 0f) setLabel(binding.handicapSlider.value) @@ -87,9 +101,9 @@ class NewGameBottomSheet(context: Context, private val onOk: (Int, Boolean, Int) } prefs.edit() .putInt(SIZE, selectedSize) - .putInt(COLOR, if (binding.blackButton.isChecked) 0 else 1) + .putBoolean(AI_BLACK, binding.blackButton.isChecked) + .putBoolean(AI_WHITE, binding.whiteButton.isChecked) .putFloat(HANDICAP, binding.handicapSlider.value) .apply() } - -} \ No newline at end of file +} diff --git a/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/middlewares/AIMoveMiddleware.kt b/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/middlewares/AIMoveMiddleware.kt index 6b2d03fb..4f26ae66 100644 --- a/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/middlewares/AIMoveMiddleware.kt +++ b/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/middlewares/AIMoveMiddleware.kt @@ -25,7 +25,7 @@ class AIMoveMiddleware : Middleware { .flatMapSingle { (_, state) -> KataGoAnalysisEngine.analyzeMoveSequence( sequence = state.history, - maxVisits = 20, + // maxVisits = 20, komi = state.position?.komi ?: 0f, includeOwnership = false, includeMovesOwnership = false @@ -33,8 +33,7 @@ class AIMoveMiddleware : Middleware { .map { val selectedMove = selectMove(it) val move = Util.getCoordinatesFromGTP(selectedMove.move, state.position!!.boardHeight) - val side = if(state.enginePlaysBlack) StoneType.BLACK else StoneType.WHITE - val newPos = RulesManager.makeMove(state.position, side, move) + val newPos = RulesManager.makeMove(state.position, state.position.nextToMove, move) if(newPos == null) { recordException(Exception("KataGO wants to play move ${selectedMove.move} ($move), but RulesManager rejects it as invalid")) AIError diff --git a/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/middlewares/AnalyticsMiddleware.kt b/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/middlewares/AnalyticsMiddleware.kt index c2b37634..037c59df 100644 --- a/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/middlewares/AnalyticsMiddleware.kt +++ b/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/middlewares/AnalyticsMiddleware.kt @@ -1,6 +1,7 @@ package io.zenandroid.onlinego.ui.screens.localai.middlewares import android.os.Bundle +import android.util.Log import androidx.core.os.bundleOf import io.reactivex.Observable import io.reactivex.rxkotlin.withLatestFrom @@ -18,8 +19,9 @@ class AnalyticsMiddleware: Middleware { override fun bind(actions: Observable, state: Observable): Observable { return actions.withLatestFrom(state) .doOnNext { (action, state) -> + Log.d("ai.state", "${action::class.simpleName}: ${action.toString()}") when(action) { - CantRestoreState, ViewReady, is RestoredState, ViewPaused, ShowNewGameDialog, DismissNewGameDialog, PromptUserForMove, is NewPosition, is UserHotTrackedCoordinate, is AIOwnershipResponse, + CantRestoreState, is ViewReady, is RestoredState, ViewPaused, ShowNewGameDialog, DismissNewGameDialog, PromptUserForMove, is NewPosition, is UserHotTrackedCoordinate, is AIOwnershipResponse, HideOwnership -> Unit is NewGame -> { analytics.logEvent("ai_game_new_game", null) @@ -35,7 +37,7 @@ class AnalyticsMiddleware: Middleware { is AIMove -> analytics.logEvent("katago_move", null) is AIHint -> analytics.logEvent("katago_hint", null) - is ScoreComputed -> if(action.aiWon) { + is ScoreComputed -> if(action.whiteWon) { analytics.logEvent("katago_won", Bundle().apply { OnlineGoApplication.instance.get< UserSessionRepository>().uiConfig?.user?.ranking?.let { putInt("RANKING", it) @@ -60,9 +62,12 @@ class AnalyticsMiddleware: Middleware { UserAskedForOwnership -> analytics.logEvent("ai_game_user_asked_territory", null) is UserTriedSuicidalMove -> analytics.logEvent("ai_game_user_tried_suicide", null) is UserTriedKoMove -> analytics.logEvent("ai_game_user_tried_ko", null) + NextPlayerChanged -> analytics.logEvent("ai_game_next_player_changed", null) + ToggleAIBlack -> analytics.logEvent("ai_game_toggled_ai_black", null) + ToggleAIWhite -> analytics.logEvent("ai_game_toggled_ai_white", null) } } .switchMap { Observable.empty() } } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/middlewares/EngineLifecycleMiddleware.kt b/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/middlewares/EngineLifecycleMiddleware.kt index 21b1048c..f4838c43 100644 --- a/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/middlewares/EngineLifecycleMiddleware.kt +++ b/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/middlewares/EngineLifecycleMiddleware.kt @@ -21,7 +21,7 @@ class EngineLifecycleMiddleware : Middleware { } private fun startEngineObservable(actions: Observable, state: Observable): Observable = - actions.ofType(ViewReady.javaClass) + actions.filter({ it is ViewReady }) .withLatestFrom(state) .filter { (_, state) -> !state.engineStarted } .flatMapSingle { (_, _) -> @@ -48,4 +48,4 @@ class EngineLifecycleMiddleware : Middleware { Log.e("EngineLifecycleMiddlew", throwable.message, throwable) recordException(throwable) } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/middlewares/GameTurnMiddleware.kt b/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/middlewares/GameTurnMiddleware.kt index 4b14e0d6..ad668f7e 100644 --- a/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/middlewares/GameTurnMiddleware.kt +++ b/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/middlewares/GameTurnMiddleware.kt @@ -18,16 +18,19 @@ import io.zenandroid.onlinego.utils.recordException class GameTurnMiddleware : Middleware { override fun bind(actions: Observable, state: Observable): Observable = Observable.merge( + listOf( engineStarted(actions, state), newGame(actions), nextMove(actions, state), - computeScore(actions, state) + computeScore(actions, state), + toggleAI(actions, state) + ) ) private fun engineStarted(actions: Observable, state: Observable) = actions.filter { it is EngineStarted || it is RestoredState } .withLatestFrom(state) - .filter { (_, state) -> !state.stateRestorePending && state.engineStarted && state.position != null && !(state.history.isGameOver() && state.aiWon != null) } + .filter { (_, state) -> !state.stateRestorePending && state.engineStarted && state.position != null && !(state.history.isGameOver() && state.whiteWon != null) } .map { (_, state) -> NewPosition(state.position!!) } private fun newGame(actions: Observable) = @@ -35,12 +38,12 @@ class GameTurnMiddleware : Middleware { .map { NewPosition(RulesManager.initializePosition(it.size, it.handicap)) } private fun nextMove(actions: Observable, state: Observable) = - actions.filter { it is NewPosition || it is AIMove } + actions.filter { it is NewPosition || it is AIMove || it is NextPlayerChanged || it is DismissNewGameDialog } .withLatestFrom(state) - .filter { (_, state) -> !state.history.isGameOver() } + .filter { (_, state) -> !state.history.isGameOver() && !state.newGameDialogShown } .map { (_, state) -> - val isBlacksTurn = state.position?.nextToMove != StoneType.WHITE - if (isBlacksTurn == state.enginePlaysBlack) { + if ((state.position?.nextToMove == StoneType.BLACK && state.enginePlaysBlack) || + (state.position?.nextToMove == StoneType.WHITE && state.enginePlaysWhite)) { GenerateAiMove } else { PromptUserForMove @@ -54,7 +57,7 @@ class GameTurnMiddleware : Middleware { .flatMap { (_, state) -> KataGoAnalysisEngine.analyzeMoveSequence( sequence = state.history, - maxVisits = 10, + //maxVisits = 10, komi = state.position!!.komi, includeOwnership = true ) @@ -90,8 +93,8 @@ class GameTurnMiddleware : Middleware { val whiteScore = (newPos.komi ?: 0f) + newPos.whiteTerritory.size + newPos.whiteCaptureCount + newPos.blackDeadStones.size val blackScore = newPos.blackTerritory.size + newPos.blackCaptureCount + newPos.whiteDeadStones.size - val aiWon = state.enginePlaysBlack == (blackScore > whiteScore) - ScoreComputed(newPos, whiteScore, blackScore, aiWon, it) + val whiteWon = blackScore < whiteScore + ScoreComputed(newPos, whiteScore, blackScore, whiteWon, it) } .subscribeOn(Schedulers.io()) .toObservable() @@ -99,8 +102,19 @@ class GameTurnMiddleware : Middleware { .onErrorResumeNext(Observable.empty()) } + private fun toggleAI(actions: Observable, state: Observable) = + actions.filter { it is ToggleAIBlack || it is ToggleAIWhite } + .withLatestFrom(state) + .filter{ (sideChanged, state) -> state.engineStarted && state.position != null + && state.position.nextToMove == when(sideChanged) { + ToggleAIBlack -> StoneType.BLACK + ToggleAIWhite -> StoneType.WHITE + else -> null + } } + .map { NextPlayerChanged } + private fun onError(throwable: Throwable) { Log.e("GameTurnMiddleware", throwable.message, throwable) recordException(throwable) } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/middlewares/HintMiddleware.kt b/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/middlewares/HintMiddleware.kt index d55c8c93..30a8b658 100644 --- a/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/middlewares/HintMiddleware.kt +++ b/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/middlewares/HintMiddleware.kt @@ -19,7 +19,7 @@ class HintMiddleware : Middleware { .flatMap { (_, state) -> KataGoAnalysisEngine.analyzeMoveSequence( sequence = state.history, - maxVisits = 30, + //maxVisits = 30, komi = state.position?.komi ?: 0f, includeOwnership = false ) @@ -35,4 +35,4 @@ class HintMiddleware : Middleware { Log.e("HintMiddleware", throwable.message, throwable) recordException(throwable) } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/middlewares/OwnershipMiddleware.kt b/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/middlewares/OwnershipMiddleware.kt index 19c7a529..e1f53a68 100644 --- a/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/middlewares/OwnershipMiddleware.kt +++ b/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/middlewares/OwnershipMiddleware.kt @@ -20,7 +20,7 @@ class OwnershipMiddleware : Middleware { } else { KataGoAnalysisEngine.analyzeMoveSequence( sequence = state.history, - maxVisits = 30, + //maxVisits = 30, komi = state.position!!.komi, includeOwnership = true ).map { @@ -30,4 +30,4 @@ class OwnershipMiddleware : Middleware { } } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/middlewares/StatePersistenceMiddleware.kt b/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/middlewares/StatePersistenceMiddleware.kt index c0ed0cee..10a1fa58 100644 --- a/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/middlewares/StatePersistenceMiddleware.kt +++ b/app/src/main/java/io/zenandroid/onlinego/ui/screens/localai/middlewares/StatePersistenceMiddleware.kt @@ -44,8 +44,10 @@ class StatePersistenceMiddleware : Middleware { private fun deserializeObservable(actions: Observable, state: Observable) = actions.ofType(ViewReady::class.java) .withLatestFrom(state) - .map { - if(prefs.contains(STATE_KEY)) { + .map { (_, state) -> + if(state.position != null) { + RestoredState(state) + } else if(prefs.contains(STATE_KEY)) { val json = prefs.getString(STATE_KEY, "")!! val newState = try { stateAdapter.fromJson(json) diff --git a/app/src/main/java/io/zenandroid/onlinego/ui/screens/main/MainActivity.kt b/app/src/main/java/io/zenandroid/onlinego/ui/screens/main/MainActivity.kt index a00cb225..93af1cee 100644 --- a/app/src/main/java/io/zenandroid/onlinego/ui/screens/main/MainActivity.kt +++ b/app/src/main/java/io/zenandroid/onlinego/ui/screens/main/MainActivity.kt @@ -9,10 +9,12 @@ import android.content.Context import android.content.pm.PackageManager import android.content.res.Configuration import android.graphics.Color +import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper +import android.util.Log import android.view.MenuItem import android.widget.Toast import androidx.activity.result.ActivityResultLauncher @@ -127,6 +129,23 @@ class MainActivity : AppCompatActivity(), MainContract.View { ) BoardView.preloadResources(resources) + + intent?.data?.let { data -> + // Figure out what to do based on the intent type + if (intent?.scheme?.startsWith("http") == true) { + // Handle intents with remote data ... + Log.d("MainActivity", "Recieved remote intent ${data}") + navHostFragment.navController.navigate(R.id.aiGameFragment, Bundle().apply { + putString("SGF_REMOTE", data.toString()) + }) + } else if (intent?.type == "application/x-go-sgf") { + // Handle intents with local data ... + Log.d("MainActivity", "Recieved local intent ${data}") + navHostFragment.navController.navigate(R.id.aiGameFragment, Bundle().apply { + putString("SGF_LOCAL", data.toString()) + }) + } + } } private fun scheduleNotificationJob() { diff --git a/app/src/main/java/io/zenandroid/onlinego/ui/screens/settings/SettingsFragment.kt b/app/src/main/java/io/zenandroid/onlinego/ui/screens/settings/SettingsFragment.kt index 5a2262b4..2b9d874e 100644 --- a/app/src/main/java/io/zenandroid/onlinego/ui/screens/settings/SettingsFragment.kt +++ b/app/src/main/java/io/zenandroid/onlinego/ui/screens/settings/SettingsFragment.kt @@ -34,6 +34,7 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedTextField import androidx.compose.material.Surface import androidx.compose.material.Switch +import androidx.compose.material.Slider import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.material.icons.Icons @@ -44,11 +45,13 @@ import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.VolumeUp +import androidx.compose.material.icons.rounded.AccountTree import androidx.compose.material.icons.rounded.DarkMode import androidx.compose.material.icons.rounded.HeartBroken import androidx.compose.material.icons.rounded.Logout import androidx.compose.material.icons.rounded.MilitaryTech import androidx.compose.material.icons.rounded.Palette +import androidx.compose.material.icons.rounded.Psychology import androidx.compose.material.icons.rounded._123 import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable @@ -97,7 +100,9 @@ import io.zenandroid.onlinego.ui.screens.settings.SettingsAction.CoordinatesClic import io.zenandroid.onlinego.ui.screens.settings.SettingsAction.DeleteAccountCanceled import io.zenandroid.onlinego.ui.screens.settings.SettingsAction.DeleteAccountClicked import io.zenandroid.onlinego.ui.screens.settings.SettingsAction.DeleteAccountConfirmed +import io.zenandroid.onlinego.ui.screens.settings.SettingsAction.DetailedAnalysisClicked import io.zenandroid.onlinego.ui.screens.settings.SettingsAction.LogoutClicked +import io.zenandroid.onlinego.ui.screens.settings.SettingsAction.MaxVisitsChanged import io.zenandroid.onlinego.ui.screens.settings.SettingsAction.NotificationsClicked import io.zenandroid.onlinego.ui.screens.settings.SettingsAction.PrivacyClicked import io.zenandroid.onlinego.ui.screens.settings.SettingsAction.RanksClicked @@ -107,6 +112,7 @@ import io.zenandroid.onlinego.ui.screens.settings.SettingsAction.ThemeClicked import io.zenandroid.onlinego.ui.theme.OnlineGoTheme import io.zenandroid.onlinego.utils.processGravatarURL import io.zenandroid.onlinego.utils.rememberStateWithLifecycle +import it.sephiroth.android.library.numberpicker.* import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel @@ -406,6 +412,24 @@ fun SettingsScreen(state: SettingsState, onAction: (SettingsAction) -> Unit) { ) } } + Section(title = "Engine Settings") { + Column(modifier = Modifier) { + SettingsRow( + title = "Max AI Playouts", + icon = Rounded.AccountTree, + slider = Pair(10.0, 10000.0), + position = state.maxVisits, + onValueChanged = { onAction(MaxVisitsChanged(it)) } + ) + SettingsRow( + title = "Detailed AI Analysis", + icon = Rounded.Psychology, + checkbox = true, + checked = state.detailedAnalysis, + onClick = { onAction(DetailedAnalysisClicked) } + ) + } + } Section(title = "Account") { Column(modifier = Modifier) { SettingsRow( @@ -452,10 +476,13 @@ private fun SettingsRow( icon: ImageVector, checkbox: Boolean = false, checked: Boolean = false, + slider: Pair? = null, + position: Double = 0.0, value: String? = null, possibleValues: List = emptyList(), onClick: () -> Unit = {}, onValueClick: (String) -> Unit = {}, + onValueChanged: (Double) -> Unit = {}, ) { var menuOpen by remember { mutableStateOf(false) } Row( @@ -487,9 +514,27 @@ private fun SettingsRow( if(checkbox) { Switch( checked = checked, - onCheckedChange = { onClick()}, + onCheckedChange = { onClick() }, modifier = Modifier.padding(end = 12.dp) ) + } else if(slider != null) { + Slider( + value = Math.log(position).toFloat(), + onValueChange = { onValueChanged(Math.exp(it.toDouble())) }, + steps = 10, + valueRange = Math.log(slider.first).toFloat()..Math.log(slider.second).toFloat(), + modifier = Modifier.weight(1f).padding(end = 12.dp) + ) + Text( + text = position.toInt().toString().padStart(8), + fontSize = 14.sp, + style = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + letterSpacing = 0.4.sp + ), + modifier = Modifier.padding(end = 16.dp, bottom = 16.dp, top = 16.dp) + ) } else if(value != null) { Box { Text( @@ -582,4 +627,4 @@ private fun SettingsScreenPreview() { ), {}) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/zenandroid/onlinego/ui/screens/settings/SettingsViewModel.kt b/app/src/main/java/io/zenandroid/onlinego/ui/screens/settings/SettingsViewModel.kt index 2e9f977a..0eedbb61 100644 --- a/app/src/main/java/io/zenandroid/onlinego/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/java/io/zenandroid/onlinego/ui/screens/settings/SettingsViewModel.kt @@ -13,7 +13,9 @@ import io.zenandroid.onlinego.ui.screens.settings.SettingsAction.CoordinatesClic import io.zenandroid.onlinego.ui.screens.settings.SettingsAction.DeleteAccountCanceled import io.zenandroid.onlinego.ui.screens.settings.SettingsAction.DeleteAccountClicked import io.zenandroid.onlinego.ui.screens.settings.SettingsAction.DeleteAccountConfirmed +import io.zenandroid.onlinego.ui.screens.settings.SettingsAction.DetailedAnalysisClicked import io.zenandroid.onlinego.ui.screens.settings.SettingsAction.LogoutClicked +import io.zenandroid.onlinego.ui.screens.settings.SettingsAction.MaxVisitsChanged import io.zenandroid.onlinego.ui.screens.settings.SettingsAction.NotificationsClicked import io.zenandroid.onlinego.ui.screens.settings.SettingsAction.PrivacyClicked import io.zenandroid.onlinego.ui.screens.settings.SettingsAction.RanksClicked @@ -37,6 +39,8 @@ class SettingsViewModel( sounds = settingsRepository.sound, ranks = settingsRepository.showRanks, coordinates = settingsRepository.showCoordinates, + maxVisits = settingsRepository.maxVisits.toDouble(), + detailedAnalysis = settingsRepository.detailedAnalysis, username = userSessionRepository.uiConfig?.user?.username ?: "", avatarURL = userSessionRepository.uiConfig?.user?.icon, ) @@ -54,6 +58,16 @@ class SettingsViewModel( state.update { it.copy(ranks = !it.ranks) } } + is MaxVisitsChanged -> { + settingsRepository.maxVisits = action.value.toInt() + state.update { it.copy(maxVisits = action.value) } + } + + DetailedAnalysisClicked -> { + settingsRepository.detailedAnalysis = !settingsRepository.detailedAnalysis + state.update { it.copy(detailedAnalysis = !it.detailedAnalysis) } + } + SoundsClicked -> { settingsRepository.sound = !state.value.sounds state.update { it.copy(sounds = !it.sounds) } @@ -158,6 +172,8 @@ data class SettingsState( val sounds: Boolean = true, val ranks: Boolean = true, val coordinates: Boolean = true, + val maxVisits: Double = 30.0, + val detailedAnalysis: Boolean = false, val username: String = "", val avatarURL: String? = null, val passwordDialogVisible: Boolean = false, @@ -171,6 +187,8 @@ sealed interface SettingsAction { data class ThemeClicked(val theme: String) : SettingsAction data class BoardThemeClicked(val boardDisplayName: String) : SettingsAction data object CoordinatesClicked : SettingsAction + data class MaxVisitsChanged(val value: Double) : SettingsAction + data object DetailedAnalysisClicked : SettingsAction data object RanksClicked : SettingsAction data object LogoutClicked : SettingsAction data object DeleteAccountClicked : SettingsAction @@ -178,4 +196,4 @@ sealed interface SettingsAction { data class DeleteAccountConfirmed(val password: String) : SettingsAction data object PrivacyClicked : SettingsAction data object SupportClicked : SettingsAction -} \ No newline at end of file +} diff --git a/app/src/main/java/io/zenandroid/onlinego/ui/views/BoardView.kt b/app/src/main/java/io/zenandroid/onlinego/ui/views/BoardView.kt index e1ff4e41..f9e63de0 100644 --- a/app/src/main/java/io/zenandroid/onlinego/ui/views/BoardView.kt +++ b/app/src/main/java/io/zenandroid/onlinego/ui/views/BoardView.kt @@ -14,6 +14,8 @@ import android.view.View import androidx.compose.ui.unit.dp import androidx.core.graphics.ColorUtils import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import io.reactivex.Observable import io.reactivex.subjects.PublishSubject import io.zenandroid.onlinego.OnlineGoApplication @@ -25,6 +27,9 @@ import io.zenandroid.onlinego.data.model.StoneType import io.zenandroid.onlinego.data.model.katago.MoveInfo import io.zenandroid.onlinego.data.model.ogs.PlayCategory import io.zenandroid.onlinego.gamelogic.Util +import io.zenandroid.onlinego.data.model.katago.RootInfo +import io.zenandroid.onlinego.data.repositories.SettingsRepository +import org.koin.core.context.GlobalContext import java.util.* import kotlin.math.abs import kotlin.math.ceil @@ -37,6 +42,8 @@ import kotlin.math.roundToInt * that is passed to it via setPosition() */ class BoardView : View { + private val settingsRepository: SettingsRepository = GlobalContext.get().get() + var boardWidth = 19 set(boardWidth) { field = boardWidth @@ -147,6 +154,7 @@ class BoardView : View { } field = value } + var hintBasis: RootInfo? = null var hints: List? = null var ownership: List? = null @@ -444,10 +452,23 @@ class BoardView : View { private fun drawHints(canvas: Canvas, position: Position) { hints?.let { - for((index, hint) in it.take(5).withIndex()) { + val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() + val adapter = moshi.adapter(RootInfo::class.java) + Log.d("BoardView", "Root: ${hintBasis?.let { adapter.toJson(it) }}") + for((index, hint) in it.withIndex()) { + val adapter = moshi.adapter(MoveInfo::class.java) + Log.d("BoardView", "Hint: ${hint?.let { adapter.toJson(it) }}") + val winrateHighest = hints?.map { it.winrate }.orEmpty().maxOrNull() ?: 100f + val winrateLowest = hints?.map { it.winrate }.orEmpty().minOrNull() ?: 0f + val winrate = hint.winrate * 100 + val playouts = hint.visits + val blackScoreDiff = hint.scoreLead.minus(hintBasis?.scoreLead ?: 0f) + val scoreDiff = blackScoreDiff * + if(position.nextToMove == StoneType.WHITE) -1 else 1 + val coords = Util.getCoordinatesFromGTP(hint.move, position.boardHeight) val center = getCellCenter(coords.x, coords.y) - val drawable = if (position.nextToMove == StoneType.BLACK) blackStoneDrawable else whiteStoneDrawable + val drawable = if (position.nextToMove == StoneType.WHITE || settingsRepository.detailedAnalysis) whiteStoneDrawable else blackStoneDrawable drawable.alpha = 100 drawable.setBounds( (center.x - cellSize / 2f + stoneSpacing).toInt(), @@ -455,11 +476,33 @@ class BoardView : View { (center.x + cellSize / 2f - stoneSpacing).toInt(), (center.y + cellSize / 2f - stoneSpacing).toInt() ) + val rank = (hint.winrate - winrateLowest) / (winrateHighest - winrateLowest) + val red = if(rank > 0.5) 1 - 2 * rank else 1.0f + val green = if(rank > 0.5) 1.0f else 2 * rank + val colour = if(index == 0) Color.argb(1f, 0f, 1f, 1f) + else Color.argb(1f, red, green, 0f) + drawable.setColorFilter(colour, PorterDuff.Mode.MULTIPLY) drawable.draw(canvas) - textPaint.color = if (position.nextToMove == StoneType.WHITE) Color.BLACK else Color.WHITE - drawTextCentred(canvas, textPaint, (index + 1).toString(), center.x, center.y) + if (settingsRepository.detailedAnalysis) { + val aiTextPaint = android.text.TextPaint(textPaint).also { + it.color = Color.BLACK + it.textSize = cellSize * .22f + } + Log.d("BoardView", "Prediction: ${adapter.toJson(hint)}") + val height = aiTextPaint.getFontMetrics().let { it.ascent - it.descent } + drawTextCentred(canvas, aiTextPaint, "${String.format("%.2g", scoreDiff)}", center.x, center.y - height) + drawTextCentred(canvas, aiTextPaint, "#${index + 1} | ${playouts}x ", center.x, center.y) + aiTextPaint.let { + it.setTypeface(Typeface.create(it.getTypeface(), Typeface.BOLD)) + } + drawTextCentred(canvas, aiTextPaint, "${String.format("%.1f", winrate)}%", center.x, center.y + height) + } else { + textPaint.color = if (position.nextToMove == StoneType.WHITE) Color.BLACK else Color.WHITE + drawTextCentred(canvas, textPaint, "${index + 1}", center.x, center.y) + } } + whiteStoneDrawable.clearColorFilter() } } diff --git a/app/src/main/res/drawable/ic_branch_blue.xml b/app/src/main/res/drawable/ic_branch_blue.xml new file mode 100644 index 00000000..0ed68b3e --- /dev/null +++ b/app/src/main/res/drawable/ic_branch_blue.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_menu.xml b/app/src/main/res/drawable/ic_menu.xml new file mode 100644 index 00000000..8ad7b0cd --- /dev/null +++ b/app/src/main/res/drawable/ic_menu.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_menu_hamburger.xml b/app/src/main/res/drawable/ic_menu_hamburger.xml new file mode 100644 index 00000000..8ad7b0cd --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_hamburger.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_thinking_blue.xml b/app/src/main/res/drawable/ic_thinking_blue.xml new file mode 100644 index 00000000..faaa8da1 --- /dev/null +++ b/app/src/main/res/drawable/ic_thinking_blue.xml @@ -0,0 +1,22 @@ + + + + + diff --git a/app/src/main/res/layout/bottom_sheet_new_ai_game.xml b/app/src/main/res/layout/bottom_sheet_new_ai_game.xml index 20e09ca7..a4276ba7 100644 --- a/app/src/main/res/layout/bottom_sheet_new_ai_game.xml +++ b/app/src/main/res/layout/bottom_sheet_new_ai_game.xml @@ -76,8 +76,8 @@ android:id="@+id/colorToggleGroup" android:layout_width="match_parent" android:layout_height="wrap_content" - app:singleSelection="true" - app:selectionRequired="true" + app:singleSelection="false" + app:selectionRequired="false" app:checkedButton="@id/blackButton" > - + > + + + + - \ No newline at end of file + diff --git a/app/src/main/res/layout/fragment_aigame.xml b/app/src/main/res/layout/fragment_aigame.xml index f495ea6f..a12e43ff 100644 --- a/app/src/main/res/layout/fragment_aigame.xml +++ b/app/src/main/res/layout/fragment_aigame.xml @@ -30,7 +30,7 @@ app:cardBackgroundColor="#FFFFFF" app:cardCornerRadius="15dp" app:cardElevation="2dp" - app:layout_constraintBottom_toTopOf="@id/nameLabelLeft" + app:layout_constraintBottom_toTopOf="@id/nameButtonLeft" app:layout_constraintDimensionRatio="h,1:1" app:layout_constraintHeight_max="80dp" app:layout_constraintLeft_toLeftOf="parent" @@ -97,8 +97,8 @@ android:layout_marginRight="8dp" /> - + app:layout_constraintTop_toBottomOf="@id/iconContainerLeft" + style="@style/AIChatButton" + android:textColor="@color/colorTextSecondary" + android:paddingLeft="0dp" + android:paddingRight="0dp" + android:layout_marginRight="8dp" + /> + app:layout_constraintTop_toTopOf="@id/nameButtonLeft" /> - + app:layout_constraintTop_toBottomOf="@id/iconContainerRight" + style="@style/AIChatButton" + android:textColor="@color/colorTextSecondary" + android:paddingLeft="0dp" + android:paddingRight="0dp" + android:layout_marginRight="8dp" + /> @@ -183,10 +195,10 @@ android:layout_height="wrap_content" android:text="Prisoners" android:textSize="12sp" - app:layout_constraintBottom_toTopOf="@id/nameLabelLeft" + app:layout_constraintBottom_toTopOf="@id/nameButtonLeft" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" - app:layout_constraintTop_toTopOf="@id/nameLabelLeft" /> + app:layout_constraintTop_toTopOf="@id/nameButtonLeft" /> - \ No newline at end of file +