1. Atomix offered the opportunity for interplay between the levels framework and the tiers framework via Temples & Tiers.
  2. Through Temples, players were given the ability to capture temple territories to then be granted access to their valuable mines.
  3. Through Tiers, players gained access to better mines. Better mines = more progress = higher leveling ability, but only if they have the PvP acumen to stay in the fight!
Product Manager & QAChris Galetta
DevelopmentAtomix Network Team
A visual of a claimable temple on Atomix.

Region Entry and Temple Claiming, Visualized

A temple is a claimable, protected area of the temple world, with a mine region inside it. Every player is in Group A, in Group B, or ungrouped. Every temple is unclaimed, claimed by a group, or under attack. A player's tier is their level of access to better mines. The code that tracks a temple's status and applies the rules for it is a state machine. Pick a player and a temple status below to see what a player is told on entry, what the server records, and whether they can mine.

This section is interactive. Tap the glowing buttons and blocks. Each tap shows the rule the server applies. Temple simulator Region and mining scenes

1. Choose a player, choose a temple status Interactive

Choose who walks in and the temple's status. The rows below show how the server responds. Choose Under attack, then press the button on the temple to flip the claim.

The player is…

The temple is…

Player
A
Group A player
Temple Claimed by Group A

Owner stored as Group A

Open Temple mine

Watch it happen Interactive

Two small scenes show the same logic in action. In scene 1, walk the player into the mine. That unlocks scene 2, where you mine a block. Scene 2 uses the player and temple status you picked above.

1Walk into the mine Tap to move

Tap Outside, Temple, then Mine. Each move fires an event, a signal the server reacts to. The log lists the events and what runs next.

2Mine a block Locked

▼ Tap a block

Tap a block to try breaking it. The three checks run in order. If one fails, the action is cancelled and the log shows the reason.

2. Behind the scenes: what happens when a player moves

These are the server steps behind the simulator and scenes above. They run for every player move and every block break.

1

The player moves or teleports

Walking onto a new block and teleporting both fire an event. Every one of these is a chance for the player to have entered or left a temple, so the server checks each one, but only for players who are in a temple world (see the note in step 2).

2

The player's regions are checked

The server looks up every protected region at the block the player left and at the block they arrived on, and asks which of them belong to a temple: either the temple's main region or the mine region inside it. A region the player was already standing in is ignored, so only a real entry or exit counts. There are three possible results:

Performance note: this region check only runs while the player is in a temple world. Anywhere else the event is skipped straight away, which cuts the strain and resource drain on the server.

No temple regionNothing happens. The player is just somewhere else in the world.
Entered the temple regionThe entry rules run (step 3).
Entered the mine inside a templeA separate mine-entry event fires. Mining is then policed by the access check (step 4).
3

Entry rules run for the temple

What happens depends on how the player relates to the temple's owner:

  • Message: an unclaimed temple, one held by the player's own group, and one held by an enemy each send a different message.
  • Timestamp: the entry is saved to the temple's file, as a group entry if the player's group owns the temple and as an enemy entry otherwise.
  • Tracking: a player inside their own group's temple is added to the in-temple list with the time they entered. Leaving removes them.
4

The mine access check runs when they try to mine

Whenever a player breaks or places a block inside a temple's mine, the server finds which temple the mine belongs to and asks three questions in order. The first failure cancels the action and sends that reason:

1Are they in a group?No: refused
2Does their group own this temple?No: refused
3Is their tier high enough for this mine?No: refused
✓All three passThe block is broken or placed

3. The three temple statuses

Each temple is always in one of these statuses. The status decides what players are told on entry and who may mine. The temple buttons in the simulator switch between them.

Unclaimed

No group holds the temple, so its mine is closed to everyone.

  • Owner is stored as an empty group name.
  • Anyone entering is told the temple is not claimed.
  • Nobody can mine: a grouped player does not control it, and an ungrouped player is refused outright.
  • Reached by setting the owner to a blank group name.

Claimed by a group

One group owns the temple. Only that group can use its mine, and only at the required tier.

  • Owner is stored as the group's name and saved to the temple's file.
  • The owning group is told it holds the temple, and every other player gets an enemy warning.
  • Only owners at or above the mine's tier requirement can break or place blocks in the mine.
  • Reached through the claim setter (the code that assigns an owner), which refuses a group name that does not exist.

Under attack, claim changing

