Skip to content

Commit

Permalink
No ruins undo (yairm210#10376)
Browse files Browse the repository at this point in the history
* Encapsulate Undo functionality

* Fix Ruins-Undo exploit

* Reorg RuinsManager candidate determination

* Deep RuinsManager clone

* Revert "Fix Ruins-Undo exploit"

This reverts commit 6df6a1a.
  • Loading branch information
SomeTroglodyte authored Oct 30, 2023
1 parent c8365b8 commit 1110811
Show file tree
Hide file tree
Showing 9 changed files with 95 additions and 49 deletions.
6 changes: 6 additions & 0 deletions core/src/com/unciv/UncivGame.kt
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.screens.mainmenuscreen.MainMenuScreen
import com.unciv.ui.screens.savescreens.LoadGameScreen
import com.unciv.ui.screens.worldscreen.PlayerReadyScreen
import com.unciv.ui.screens.worldscreen.UndoHandler.Companion.clearUndoCheckpoints
import com.unciv.ui.screens.worldscreen.WorldMapHolder
import com.unciv.ui.screens.worldscreen.WorldScreen
import com.unciv.ui.screens.worldscreen.unit.UnitTable
Expand Down Expand Up @@ -111,6 +112,11 @@ object GUI {
return UncivGame.Current.worldScreen!!.selectedCiv
}

/** Disable Undo (as in: forget the way back, but allow future undo checkpoints) */
fun clearUndoCheckpoints() {
UncivGame.Current.worldScreen?.clearUndoCheckpoints()
}

private var keyboardAvailableCache: Boolean? = null
/** Tests availability of a physical keyboard */
val keyboardAvailable: Boolean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,13 @@ class ReligionManager : IsPartOfGameInfoSerialization {
UniqueTriggerActivation.triggerCivwideUnique(unique, civInfo)
}

fun greatProphetsEarned(): Int = civInfo.civConstructions.boughtItemsWithIncreasingPrice[getGreatProphetEquivalent()?.name ?: ""]
// Counter.get never returns null, but it needs the correct key type, which is non-nullable

// https://www.reddit.com/r/civ/comments/2m82wu/can_anyone_detail_the_finer_points_of_great/
// Game files (globaldefines.xml)
fun faithForNextGreatProphet(): Int {
val greatProphetsEarned = civInfo.civConstructions.boughtItemsWithIncreasingPrice[getGreatProphetEquivalent()!!.name]
val greatProphetsEarned = greatProphetsEarned()

var faithCost =
(200 + 100 * greatProphetsEarned * (greatProphetsEarned + 1) / 2f) *
Expand Down
64 changes: 34 additions & 30 deletions core/src/com/unciv/logic/civilization/managers/RuinsManager.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
package com.unciv.logic.civilization.managers
// Why is this the only file in its own package?

import com.unciv.logic.IsPartOfGameInfoSerialization
import com.unciv.logic.civilization.Civilization
Expand All @@ -10,49 +9,54 @@ import com.unciv.models.ruleset.unique.UniqueTriggerActivation
import com.unciv.models.ruleset.unique.UniqueType
import kotlin.random.Random

class RuinsManager : IsPartOfGameInfoSerialization {
var lastChosenRewards: MutableList<String> = mutableListOf("", "")
private fun rememberReward(reward: String) {
lastChosenRewards[0] = lastChosenRewards[1]
lastChosenRewards[1] = reward
}
class RuinsManager(
private var lastChosenRewards: MutableList<String> = mutableListOf("", "")
) : IsPartOfGameInfoSerialization {

@Transient
lateinit var civInfo: Civilization
@Transient
lateinit var validRewards: List<RuinReward>

fun clone(): RuinsManager {
val toReturn = RuinsManager()
toReturn.lastChosenRewards = lastChosenRewards
return toReturn
}
fun clone() = RuinsManager(ArrayList(lastChosenRewards)) // needs to deep-clone (the List, not the Strings) so undo works

fun setTransients(civInfo: Civilization) {
this.civInfo = civInfo
validRewards = civInfo.gameInfo.ruleset.ruinRewards.values.toList()
}

fun selectNextRuinsReward(triggeringUnit: MapUnit) {
val tileBasedRandom = Random(triggeringUnit.getTile().position.toString().hashCode())
val availableRewards = validRewards.filter { it.name !in lastChosenRewards }

// This might be a dirty way to do this, but it works.
// For each possible reward, this creates a list with reward.weight amount of copies of this reward
// These lists are then combined into a single list, and the result is shuffled.
val possibleRewards = availableRewards.flatMap { reward -> List(reward.weight) { reward } }.shuffled(tileBasedRandom)

for (possibleReward in possibleRewards) {
if (civInfo.gameInfo.difficulty in possibleReward.excludedDifficulties) continue
if (possibleReward.hasUnique(UniqueType.HiddenWithoutReligion) && !civInfo.gameInfo.isReligionEnabled()) continue
if (possibleReward.hasUnique(UniqueType.HiddenAfterGreatProphet)
&& (civInfo.civConstructions.boughtItemsWithIncreasingPrice[civInfo.religionManager.getGreatProphetEquivalent()?.name] ?: 0) > 0
) continue
private fun rememberReward(reward: String) {
lastChosenRewards[0] = lastChosenRewards[1]
lastChosenRewards[1] = reward
}

if (possibleReward.getMatchingUniques(UniqueType.OnlyAvailableWhen, StateForConditionals.IgnoreConditionals)
.any { !it.conditionalsApply(StateForConditionals(civInfo, unit=triggeringUnit, tile = triggeringUnit.getTile()) ) })
continue
private fun getShuffledPossibleRewards(triggeringUnit: MapUnit): Iterable<RuinReward> {
val stateForOnlyAvailableWhen = StateForConditionals(civInfo, unit = triggeringUnit, tile = triggeringUnit.getTile())
val candidates =
validRewards.asSequence()
// Filter out what shouldn't be considered right now, before the random choice
.filterNot { possibleReward ->
possibleReward.name in lastChosenRewards
|| civInfo.gameInfo.difficulty in possibleReward.excludedDifficulties
|| possibleReward.hasUnique(UniqueType.HiddenWithoutReligion) && !civInfo.gameInfo.isReligionEnabled()
|| possibleReward.hasUnique(UniqueType.HiddenAfterGreatProphet) && civInfo.religionManager.greatProphetsEarned() > 0
|| possibleReward.getMatchingUniques(UniqueType.OnlyAvailableWhen, StateForConditionals.IgnoreConditionals)
.any { !it.conditionalsApply(stateForOnlyAvailableWhen) }
}
// This might be a dirty way to do this, but it works (we do have randomWeighted in CollectionExtensions, but below we
// need to choose another when the first choice's TriggerActivations report failure, and that's simpler this way)
// For each possible reward, this feeds (reward.weight) copies of this reward to the overall Sequence to implement 'weight'.
.flatMap { reward -> generateSequence { reward }.take(reward.weight) }
// Convert to List since Sequence.shuffled would do one anyway, Mutable so shuffle doesn't need to pull a copy
.toMutableList()
// The resulting List now gets shuffled, using a tile-based random to thwart save-scumming.
// Note both Sequence.shuffled and Iterable.shuffled (with a 'd') always pull an extra copy of a MutableList internally, even if you feed them one.
candidates.shuffle(Random(triggeringUnit.getTile().position.hashCode()))
return candidates
}

fun selectNextRuinsReward(triggeringUnit: MapUnit) {
for (possibleReward in getShuffledPossibleRewards(triggeringUnit)) {
var atLeastOneUniqueHadEffect = false
for (unique in possibleReward.uniqueObjects) {
atLeastOneUniqueHadEffect =
Expand Down
5 changes: 1 addition & 4 deletions core/src/com/unciv/logic/map/tile/Tile.kt
Original file line number Diff line number Diff line change
Expand Up @@ -239,10 +239,7 @@ open class Tile : IsPartOfGameInfoSerialization {
if (isExplored) {
// Disable the undo button if a new tile has been explored
if (!exploredBy.contains(player.civName)) {
if (GUI.isWorldLoaded()) {
val worldScreen = GUI.getWorldScreen()
worldScreen.preActionGameInfo = worldScreen.gameInfo
}
GUI.clearUndoCheckpoints()
exploredBy = exploredBy.withItem(player.civName)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ import com.unciv.ui.screens.overviewscreen.EspionageOverviewScreen
import com.unciv.ui.screens.pickerscreens.PolicyPickerScreen
import com.unciv.ui.screens.pickerscreens.TechButton
import com.unciv.ui.screens.pickerscreens.TechPickerScreen
import com.unciv.utils.Concurrency
import com.unciv.ui.screens.worldscreen.UndoHandler.Companion.canUndo
import com.unciv.ui.screens.worldscreen.UndoHandler.Companion.restoreUndoCheckpoint


/** A holder for Tech, Policies and Diplomacy buttons going in the top left of the WorldScreen just under WorldScreenTopBar */
Expand Down Expand Up @@ -118,7 +119,7 @@ class TechPolicyDiplomacyButtons(val worldScreen: WorldScreen) : Table(BaseScree

private fun updateUndoButton() {
// Don't show the undo button if there is no action to undo
if (worldScreen.gameInfo != worldScreen.preActionGameInfo && worldScreen.canChangeState) {
if (worldScreen.canUndo()) {
undoButtonHolder.touchable = Touchable.enabled
undoButtonHolder.actor = undoButton
} else {
Expand Down Expand Up @@ -164,11 +165,6 @@ class TechPolicyDiplomacyButtons(val worldScreen: WorldScreen) : Table(BaseScree

private fun handleUndo() {
undoButton.disable()
Concurrency.run {
// Most of the time we won't load this, so we only set transients once we see it's relevant
worldScreen.preActionGameInfo.setTransients()
worldScreen.preActionGameInfo.isUpToDate = worldScreen.gameInfo.isUpToDate
game.loadGame(worldScreen.preActionGameInfo)
}
worldScreen.restoreUndoCheckpoint()
}
}
39 changes: 39 additions & 0 deletions core/src/com/unciv/ui/screens/worldscreen/UndoHandler.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.unciv.ui.screens.worldscreen

import com.unciv.utils.Concurrency

/** Encapsulates the Undo functionality.
*
* Implementation is based on actively saving GameInfo clones and restoring them when needed.
* For now, there's only one single Undo level (but the class signature could easily support more).
*/
class UndoHandler(private val worldScreen: WorldScreen) {
private var preActionGameInfo = worldScreen.gameInfo

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

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

fun restoreCheckpoint() {
Concurrency.run {
// Most of the time we won't load this, so we only set transients once we see it's relevant
preActionGameInfo.setTransients()
preActionGameInfo.isUpToDate = worldScreen.gameInfo.isUpToDate // Multiplayer!
worldScreen.game.loadGame(preActionGameInfo)
}
}

fun clearCheckpoints() {
preActionGameInfo = worldScreen.gameInfo
}

/** Simple readability proxies so the caller can pretend the interface exists directly on WorldScreen (imports ugly but calls neat) */
companion object {
fun WorldScreen.canUndo() = undoHandler.canUndo()
fun WorldScreen.recordUndoCheckpoint() = undoHandler.recordCheckpoint()
fun WorldScreen.restoreUndoCheckpoint() = undoHandler.restoreCheckpoint()
fun WorldScreen.clearUndoCheckpoints() = undoHandler.clearCheckpoints()
}
}
7 changes: 4 additions & 3 deletions core/src/com/unciv/ui/screens/worldscreen/WorldMapHolder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,6 @@ import com.unciv.models.ruleset.unique.UniqueType
import com.unciv.ui.audio.SoundPlayer
import com.unciv.ui.components.MapArrowType
import com.unciv.ui.components.MiscArrowTypes
import com.unciv.ui.components.widgets.UnitGroup
import com.unciv.ui.components.widgets.ZoomableScrollPane
import com.unciv.ui.components.extensions.center
import com.unciv.ui.components.extensions.colorFromRGB
import com.unciv.ui.components.extensions.darken
Expand All @@ -48,9 +46,12 @@ import com.unciv.ui.components.tilegroups.TileGroup
import com.unciv.ui.components.tilegroups.TileGroupMap
import com.unciv.ui.components.tilegroups.TileSetStrings
import com.unciv.ui.components.tilegroups.WorldTileGroup
import com.unciv.ui.components.widgets.UnitGroup
import com.unciv.ui.components.widgets.ZoomableScrollPane
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.screens.basescreen.UncivStage
import com.unciv.ui.screens.worldscreen.UndoHandler.Companion.recordUndoCheckpoint
import com.unciv.ui.screens.worldscreen.bottombar.BattleTableHelpers.battleAnimation
import com.unciv.utils.Concurrency
import com.unciv.utils.Log
Expand Down Expand Up @@ -278,7 +279,7 @@ class WorldMapHolder(
}


worldScreen.preActionGameInfo = worldScreen.gameInfo.clone()
worldScreen.recordUndoCheckpoint()

launchOnGLThread {
try {
Expand Down
3 changes: 1 addition & 2 deletions core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,7 @@ class WorldScreen(

private var uiEnabled = true

var preActionGameInfo = gameInfo

internal val undoHandler = UndoHandler(this)

init {
// notifications are right-aligned, they take up only as much space as necessary.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import com.unciv.ui.components.input.onClick
import com.unciv.ui.components.widgets.UnitGroup
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.screens.worldscreen.UndoHandler.Companion.clearUndoCheckpoints
import com.unciv.ui.screens.worldscreen.WorldScreen
import com.unciv.ui.screens.worldscreen.bottombar.BattleTableHelpers.battleAnimation
import com.unciv.ui.screens.worldscreen.bottombar.BattleTableHelpers.getHealthBar
Expand Down Expand Up @@ -285,7 +286,7 @@ class BattleTable(val worldScreen: WorldScreen): Table() {
// There was a direct worldScreen.update() call here, removing its 'private' but not the comment justifying the modifier.
// My tests (desktop only) show the red-flash animations look just fine without.
worldScreen.shouldUpdate = true
worldScreen.preActionGameInfo = worldScreen.gameInfo // Reset - can no longer undo
worldScreen.clearUndoCheckpoints()
//Gdx.graphics.requestRendering() // Use this if immediate rendering is required

if (!canStillAttack) return
Expand Down

0 comments on commit 1110811

Please sign in to comment.