Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrate KTS Plugins to KT for Compatibility and Future K2 Support #552

Open
HarleyGilpin opened this issue Oct 15, 2024 · 6 comments
Open
Assignees
Labels
refactor refactoring

Comments

@HarleyGilpin
Copy link
Member

HarleyGilpin commented Oct 15, 2024

We are proposing to migrate our KTS (Kotlin Script) plugins to regular Kotlin (KT) in order to ensure better compatibility with the latest versions of IntelliJ IDEA and to make it easier to add K2 support in the future. Below is the reasoning behind this proposed migration.


Context and Rationale:

  1. IDE Support:

    • KTS has increasingly unreliable support in modern IDEs, especially IntelliJ IDEA. As newer Kotlin and IntelliJ versions are released, the KTS feature set often lags behind, leading to compatibility issues.
    • Migrating to KT will allow us to stay updated with the latest tooling and avoid being locked to outdated versions of IntelliJ or Kotlin.
  2. K2 Compatibility:

    • Kotlin K2 is the new compiler under active development, which introduces significant changes. As seen in previous updates, KTS can become fragile with major language/compiler shifts, and this is a risk we want to avoid.
  3. Boilerplate Reduction vs. Fragility:

    • While KTS reduces boilerplate code in some cases, such as when defining classes like TestPlugin: Plugin, this benefit does not justify the instability of using KTS, especially as it relates to newer Kotlin versions.
    • KTS also introduces implicit behaviors (e.g., implicit imports) which make the code harder to follow for both new and experienced developers.
  4. Dependency Injection (DI) Issues:

    • Since all code within KTS files is placed into the Kotlin-generated class constructor (which we have no way to access within code), we are forced to rely on an inject() method for injecting dependencies into our code. To create such a function, each KTS class now requires access to the Injector, which, when using Guice, contradicts the best practice of "using the Injector as sparingly as possible, preferably only once." All code throughout the script file has access to this method, allowing developers to call it at runtime without any restrictions, which can be quite dangerous.
  5. KTS is Heavily "Magic" Dependent:

    • KTS involves a level of abstraction ("magic") that is challenging for developers to follow, increasing the learning curve and decreasing overall code clarity.
    • Relying on compiler "magic" can obscure code functionality, making it difficult to debug and maintain.
  6. Neglected Maintenance:

    • The KTS ecosystem has been relatively neglected by its maintainers, with fewer updates and bug fixes compared to regular Kotlin. This leads to increased fragility in the face of updates to Kotlin or IntelliJ IDEA.
  7. Limited Usage Scenarios:

    • The way we are using KTS in RSPS does not align with the vision of its primary maintainer, further complicating its viability for long-term support.
@alycii
Copy link
Collaborator

alycii commented Oct 21, 2024

Proposal for replacement

This was written for PokeForce but was eventually scrapped in favor of a Java environment. The following code simply provides a "similar" but alternative way to construct plugins. This will require a bit more involvement to introduce to 2011Scape, and some more insight into its core.

package org.pokeforce.game.plugin

import org.pokeforce.game.plugin.impl.Interaction
import org.pokeforce.model.World
import org.pokeforce.model.entity.Player

/**
 * @author Alycia <https://github.com/alycii>
 */
abstract class Plugin(protected val world: World) {

    private val interactions = mutableListOf<Interaction>()
    abstract fun buildPlugin()

    fun registerInteraction(interaction: Interaction) {
        interactions.add(interaction)
    }

    fun handleEvent(event: GameEvent, player: Player): Boolean {
        val interaction = interactions.firstOrNull { it.event.id == event.id }
        interaction?.action?.invoke(player, event)
        return interaction != null
    }

}
package org.pokeforce.game.plugin

import org.pokeforce.model.timer.TimerKey

/**
 * @author Alycia <https://github.com/alycii>
 */
sealed class GameEvent {
    abstract val id: String

    data class TileInteraction(val mapId: Int, val x: Int, val y: Int, override val id: String = "TileInteraction${mapId}_${x}_$y") : GameEvent()
    data class BagInteraction(val item: Int, override val id: String = "BagInteraction${item}") : GameEvent()
    data class LoginPlugin(override val id: String = "login") : GameEvent()
    data class TimerTrigger(val key: TimerKey, override val id: String = "timer_$key") : GameEvent()
    data class NodeInteraction(val nodeType: String, override val id: String = "NodeInteraction_${nodeType}") : GameEvent()

}
package org.pokeforce.plugin.api