A fight is on. The owner keeps its rights until the claim flips to the attackers.

  • The attack status is stored separately from the owner, so the owner keeps its rights while the fight runs.
  • Entries by the owner and by enemies are timestamped separately, so each side's recent presence is on record.
  • When the claim flips, players outside the new owner's group are dropped from in-temple tracking and the mine locks to the new owner.

4. Safeguards around the state machine

A state machine keeps each temple in one status and changes it only by set rules. These six safeguards keep that reliable and testable.

Admin lock

While the plugin's lock flag is on, no temple status or owner can change. Both setters (the code that changes them) exit early, so nothing shifts during a locked period.

Over-claim cleanup

When a temple changes hands (an over-claim), every online player outside the new owner's group is removed from the in-temple list, so nobody keeps benefits from an old claim.

Group changes mid-fight

Quitting a group removes the player from the in-temple list and clears any boss that was targeting them. Joining a group clears targeting from bosses of the group they just joined. A group change always clears boss targeting (aggro) cleanly.

Stray mob cleanup

A repeating task removes stray mobs inside temple regions. It keeps players, armor stands, custom Mythic mobs and anything tagged as belonging to the temple.

State survives restarts

Owner, game state and both entry timestamps are written to each temple's own YAML file, then read back when temples load, so claims are not lost when the server restarts.

Admin-triggerable

Because state and owner have their own setters, an admin can place any temple in any state to test it without breaking the game.

Summary: how it all fits together

Players choose one of two groups to fight for, shown here as Group A and Group B. Through their activities in the temple world they level up their tier, and they can then choose to engage in combat to claim temples and use their mines. How a fight starts and how long a capture takes live in the temple class and are not part of this module, so the diagram shows the status changes and not the capture timers.

Temple Gamestates Code

This is the temple module's source, the code the sections above describe. Scroll the box to find the event listeners, the lock check and the setters.

  1. Working through the logic and accounting for all of the possible scenarios that would play out took several dozen hours and touched the hands of multiple developers.
  2. This project in particular taught me the importance of having developers write clean, readable, and well documented code so that future teammates can work on it effectively (and with less pain...)
  3. When it was all said and done, not only did we have a working state machine, but we also had the ability to trigger certain states with administrative commands without breaking the game.
import com.sk89q.worldguard.protection.regions.ProtectedRegion
import com.sxtanna.atomix.events.atomix.AtomixEventListener
import com.sxtanna.atomix.events.bukkit.BukkitEventListener
import com.sxtanna.atomix.exte.TEMPLE_META
import com.sxtanna.atomix.group.event.GroupJoinEvent
import com.sxtanna.atomix.group.event.GroupQuitEvent
import com.sxtanna.atomix.group.xAtomixGroup
import com.sxtanna.atomix.lang.xAtomixLang
import com.sxtanna.atomix.libs.mine.MineHook.SxMine
import com.sxtanna.atomix.module.xAtomixModule
import com.sxtanna.atomix.player.event.PlayerMoveBlockEvent
import com.sxtanna.atomix.store.yaml.TempleYamlPull
import com.sxtanna.atomix.temple.event.*
import com.sxtanna.atomix.temple.event.base.TempleEvent
import com.sxtanna.atomix.xAtomix
import de.myzelyam.api.vanish.PlayerShowEvent
import io.lumine.mythic.bukkit.MythicBukkit
import org.bukkit.Location
import org.bukkit.World
import org.bukkit.block.Block
import org.bukkit.configuration.file.YamlConfiguration
import org.bukkit.entity.ArmorStand
import org.bukkit.entity.Entity
import org.bukkit.entity.LivingEntity
import org.bukkit.entity.Player
import org.bukkit.event.EventPriority.MONITOR
import org.bukkit.event.EventPriority.NORMAL
import org.bukkit.event.block.BlockBreakEvent
import org.bukkit.event.block.BlockPlaceEvent
import org.bukkit.event.player.PlayerJoinEvent
import org.bukkit.event.player.PlayerTeleportEvent
import org.bukkit.event.world.ChunkUnloadEvent
import org.bukkit.util.Vector
import java.io.File
import java.util.concurrent.ConcurrentHashMap

class xAtomixTempleModule internal constructor(override val plugin: xAtomix) : xAtomixModule("Temples")
{

    private val loadedTemples = mutableMapOf<String, xAtomixTemple>()
    private val templeDecoder = TempleYamlPull(plugin)


