From b9db85655ba9cc9ff295bc264e0e8245af0f51b4 Mon Sep 17 00:00:00 2001 From: "G. Falko" Date: Mon, 20 Jul 2026 22:21:56 +0300 Subject: [PATCH 1/2] Add ModOptions unique to disable undo Allow mods to turn off in-game undo so multiplayer players cannot reverse moves within a turn. Co-authored-by: Cursor --- android/assets/jsons/translations/Russian.properties | 1 + .../com/unciv/models/ruleset/unique/UniqueType.kt | 2 ++ .../com/unciv/ui/screens/worldscreen/UndoHandler.kt | 12 +++++++++++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/android/assets/jsons/translations/Russian.properties b/android/assets/jsons/translations/Russian.properties index 46e9db8a4ba48..a1a4e18333f14 100644 --- a/android/assets/jsons/translations/Russian.properties +++ b/android/assets/jsons/translations/Russian.properties @@ -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 = Следует использовать только в качестве постоянного аудиовизуального мода diff --git a/core/src/com/unciv/models/ruleset/unique/UniqueType.kt b/core/src/com/unciv/models/ruleset/unique/UniqueType.kt index 65cf078aacdde..5f583990ff747 100644 --- a/core/src/com/unciv/models/ruleset/unique/UniqueType.kt +++ b/core/src/com/unciv/models/ruleset/unique/UniqueType.kt @@ -1091,6 +1091,8 @@ 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. Useful for multiplayer to prevent undoing moves within a turn."), SuppressWarnings("Suppress warning [validationWarning]", *UniqueTarget.CanIncludeSuppression, flags = UniqueFlag.setOfHiddenNoConditionals, docDescription = Suppression.uniqueDocDescription), diff --git a/core/src/com/unciv/ui/screens/worldscreen/UndoHandler.kt b/core/src/com/unciv/ui/screens/worldscreen/UndoHandler.kt index ff5d8788150d6..54adb72598999 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/UndoHandler.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/UndoHandler.kt @@ -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 @@ -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() From 3d9c0e8b99cea8a5f06bd66f8275558644268901 Mon Sep 17 00:00:00 2001 From: "G. Falko" Date: Mon, 20 Jul 2026 22:56:16 +0300 Subject: [PATCH 2/2] Close mid-turn reload abuse with Disable undo When Disable undo is active in online multiplayer, debounce-upload after actions and always load from the server instead of a local mid-turn copy. Co-authored-by: Cursor --- .../unciv/logic/multiplayer/Multiplayer.kt | 6 ++ .../multiplayer/MultiplayerTurnIntegrity.kt | 57 +++++++++++++++++++ .../unciv/models/ruleset/unique/UniqueType.kt | 5 +- .../ui/screens/savescreens/LoadGameScreen.kt | 19 ++++++- .../worldscreen/bottombar/BattleTable.kt | 2 + .../unit/actions/UnitActionsTable.kt | 2 + .../worldscreen/worldmap/WorldMapHolder.kt | 3 + 7 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 core/src/com/unciv/logic/multiplayer/MultiplayerTurnIntegrity.kt diff --git a/core/src/com/unciv/logic/multiplayer/Multiplayer.kt b/core/src/com/unciv/logic/multiplayer/Multiplayer.kt index 13c2fed69b1cd..23aa423f846e2 100644 --- a/core/src/com/unciv/logic/multiplayer/Multiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/Multiplayer.kt @@ -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 diff --git a/core/src/com/unciv/logic/multiplayer/MultiplayerTurnIntegrity.kt b/core/src/com/unciv/logic/multiplayer/MultiplayerTurnIntegrity.kt new file mode 100644 index 0000000000000..e286e0b9f46f5 --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/MultiplayerTurnIntegrity.kt @@ -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) + } + } + } +} diff --git a/core/src/com/unciv/models/ruleset/unique/UniqueType.kt b/core/src/com/unciv/models/ruleset/unique/UniqueType.kt index 5f583990ff747..191404f8ddce8 100644 --- a/core/src/com/unciv/models/ruleset/unique/UniqueType.kt +++ b/core/src/com/unciv/models/ruleset/unique/UniqueType.kt @@ -1092,7 +1092,10 @@ enum class UniqueType( 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. Useful for multiplayer to prevent undoing moves within a turn."), + 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), diff --git a/core/src/com/unciv/ui/screens/savescreens/LoadGameScreen.kt b/core/src/com/unciv/ui/screens/savescreens/LoadGameScreen.kt index 78da2478639f7..4379447d566ea 100644 --- a/core/src/com/unciv/ui/screens/savescreens/LoadGameScreen.kt +++ b/core/src/com/unciv/ui/screens/savescreens/LoadGameScreen.kt @@ -5,6 +5,7 @@ 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 @@ -12,6 +13,7 @@ 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 @@ -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) @@ -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 { @@ -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 { diff --git a/core/src/com/unciv/ui/screens/worldscreen/bottombar/BattleTable.kt b/core/src/com/unciv/ui/screens/worldscreen/bottombar/BattleTable.kt index d98b012ce9fc2..d2861e9920d50 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/bottombar/BattleTable.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/bottombar/BattleTable.kt @@ -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 @@ -364,6 +365,7 @@ class BattleTable(val worldScreen: WorldScreen) : Table() { worldScreen.battleAnimationDeferred(attacker, damageToAttacker, defender, damageToDefender) if (!attacker.canAttack()) hide() + MultiplayerTurnIntegrity.scheduleUpload(worldScreen) } diff --git a/core/src/com/unciv/ui/screens/worldscreen/unit/actions/UnitActionsTable.kt b/core/src/com/unciv/ui/screens/worldscreen/unit/actions/UnitActionsTable.kt index b1e99407d99fb..093c99a2b1f05 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/unit/actions/UnitActionsTable.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/unit/actions/UnitActionsTable.kt @@ -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 @@ -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())) diff --git a/core/src/com/unciv/ui/screens/worldscreen/worldmap/WorldMapHolder.kt b/core/src/com/unciv/ui/screens/worldscreen/worldmap/WorldMapHolder.kt index 62f55861d8e32..d1b8d280d49ae 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/worldmap/WorldMapHolder.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/worldmap/WorldMapHolder.kt @@ -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 @@ -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) }