import org.pokeforce.game.plugin.GameEvent
import org.pokeforce.game.plugin.Plugin
import org.pokeforce.game.plugin.impl.Interaction
import org.pokeforce.model.entity.Player
import org.pokeforce.model.timer.TimerKey

/**
 * @author Alycia <https://github.com/alycii>
 */
object PluginExt {
    fun Plugin.onTileInteraction(name: String = "", mapId: Int, x: Int, y: Int, action: Player.(GameEvent.TileInteraction) -> Unit) {
        registerInteraction(Interaction(GameEvent.TileInteraction(mapId, x, y)) { player, event ->
            if (event is GameEvent.TileInteraction) {
                player.action(event)
            }
        })
    }

    fun Plugin.onBagInteraction(name: String = "", item: Int, action: Player.(GameEvent.BagInteraction) -> Unit) {
        registerInteraction(Interaction(GameEvent.BagInteraction(item)) { player, event ->
            if (event is GameEvent.BagInteraction) {
                player.action(event)
            }
        })
    }

    fun Plugin.onNodeInteraction(name: String = "", nodeType: String, action: Player.(GameEvent.NodeInteraction) -> Unit) {
        registerInteraction(Interaction(GameEvent.NodeInteraction(nodeType)) { player, event ->
            if (event is GameEvent.NodeInteraction) {
                player.action(event)
            }
        })
    }

    fun Plugin.onLogin(name: String = "", action: Player.(GameEvent.LoginPlugin) -> Unit) {
        registerInteraction(Interaction(GameEvent.LoginPlugin()) { player, event ->
            if (event is GameEvent.LoginPlugin) {
                player.action(event)
            }
        })
    }

    fun Plugin.onTimer(name: String = "", key: TimerKey, action: Player.(GameEvent.TimerTrigger) -> Unit) {
        registerInteraction(Interaction(GameEvent.TimerTrigger(key)) { player, event ->
            if (event is GameEvent.TimerTrigger) {
                player.action(event)
            }
        })
    }

}
package org.pokeforce.plugin.skills.foraging

import com.badlogic.gdx.math.MathUtils
import org.pokeforce.game.plugin.Plugin
import org.pokeforce.message.impl.Emote
import org.pokeforce.model.Location
import org.pokeforce.model.World
import org.pokeforce.model.entity.Player
import org.pokeforce.model.map.objects.ForageNodeObject
import org.pokeforce.model.skill.Skills
import org.pokeforce.plugin.api.PluginExt.onBagInteraction

/**
 * @author Alycia <https://github.com/alycii>
 */
class ForagingAction(world: World) : Plugin(world) {

    companion object {
        private const val TOOL_USE_DELAY = 150
        private val RESPAWN_DELAY = 25_000..100_000
    }

    override fun buildPlugin() {
        onBagInteraction("trowel", item = 914) {
            val targetLocation = location.getFacingCoordinates(lastFacingDirection)
            val node = getNode(targetLocation)
            if (node == null) {
                emote(Emote.CANT_DIG)
                return@onBagInteraction
            }
            val pickable = Pickables.findByNode(node.nodeType)
            if (pickable == null) {
                emote(Emote.CANT_DIG)
                return@onBagInteraction
            }
            if (!canPick(this, pickable)) {
                emote(Emote.CANT_DIG)
                return@onBagInteraction
            }
            queue {
                emote(Emote.DIG)
                wait(TOOL_USE_DELAY)
                node.animate(1)
                giveAndNotify(pickable.reward, node.location)
                wait(5)
                node.invisible = true
                addXp(Skills.FORAGING.ordinal, pickable.experience)
                occupiedPickableLocations.remove(targetLocation)
                world.queue {
                    wait(MathUtils.random(25_000, 100_000))
                    refillNode(this@onBagInteraction)
                }
            }
        }
    }

    private fun canPick(player: Player, pickable: Pickables): Boolean {
        if (player.skillSet[Skills.FORAGING.ordinal].currentLevel < pickable.requiredLevel) {
            player.sendMessage("You need level ${pickable.requiredLevel} Foraging to forage this item.")
            return false
        }
        return true
    }