    private var overClaimListener: AtomixEventListener<TempleClaimedEvent>? = null
    private var whenMovedListener: AtomixEventListener<PlayerMoveBlockEvent>? = null

    private var tempEnterListener: AtomixEventListener<PlayerEnterTempleEvent>? = null
    private var tempLeaveListener: AtomixEventListener<PlayerLeaveTempleEvent>? = null
    private var mineEnterListener: AtomixEventListener<PlayerEnterTempleMineEvent>? = null
    private var mineLeaveListener: AtomixEventListener<PlayerLeaveTempleMineEvent>? = null

    private var groupQuitListener: AtomixEventListener<GroupQuitEvent>? = null
    private var groupJoinListener: AtomixEventListener<GroupJoinEvent>? = null

    private var playerJoinListener: BukkitEventListener<PlayerJoinEvent>? = null
    private var unvanishListener: BukkitEventListener<PlayerShowEvent>? = null
    private var templeMineBreakListener: BukkitEventListener<BlockBreakEvent>? = null
    private var templeMinePlaceListener: BukkitEventListener<BlockPlaceEvent>? = null
    private var templeTeleportsListener: BukkitEventListener<PlayerTeleportEvent>? = null
    private var templeChunkKillListener: BukkitEventListener<ChunkUnloadEvent>? = null


    private val playersInTheirTemple = ConcurrentHashMap<Player, Long>()


    override fun load()
    {
        super.load()

        pluginFolder.resolve("temples").mkdirs()


        loadTemples()
        loadedTemples.values.forEach(xAtomixTemple::load)

        fun TempleEvent.updateLastEntry(playerGroup: () -> xAtomixGroup?)
        {
            val tGroup = templeGroup()
            val pGroup = playerGroup()

            if (tGroup != null && tGroup == pGroup)
            {
                temple.gameData.lastGroupEntry = System.currentTimeMillis()
                temple.conf.set("GameData.Cache.LastGroupEntry", temple.gameData.lastGroupEntry)
                temple.save()
            } else
            {
                temple.gameData.lastEnemyEntry = System.currentTimeMillis()
                temple.conf.set("GameData.Cache.LastEnemyEntry", temple.gameData.lastEnemyEntry)
                temple.save()
            }
        }


        tempEnterListener = plugin.atomixEvents.listen {
            updateLastEntry(::playerGroup)

            val tGroup = templeGroup()
            val pGroup = playerGroup()

            if (tGroup == null)
            {
                return@listen plugin.langModule.send(xAtomixLang.TEMPLE_ENTER_NOT_CLAIMED, player, mapOf("temple_name" to temple.name))
            }

            val value = mapOf(
                    "temple_name" to temple.name,
                    "your_group" to (pGroup?.named?.external ?: "unknown"),
                    "enemy_group" to tGroup.named.external
            )

            if (tGroup == pGroup)
            {
                plugin.langModule.send(xAtomixLang.TEMPLE_ENTER_WAS_CLAIMED_BY_GROUP, player, value)

                // log when they enter
                playersInTheirTemple[player] = System.currentTimeMillis()
            } else
            {
                plugin.langModule.send(xAtomixLang.TEMPLE_ENTER_WAS_CLAIMED_BY_ENEMY, player, value)
            }
        }

        tempLeaveListener = plugin.atomixEvents.listen {
            updateLastEntry(::playerGroup)

            // remove when they leave
            playersInTheirTemple -= player
            debug("Removed ${player.name} from players in temple")
        }

        templeMineBreakListener = plugin.bukkitEvents.listen(priority = NORMAL) {
            if (isAllowedToInteractHere(player, block)) {
                return@listen
            }

            isCancelled = true
        }

        templeMinePlaceListener = plugin.bukkitEvents.listen(priority = NORMAL) {
            if (isAllowedToInteractHere(player, block))
            {
                return@listen
            }

            isCancelled = true
        }

        templeTeleportsListener = plugin.bukkitEvents.listen(MONITOR, ignoreCancelled = true) {
            calcFromTo(player, from.block, to.block)
        }

        whenMovedListener = plugin.atomixEvents.listen {
            calcFromTo(player, fromBlock, intoBlock)
        }

        templeChunkKillListener = plugin.bukkitEvents.listen {
            if (!isATempleWorld(world))
            {
                return@listen
            }

            val temples = plugin.worldG.getCurrentRegions(chunk).mapNotNull(::tempByRegion)
            if (temples.isEmpty())
            {
                return@listen
            }
        }

        groupQuitListener = plugin.atomixEvents.listen {
            playersInTheirTemple.remove(player)
            debug("Removed ${player.name} from players in temple as they left their group")
            MythicBukkit.inst().mobManager.mobsInCombat.forEach()
            { mob ->
                if ((mob.hasThreatTable() && mob.threatTable.topThreatHolder.uniqueId === player.uniqueId))
                {
                    mob.threatTable.clearTarget()
                }
            }
        }

        groupJoinListener = plugin.atomixEvents.listen {
            MythicBukkit.inst().mobManager.mobsInCombat.forEach()
            { mob ->
                if ((mob.hasThreatTable() && mob.threatTable.topThreatHolder.uniqueId === player.uniqueId))
                {
                    if (mob.faction.equals(player.group()?.name, ignoreCase = true))
                    {
                        mob.threatTable.clearTarget()
                    }
                }
            }
        }

        /*
        repeat(20)
        {
            playersInTheirTemple.forEach()
            { (player, entered) ->
                val temple = player.currentTemple() ?: return@forEach
                if (temple.atomGain.not())
                {
                    return@forEach
                }

                val (_, gain) = temple.gameData.atomGainTimeValues.floorEntry((System.currentTimeMillis() - entered) / 1000L)
                        ?: return@forEach

                plugin.tokenE.addAtomsFor(player, gain.toDouble())
            }
        }
        */

        // TODO: Try to do async
        repeat(20)
        {
            temples()
                    .asSequence()
                    // get all entities inside of the temple region
                    .flatMap()
                    { temple ->
                        temple.world.entities.asSequence().filter { plugin.worldG.isWithin(it, temple.mainRegion) }
                    }
                    // ignore players, armor stands, and non living entities
                    .filterNot()
                    { entity ->
                        entity is Player || entity is ArmorStand || entity !is LivingEntity
                    }
                    // ignore entities that are mythic mobs
                    .filterNot()
                    { entity ->
                        plugin.mythMb.isMythicMob(entity)
                    }
                    // ignore entities that have the temple tag (or are children of mobs who do)
                    .filterNot()
                    { entity ->
                        entity.persistentDataContainer.has(TEMPLE_META)
                                || (plugin.mythMb.getParentOfMobSpigot(entity))?.persistentDataContainer?.has(
                            TEMPLE_META
                        ) ?: false
                    }
                    // remove excess entity
                    .forEach(Entity::remove)
        }
    }

