Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions android/assets/jsons/translations/Russian.properties
Original file line number Diff line number Diff line change
Expand Up @@ -2688,6 +2688,7 @@ Disable religion = Отключить религию
Can only start games from the starting era = Может начинать игры только со стартовой эпохи
Allow raze capital = Разрешить разрушать столицу
Allow raze holy city = Разрешить разрушать святые города
Disable undo = Отключить отмену хода
Mod is incompatible with [modFilter] = Мод несовместим с [modFilter]
Mod requires [modFilter] = Мод требует [modFilter]
Should only be used as permanent audiovisual mod = Следует использовать только в качестве постоянного аудиовизуального мода
Expand Down
6 changes: 6 additions & 0 deletions core/src/com/unciv/logic/multiplayer/Multiplayer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,12 @@ class Multiplayer {
*/
suspend fun downloadGame(gameInfo: GameInfo) = coroutineScope {
val gameId = gameInfo.gameId
// With Disable undo, mid-turn progress is uploaded to the server; never prefer a local
// copy that only matches turns/currentPlayer (hasLatestGameState ignores mid-turn actions).
if (MultiplayerTurnIntegrity.mustDownloadFromServer(gameInfo)) {
downloadGame(gameId)
return@coroutineScope
}
val preview = multiplayerServer.tryDownloadGamePreview(gameId)
if (hasLatestGameState(gameInfo, preview)) {
gameInfo.isUpToDate = true
Expand Down
57 changes: 57 additions & 0 deletions core/src/com/unciv/logic/multiplayer/MultiplayerTurnIntegrity.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.unciv.logic.multiplayer

import com.unciv.UncivGame
import com.unciv.logic.GameInfo
import com.unciv.models.ruleset.unique.UniqueType
import com.unciv.ui.screens.worldscreen.WorldScreen
import com.unciv.utils.Concurrency
import com.unciv.utils.Log
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import yairm210.purity.annotations.Readonly

/**
* Closes mid-turn undo/reload abuse when [UniqueType.DisableUndo] is enabled in online multiplayer.
*
* Uploads the current game state to the multiplayer server after player actions (debounced),
* so rejoining downloads the post-action state instead of the turn-start snapshot.
*/
object MultiplayerTurnIntegrity {

private const val UPLOAD_DEBOUNCE_MS = 1500L
private var uploadJob: Job? = null

@Readonly
fun isEnabled(gameInfo: GameInfo): Boolean =
gameInfo.gameParameters.isOnlineMultiplayer
&& gameInfo.ruleset.modOptions.hasUnique(UniqueType.DisableUndo)

/** Always prefer the remote save over a local copy when integrity mode is on. */
@Readonly
fun mustDownloadFromServer(gameInfo: GameInfo): Boolean = isEnabled(gameInfo)

/**
* Schedule a debounced mid-turn upload of [worldScreen]'s current game.
* No-op unless integrity mode is active and it is this client's turn to play.
*/
fun scheduleUpload(worldScreen: WorldScreen) {
val gameInfo = worldScreen.gameInfo
if (!isEnabled(gameInfo)) return
if (!worldScreen.isPlayersTurn || !worldScreen.canChangeState) return

uploadJob?.cancel()
uploadJob = Concurrency.run("MidTurnMpUpload") {
delay(UPLOAD_DEBOUNCE_MS)
try {
// Clone so a concurrent UI change cannot mutate the payload mid-upload
val toUpload = worldScreen.gameInfo.clone()
toUpload.setTransients()
toUpload.isUpToDate = true
UncivGame.Current.onlineMultiplayer.updateGame(toUpload)
} catch (ex: Exception) {
// Never break gameplay if upload fails; next action / end turn can retry
Log.error("Mid-turn multiplayer upload failed", ex)
}
}
}
}
5 changes: 5 additions & 0 deletions core/src/com/unciv/models/ruleset/unique/UniqueType.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1091,6 +1091,11 @@ enum class UniqueType(
docDescription = "In this case, 'starting era' means the first defined Era in the entire ruleset."),
AllowRazeCapital("Allow raze capital", UniqueTarget.ModOptions, flags = UniqueFlag.setOfNoConditionals),
AllowRazeHolyCity("Allow raze holy city", UniqueTarget.ModOptions, flags = UniqueFlag.setOfNoConditionals),
DisableUndo("Disable undo", UniqueTarget.ModOptions, flags = UniqueFlag.setOfNoConditionals,
docDescription = "Disables the in-game Undo button and undo checkpoints. " +
"In online multiplayer, also uploads mid-turn progress to the server after actions " +
"and forces loading from the server (not a local mid-turn copy), so players cannot " +
"undo moves by rejoining the game."),

SuppressWarnings("Suppress warning [validationWarning]", *UniqueTarget.CanIncludeSuppression, flags = UniqueFlag.setOfHiddenNoConditionals, docDescription = Suppression.uniqueDocDescription),

Expand Down
19 changes: 16 additions & 3 deletions core/src/com/unciv/ui/screens/savescreens/LoadGameScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import com.badlogic.gdx.files.FileHandle
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextButton
import com.unciv.Constants
import com.unciv.UncivGame
import com.unciv.logic.MissingModsException
import com.unciv.logic.MissingNationException
import com.unciv.logic.UncivShowableException
import com.unciv.logic.files.PlatformSaverLoader
import com.unciv.logic.files.UncivFiles
import com.unciv.logic.github.GithubAPI
import com.unciv.logic.github.GithubAPI.downloadAndExtract
import com.unciv.logic.multiplayer.MultiplayerTurnIntegrity
import com.unciv.models.ruleset.RulesetCache
import com.unciv.models.translations.tr
import com.unciv.ui.components.UncivTooltip.Companion.addTooltip
Expand Down Expand Up @@ -91,7 +93,12 @@ class LoadGameScreen : LoadOrSaveScreen() {
try {
// This is what can lead to ANRs - reading the file and setting the transients, that's why this is in another thread
val loadedGame = game.files.loadGameFromFile(saveGameFile)
game.loadGame(loadedGame, callFromLoadScreen = true)
if (MultiplayerTurnIntegrity.mustDownloadFromServer(loadedGame)) {
// Never load a local mid-turn snapshot when integrity mode is on
game.onlineMultiplayer.downloadGame(loadedGame.gameId)
} else {
game.loadGame(loadedGame, callFromLoadScreen = true)
}
} catch (notAPlayer: UncivShowableException) {
launchOnGLThread {
val (message) = getLoadExceptionMessage(notAPlayer)
Expand All @@ -117,7 +124,10 @@ class LoadGameScreen : LoadOrSaveScreen() {
try {
val clipboardContentsString = Gdx.app.clipboard.contents.trim()
val loadedGame = UncivFiles.gameInfoFromString(clipboardContentsString)
game.loadGame(loadedGame, callFromLoadScreen = true)
if (MultiplayerTurnIntegrity.mustDownloadFromServer(loadedGame))
game.onlineMultiplayer.downloadGame(loadedGame.gameId)
else
game.loadGame(loadedGame, callFromLoadScreen = true)
} catch (ex: Exception) {
launchOnGLThread { handleLoadGameException(ex, "Could not load game from clipboard!") }
} finally {
Expand Down Expand Up @@ -149,7 +159,10 @@ class LoadGameScreen : LoadOrSaveScreen() {
onLoaded = { loadedGame ->
Concurrency.run {
try {
game.loadGame(loadedGame, callFromLoadScreen = true)
if (MultiplayerTurnIntegrity.mustDownloadFromServer(loadedGame))
game.onlineMultiplayer.downloadGame(loadedGame.gameId)
else
game.loadGame(loadedGame, callFromLoadScreen = true)
} catch (ex: Exception) {
launchOnGLThread { handleLoadGameException(ex, "Could not load game from custom location!") }
} finally {
Expand Down
12 changes: 11 additions & 1 deletion core/src/com/unciv/ui/screens/worldscreen/UndoHandler.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.unciv.ui.screens.worldscreen

import com.unciv.models.ruleset.unique.UniqueType
import com.unciv.utils.Concurrency
import yairm210.purity.annotations.Readonly

Expand All @@ -11,13 +12,22 @@ import yairm210.purity.annotations.Readonly
class UndoHandler(private val worldScreen: WorldScreen) {
private var preActionGameInfo = worldScreen.gameInfo

@Readonly fun canUndo() = preActionGameInfo != worldScreen.gameInfo && worldScreen.canChangeState
@Readonly
private fun isUndoDisabledByMod() =
worldScreen.gameInfo.ruleset.modOptions.hasUnique(UniqueType.DisableUndo)

@Readonly fun canUndo() =
!isUndoDisabledByMod()
&& preActionGameInfo != worldScreen.gameInfo
&& worldScreen.canChangeState

fun recordCheckpoint() {
if (isUndoDisabledByMod()) return
preActionGameInfo = worldScreen.gameInfo.clone()
}

fun restoreCheckpoint() {
if (isUndoDisabledByMod()) return
Concurrency.run {
// Most of the time we won't load this, so we only set transients once we see it's relevant
preActionGameInfo.setTransients()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import com.unciv.logic.battle.MapUnitCombatant
import com.unciv.logic.battle.Nuke
import com.unciv.logic.battle.TargetHelper
import com.unciv.logic.map.tile.Tile
import com.unciv.logic.multiplayer.MultiplayerTurnIntegrity
import com.unciv.models.UncivSound
import com.unciv.models.ruleset.unique.UniqueType
import com.unciv.models.translations.tr
Expand Down Expand Up @@ -364,6 +365,7 @@ class BattleTable(val worldScreen: WorldScreen) : Table() {

worldScreen.battleAnimationDeferred(attacker, damageToAttacker, defender, damageToDefender)
if (!attacker.canAttack()) hide()
MultiplayerTurnIntegrity.scheduleUpload(worldScreen)
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.badlogic.gdx.scenes.scene2d.ui.Button
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.unciv.UncivGame
import com.unciv.logic.map.mapunit.MapUnit
import com.unciv.logic.multiplayer.MultiplayerTurnIntegrity
import com.unciv.models.UnitAction
import com.unciv.models.UnitActionType
import com.unciv.models.UpgradeUnitAction
Expand Down Expand Up @@ -200,6 +201,7 @@ class UnitActionsTable(val worldScreen: WorldScreen) : Table() {
// so you need less clicks/touches to do things, but once we do an action with the new unit, we want to close this
// overlay, since the user definitely wants to interact with the new unit.
worldScreen.mapHolder.removeUnitActionOverlay()
MultiplayerTurnIntegrity.scheduleUpload(worldScreen)
if (!UncivGame.Current.settings.autoUnitCycle) return
if (unit.isDestroyed ||
unitAction.type.isSkippingToNextUnit && (!unit.isMoving() || !unit.hasMovement()))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import com.unciv.logic.map.*
import com.unciv.logic.map.mapunit.MapUnit
import com.unciv.logic.map.mapunit.movement.UnitMovement
import com.unciv.logic.map.tile.Tile
import com.unciv.logic.multiplayer.MultiplayerTurnIntegrity
import com.unciv.models.Spy
import com.unciv.models.UncivSound
import com.unciv.ui.audio.SoundPlayer
Expand Down Expand Up @@ -355,6 +356,8 @@ class WorldMapHolder(
if (UncivGame.Current.settings.autoUnitCycle && !selectedUnit.hasMovement())
worldScreen.switchToNextUnit()

MultiplayerTurnIntegrity.scheduleUpload(worldScreen)

} catch (ex: Exception) {
Log.error("Exception in moveUnitToTargetTile", ex)
}
Expand Down
Loading