    private fun refillNode(player: Player) {
        val availableNodes = ForageNodeObject.forageNodes.filter {
            val newNodeLocation = Location(it.x, it.y, 0)
            !player.occupiedPickableLocations.contains(newNodeLocation)
        }
        if (availableNodes.isNotEmpty()) {
            val newNode = ForageNodeObject.forageNodes.random()
            val newNodeLocation = Location(newNode.x, newNode.y, 0)
            player.occupiedPickableLocations.add(newNodeLocation)
            player.buildResourceNode(newNode.name, x = newNode.x, y = newNode.y, map = 0, definitionId = newNode.definitionId)
            println("New node has spawned ${newNode.name}: ${newNode.x}, ${newNode.y}")
        }
    }

}
private fun handleNodeInteraction(client: Client): Boolean {
        val targetLocation = client.location.getFacingCoordinates(client.lastFacingDirection)
        val node = client.getNode(targetLocation) ?: return false
        return client.dispatchEvent(GameEvent.NodeInteraction(node.nodeType))
    }
 /**
    * Dispatches an event from this player using the world's plugin manager.
    */
   fun dispatchEvent(event: GameEvent): Boolean {
       return world.pluginManager.dispatchEvent(event, this)
   }
 val pluginManager = PluginManager()

    /**
     * Loads all plugins using reflection to automatically discover plugin classes in a specified package.
     * This function instantiates each plugin and registers it to the PluginManager for event handling.
     */
    fun loadPlugins(world: World) {
        val reflections = Reflections(
            ConfigurationBuilder().setUrls(ClasspathHelper.forPackage("org.pokeforce.plugin")).setScanners(SubTypesScanner()).filterInputsBy(FilterBuilder().includePackage("org.pokeforce.plugin"))
        )

        val pluginClasses = reflections.getSubTypesOf(Plugin::class.java)
        pluginClasses.forEach { pluginClass ->
            val pluginInstance = pluginClass.getDeclaredConstructor(World::class.java).newInstance(world)
            pluginManager.registerPlugin(pluginInstance)
        }
    }
package org.pokeforce.game.plugin

import org.pokeforce.model.entity.Player

/**
 * Handles the registration and event dispatching for plugins.
 */
class PluginManager {

    val plugins = mutableListOf<Plugin>()

    fun registerPlugin(plugin: Plugin) {
        plugins.add(plugin)
        plugin.buildPlugin()
    }

    fun dispatchEvent(event: GameEvent, player: Player): Boolean {
        plugins.forEach { plugin ->
            if (plugin.handleEvent(event, player)) {
                return true
            }
        }
        return false
    }
}

Please let me know if I'm missing anything from this code dump.

@MatthewBishop
Copy link

Hmm. Very interesting @alycii . I personally prefer the rsmod style of a separate map per different plugin/interaction type. This simplifies it to a single lookup and you can get the safety of easily having debug out for binding duplicate plugins/overriding behavior.

Storing in a list like that which gets iterated it looks like there could be issues with the order that plugins get registered. For example potentially causing issues if a repackage occurs and then the scanner registers them in a different order (assuming you have the same ids bound across 2 different plugins).

I do like the "buildPlugin" method. It is nice to move the binding of the handlers to that rather than the init{} block like you would get with a direct conversion of the existing rsmod kts. If the binding of the handlers gets desynched from the scanning/instantiation then it would also be easier to multithread safely. As the project size increases that might help.

On my rsmod fork i basically just deleted this class
https://github.com/2011Scape/game/blob/main/game/src/main/kotlin/gg/rsmod/game/plugin/KotlinPluginConfiguration.kt
these lines
https://github.com/2011Scape/game/blob/main/game/src/main/kotlin/gg/rsmod/game/plugin/KotlinPlugin.kt#L32-L36

And then the kotlinscript references from any of the gradle files.

@MatthewBishop
Copy link

Just as an update on this: https://blog.jetbrains.com/kotlin/2024/11/state-of-kotlin-scripting-2024/

Probably will be in the cards to get off kts in the near future. When i did it on my src, it took maybe an hour to migrate the kts, but id done it via hand. A macro or something setup could be nice.

@ipkpjersi
Copy link
Contributor

ipkpjersi commented Nov 21, 2024

Yep, because of that announcement many people in this scene are switching away from KTS. It's likely the best move going forward.

@DesecratedTree
Copy link
Contributor

IMG_2314

https://media.z-kris.com/2024/11/idea64_X0HXEzKzdM.png

@DesecratedTree
Copy link
Contributor

Added a screenshot from R-S on the topic for archive purposes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
refactor refactoring
Projects
Status: Todo
Development

No branches or pull requests

5 participants