    override fun kill()
    {
        super.kill()

        loadedTemples.values.forEach(xAtomixTemple::kill)
        loadedTemples.clear()

        templeMineBreakListener.detach()
        templeMinePlaceListener.detach()
        overClaimListener.detach()
        templeTeleportsListener.detach()
        whenMovedListener.detach()

        tempEnterListener.detach()
        tempLeaveListener.detach()
        mineEnterListener.detach()
        mineLeaveListener.detach()


    }


    override fun onPlayerJoin(player: Player)
    {
        handleMoveBetween(player, ::PlayerEnterTempleEvent, ::PlayerEnterTempleMineEvent)
    }

    override fun onPlayerQuit(player: Player)
    {
        handleMoveBetween(player, ::PlayerLeaveTempleEvent, ::PlayerLeaveTempleMineEvent)
    }


    fun temples(): List<xAtomixTemple>
    {
        return loadedTemples.values.toList()
    }

    fun templeByName(name: String): xAtomixTemple?
    {
        return loadedTemples[name]
    }


    fun mineByRegion(region: ProtectedRegion): SxMine?
    {
        val minVec = region.minimumPoint
        val maxVec = region.maximumPoint

        return plugin.prMine.findMine(Vector(minVec.x, minVec.y, minVec.z), Vector(maxVec.x, maxVec.y, maxVec.z))?.apply { this.region = region }
    }


    fun tempByVector(location: Location): xAtomixTemple?
    {
        return temples().find { it.world == location.world && it.mainRegion.contains(location.blockX, location.blockY, location.blockZ) }
    }

    fun tempByRegion(region: ProtectedRegion): xAtomixTemple?
    {
        return temples().find { it.mainRegion == region || it.mineRegion == region }
    }

    fun tempByMine(mine: SxMine): xAtomixTemple?
    {
        return temples().find { it.mineRegion == mine.region }
    }


    fun isATempleWorld(world: World): Boolean
    {
        return loadedTemples.values.any { it.world.uid == world.uid }
    }

    fun setTempleClaimedBy(temple: xAtomixTemple, groupName: String)
    {
        if (plugin.spigot.lock) return

        /*
        Dirty, but hey, it works
        If the temple is being unclaimed, null group will match it.group() != null
        Otherwise, make sure the group exists and get it
        */
        val group = if (groupName == "") null else run {
            val group = plugin.groupsModule.groupByName(groupName)
            if (group == null)
            {
                logger.warning("Will not set ${temple.name} to non-existent group $groupName")
                return
            }
            group
        }

        onlinePlayers
            .filter { it.group() != group }
            .forEach {
                playersInTheirTemple.remove(it)
                debug("Removed ${it.name} from players in temple, as the temple was over-claimed")
            }

        temple.gameData.claimedBy = groupName

        temple.conf.set("GameData.Group", groupName)
        temple.save()
    }

    fun setTempleGameState(temple: xAtomixTemple, stateName: String)
    {
        if (plugin.spigot.lock) return

        temple.conf.set("GameData.State", stateName)
        temple.save()
    }


    private fun isAllowedToInteractHere(player: Player, block: Block): Boolean
    {
        val mine = plugin.prMine.findMine(block) ?: return true
        val temp = temples().find { it.gameData.mineNamed == mine.name } ?: return true

        val pGroup = player.group()

        if (pGroup == null)
        {
            plugin.langModule.send(xAtomixLang.MINE_NOT_IN_GROUP, player, timeout = 10)
            return false
        }

        val tGroup = temp.group()
        if (pGroup != tGroup)
        {
            plugin.langModule.send(xAtomixLang.MINE_DOES_NOT_CONTROL, player, timeout = 10)
            return false
        }

        val curTier = player.groupTier() ?: return true
        val reqTier = tGroup.perms.getRequiredTier(temp) ?: return true

        if (curTier < reqTier)
        {
            plugin.langModule.send(xAtomixLang.MINE_TIER_TOO_LOW, player, timeout = 10)
            return false
        }

        return true
    }

    private fun calcFromTo(player: Player, fromBlock: Block, intoBlock: Block)
    {
        val fromRegions = plugin.worldG.getCurrentRegions(fromBlock.location)
        val intoRegions = plugin.worldG.getCurrentRegions(intoBlock.location)

        handleMoveBetween(player, ::PlayerLeaveTempleEvent, ::PlayerLeaveTempleMineEvent, fromRegions, intoRegions)
        handleMoveBetween(player, ::PlayerEnterTempleEvent, ::PlayerEnterTempleMineEvent, intoRegions, fromRegions)
    }


    private fun handleMoveBetween(
            player: Player,
            tempEvent: (xAtomixTemple, Player) -> TempleEvent,
            mineEvent: (SxMine, xAtomixTemple, Player) -> TempleEvent,
            regions: Iterable<ProtectedRegion> = plugin.worldG.getCurrentRegions(player.location),
            filterNotIn: Iterable<ProtectedRegion> = emptySet()
    )
    {
        regions.forEach()
        {
            if (it in filterNotIn)
            {
                return@forEach
            }
            val mine = mineByRegion(it)
            val temp = tempByRegion(it)

            if (mine != null && temp != null && temp.mineRegion in regions)
            {
                plugin.atomixEvents.push(mineEvent.invoke(mine, temp, player))
                return
            }

            if (temp != null && temp.mineRegion != it)
            {
                plugin.atomixEvents.push(tempEvent.invoke(temp, player))
            }
        }
    }


    private fun loadTemples()
    {
        // load temples from folder
        File(pluginFolder, "temples").listFiles { file -> file.extension.equals("yml", true) }?.forEach {
            logger.info("[Temples] attempting to load temple from file: ${it.name}")
            try
            {
                val temple = templeDecoder.pull(YamlConfiguration.loadConfiguration(it)) ?: return@forEach
                temple.file = it

                loadedTemples[temple.name] = temple

                mineByRegion(temple.mineRegion)?.let { mine ->
                    temple.gameData.mineNamed = mine.name
                }

                logger.info("[Temples] ${temple.name}'s state is ${temple.templeState.name}")
            } catch (ex: Exception)
            {
                logger.severe("[Temples] failed to load temple from file: ${it.name}")
                ex.printStackTrace()
            }
        }
    }
}