Implement a command system

This commit is contained in:
Charlotte Som 2022-02-03 21:43:06 +00:00
parent ca456f0689
commit e30198fdab
23 changed files with 645 additions and 10 deletions

View file

@ -2,6 +2,7 @@ package codes.som.hibiscus.mixins;
import codes.som.hibiscus.HibiscusMod;
import codes.som.hibiscus.events.PlayerTickEvent;
import codes.som.hibiscus.events.SendChatEvent;
import net.minecraft.client.network.ClientPlayerEntity;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
@ -14,4 +15,12 @@ public abstract class MixinClientPlayerEntity {
private void onPostTick(CallbackInfo ci) {
HibiscusMod.bus().fire(PlayerTickEvent.INSTANCE);
}
@Inject(method = "sendChatMessage", at = @At("HEAD"), cancellable = true)
private void onChatMessage(String message, CallbackInfo ci) {
var event = new SendChatEvent(message);
HibiscusMod.bus().fire(event);
if (event.isCancelled())
ci.cancel();
}
}

View file

@ -0,0 +1,20 @@
package codes.som.hibiscus
import net.minecraft.text.LiteralText
import net.minecraft.util.Formatting
object HibiscusLog {
private val hibiscusMessagePrefix
get() = LiteralText("[H] ").styled { it.withColor(0xFFC1F8L.toInt()) }
fun info(message: String) {
val messageText = LiteralText(message).styled { it.withColor(Formatting.WHITE) }
player.sendMessage(hibiscusMessagePrefix.append(messageText), false)
}
fun error(message: String) {
val errorText = LiteralText("Error: ").styled { it.withColor(Formatting.RED) }
val messageText = LiteralText(message).styled { it.withColor(Formatting.WHITE) }
player.sendMessage(hibiscusMessagePrefix.append(errorText).append(messageText), false)
}
}

View file

@ -1,9 +1,12 @@
package codes.som.hibiscus
import codes.som.hibiscus.api.command.CommandManager
import codes.som.hibiscus.api.event.EventBus
import codes.som.hibiscus.api.event.EventPhase
import codes.som.hibiscus.events.KeyEvent
import codes.som.hibiscus.features.FeaturesRegistry
import codes.som.hibiscus.gui.ImGuiScreen
import codes.som.hibiscus.util.command.ChatCommandListener
import codes.som.hibiscus.util.netmoving.NetworkMovingDispatcher
import net.fabricmc.api.ModInitializer
import org.lwjgl.glfw.GLFW
@ -18,9 +21,13 @@ object HibiscusMod : ModInitializer {
val bus = EventBus()
val features = FeaturesRegistry()
val commands = CommandManager()
override fun onInitialize() {
bus.register(NetworkMovingDispatcher())
for (feature in features.getAllFeatures()) {
commands.register(feature.createFeatureCommand())
}
bus.register { event: KeyEvent ->
if (event.key != GLFW_KEY_RIGHT_SHIFT || event.action != GLFW.GLFW_PRESS)
return@register
@ -30,5 +37,8 @@ object HibiscusMod : ModInitializer {
mc.setScreen(ImGuiScreen)
}
bus.register(NetworkMovingDispatcher(), EventPhase.AFTER)
bus.register(ChatCommandListener())
}
}

View file

@ -0,0 +1,72 @@
package codes.som.hibiscus.api.command
import codes.som.hibiscus.api.command.ExecutableCommand.CommandBranch
import codes.som.hibiscus.api.command.parser.ArgumentParser
import java.lang.reflect.ParameterizedType
open class Command(val name: String) {
private val branches = mutableListOf<BranchDeclaration>()
private val aliases = mutableListOf<String>()
fun alias(aliasName: String) {
aliases += aliasName
}
fun branch(branchName: String? = null, handler: Function<*>): BranchDeclaration {
val params = handler.javaClass.genericInterfaces
for (param in params) {
if (param is ParameterizedType) {
val parameterTypes = mutableListOf<Class<*>>()
param.actualTypeArguments
.filterIndexed { index, _ -> index != param.actualTypeArguments.lastIndex }
.filterIsInstance<Class<*>>()
.forEach { parameterTypes += it }
val branchDecl = BranchDeclaration(branchName, handler, parameterTypes.toTypedArray())
branches += branchDecl
return branchDecl
}
}
throw IllegalStateException("Handler function had no generic parameters!")
}
fun buildCommand(): ExecutableCommand {
val command = ExecutableCommand(name, aliases.toTypedArray())
command.branches.addAll(
branches.asSequence()
.map {
CommandBranch(
it.branchName,
it.aliases.toTypedArray(),
it.parameterTypes,
it.handler,
it.typeHints
)
}
)
return command
}
inner class BranchDeclaration internal constructor(
val branchName: String?,
val handler: Function<*>,
val parameterTypes: Array<Class<*>>
) {
internal val aliases = mutableListOf<String>()
internal val typeHints = mutableMapOf<Int, ArgumentParser<*>>()
fun alias(aliasName: String): BranchDeclaration {
aliases += aliasName
return this
}
fun typeHint(index: Int, parser: ArgumentParser<*>): BranchDeclaration {
typeHints.put(index, parser)
return this
}
}
}

View file

@ -0,0 +1,7 @@
package codes.som.hibiscus.api.command
enum class CommandContext {
MANUAL,
KEYBIND,
OTHER,
}

View file

@ -0,0 +1,231 @@
package codes.som.hibiscus.api.command
import codes.som.hibiscus.api.command.ExecutableCommand.CommandBranch
import codes.som.hibiscus.api.command.context.CommandExecutionContext
import codes.som.hibiscus.api.command.exceptions.*
import codes.som.hibiscus.api.command.parser.ArgumentParser
import codes.som.hibiscus.api.command.utils.PeekableIterator
import codes.som.hibiscus.api.command.utils.splitExceptingQuotes
class CommandManager(private val registerDefaultParsers: Boolean = true) {
private val parserRegistry = mutableMapOf<Class<*>, ArgumentParser<*>>()
val commands = mutableListOf<ExecutableCommand>()
var context: CommandContext = CommandContext.OTHER
init {
registerDefaultParsersIfApplicable()
}
private fun registerDefaultParsersIfApplicable() {
if (registerDefaultParsers) {
addDefaultParsers(parserRegistry)
}
}
fun <T> registerParser(type: Class<T>, parser: ArgumentParser<T>) {
parserRegistry.put(type, parser)
}
private fun verifyCommand(command: ExecutableCommand) {
// TODO: Prevent name collisions for commands / aliases
command.branches.forEach {
it.parameterTypes
.filter { it !in parserRegistry }
.forEach { throw MissingParserException(it) }
}
}
@Throws(CommandRegistrationException::class)
fun register(declaration: Command) {
try {
val command = declaration.buildCommand()
verifyCommand(command)
commands += command
} catch (e: Exception) {
throw CommandRegistrationException(e)
}
}
@Throws(CommandExecutionException::class)
fun executeCommand(fullCommand: String) {
try {
val args = splitExceptingQuotes(fullCommand, true).toList()
if (args.isEmpty())
throw CommandNotFoundException("")
val commandName = args[0]
val matchingCommands = commands.filter { commandMatches(commandName, it) }
when {
matchingCommands.isEmpty() -> throw CommandNotFoundException(commandName)
matchingCommands.size > 1 -> throw CommandIsAmbiguousException(commandName)
}
val command = matchingCommands.first()
fun isBranchViable(branch: CommandBranch, args: List<String>): Boolean {
try {
val argObjects = parseArgumentsForBranch(branch, args)
if (argObjects.size == branch.parameterTypes.size) {
return true
}
} catch (e: Exception) {
return false
}
return false
}
val viableBranches = command.branches.filter { branchMatches(args, it) }.filter { isBranchViable(it, args) }
val viableNamedBranches = viableBranches.filter { it.name != null }
if (viableNamedBranches.size > 1)
throw BranchesAreAmbiguousException(commandName, args[1])
// Loop through named branches first, as branch names have priority over arguments.
for (branch in viableNamedBranches) {
executeBranch(branch, args)
return
}
for (branch in viableBranches.filter { it.name == null }) {
executeBranch(branch, args)
return
}
throw NoMatchingBranchesException()
} catch (e: Exception) {
throw CommandExecutionException(e)
}
}
fun completeCommand(fullCommand: String): Array<String> {
return completeCommandDuplicatesSpaces(fullCommand)
.map {
if (it.toCharArray().any { it.isWhitespace() }) "\"$it\"" else it
} // Wrap suggestions containing spaces in quotes
.toSet() // Remove duplicates
.toTypedArray()
}
private fun completeCommandDuplicatesSpaces(fullCommand: String): List<String> {
val args = splitExceptingQuotes(fullCommand, true).toList()
if (args.isEmpty())
return commands.map { it.name }
if (args.size == 1) {
val namesAndAliasesOfCommands = mutableListOf<String>().apply {
addAll(commands.map { it.name })
commands.forEach { addAll(it.aliases) }
}
return namesAndAliasesOfCommands.filter { it.toLowerCase().startsWith(fullCommand.toLowerCase()) }
}
val commandName = args[0]
val matchingCommands = commands.filter { commandMatches(commandName, it) }
when {
matchingCommands.isEmpty() -> return emptyList()
matchingCommands.size > 1 -> throw CommandIsAmbiguousException(commandName)
}
val command = matchingCommands.first()
if (args[1].isBlank())
return command.branches.mapNotNull { it.name }
val matchingBranches = mutableListOf<String>().apply {
addAll(command.branches.mapNotNull { it.name })
command.branches.forEach { addAll(it.aliases) }
}.filter { it.startsWith(args[1]) }
if (args.size == 2)
return matchingBranches
// TODO: Tab completion for parameters
// - Solve ambiguity issues
// ????
return emptyList()
}
private fun parseArgumentsForBranch(branch: CommandBranch, args: List<String>): Array<Any> {
val parsers = mutableListOf<ArgumentParser<*>>()
for ((index, parameterType) in branch.parameterTypes.withIndex()) {
if (index in branch.typeHints) {
parsers.add(branch.typeHints[index]!!)
} else {
if (parameterType !in parserRegistry)
throw MissingParserException(parameterType)
parsers.add(parserRegistry[parameterType]!!)
}
}
val startIndex = if (branch.name == null) 1 else 2
if (parsers.isEmpty() && args.size > startIndex) {
throw ParsingException(
InvalidArgumentCount(
branch.name
?: "<blank>"
)
)
}
val argsIterator = PeekableIterator(args.listIterator(startIndex))
val context = CommandExecutionContext(argsIterator, branch.parameterTypes, 0)
val argumentObjects = mutableListOf<Any>()
val minArgs = parsers.map { it.minimumAcceptedArguments() }.sum()
if (args.size - startIndex >= minArgs && args.size - startIndex >= parsers.size) {
for ((index, parser) in parsers.withIndex()) {
context.currentParameter = index
try {
argumentObjects += parser.parse(context)!!
} catch (e: Exception) {
throw ParsingException(e)
}
}
} else {
throw ParsingException(
InvalidArgumentCount(
branch.name
?: "<blank>"
)
)
}
return argumentObjects.toTypedArray()
}
private fun executeBranch(branch: CommandBranch, args: List<String>) {
val argumentObjects = parseArgumentsForBranch(branch, args)
branch.execute(*argumentObjects)
}
private fun commandMatches(commandName: String, command: ExecutableCommand): Boolean {
return command.name.equals(commandName, ignoreCase = true) ||
command.aliases.any { it.equals(commandName, ignoreCase = true) }
}
private fun branchMatches(args: List<String>, branch: CommandBranch): Boolean {
return branch.name == null ||
(args.size > 1 && (branch.name.equals(args[1], ignoreCase = true) ||
branch.aliases.any { it.equals(args[1], ignoreCase = true) }))
}
fun reset() {
commands.clear()
parserRegistry.clear()
registerDefaultParsersIfApplicable()
}
}

View file

@ -0,0 +1,23 @@
package codes.som.hibiscus.api.command
import codes.som.hibiscus.api.command.parser.*
fun addDefaultParsers(parserRegistry: MutableMap<Class<*>, ArgumentParser<*>>) {
parserRegistry[String::class.java] = StringParser()
parserRegistry[Int::class.java] = IntParser()
parserRegistry[Float::class.java] = FloatParser()
parserRegistry[Double::class.java] = DoubleParser()
parserRegistry[Short::class.java] = ShortParser()
parserRegistry[Long::class.java] = LongParser()
parserRegistry[Char::class.java] = CharParser()
// Java interop
parserRegistry[java.lang.Integer::class.java] = parserRegistry[Int::class.java]!!
parserRegistry[java.lang.Float::class.java] = parserRegistry[Float::class.java]!!
parserRegistry[java.lang.Double::class.java] = parserRegistry[Double::class.java]!!
parserRegistry[java.lang.Short::class.java] = parserRegistry[Short::class.java]!!
parserRegistry[java.lang.Long::class.java] = parserRegistry[Long::class.java]!!
parserRegistry[java.lang.Character::class.java] = parserRegistry[Char::class.java]!!
}

View file

@ -0,0 +1,29 @@
package codes.som.hibiscus.api.command
import codes.som.hibiscus.api.command.parser.ArgumentParser
class ExecutableCommand(val name: String, val aliases: Array<String>) {
companion object {
fun declare(name: String, init: Command.() -> Unit): Command {
val declaration = Command(name)
init(declaration)
return declaration
}
}
val branches = mutableListOf<CommandBranch>()
class CommandBranch(
val name: String?,
val aliases: Array<String>,
val parameterTypes: Array<Class<*>>,
private val handler: Function<*>,
val typeHints: Map<Int, ArgumentParser<*>>
) {
fun execute(vararg args: Any) {
handler.javaClass.declaredMethods.first { it.name == "invoke" }.apply { this.isAccessible = true }
.invoke(handler, *args)
}
}
}

View file

@ -0,0 +1,9 @@
package codes.som.hibiscus.api.command.context
import codes.som.hibiscus.api.command.utils.PeekableIterator
class CommandExecutionContext(
val arguments: PeekableIterator<String>,
val parameters: Array<Class<*>>,
var currentParameter: Int
)

View file

@ -0,0 +1,11 @@
package codes.som.hibiscus.api.command.exceptions
class CommandExecutionException(cause: Exception) : RuntimeException("Exception while executing command", cause)
class CommandRegistrationException(cause: Exception) : RuntimeException("Exception while registering command", cause)
class CommandNotFoundException(name: String) : RuntimeException("The command '$name' was not found.")
class CommandIsAmbiguousException(name: String) : RuntimeException("The command '$name' is ambiguous.")
class NoMatchingBranchesException : RuntimeException("No branches matching were found")
class BranchesAreAmbiguousException(command: String, branchName: String?) :
RuntimeException("Branch '$branchName' is ambiguous for command '$command'.")

View file

@ -0,0 +1,8 @@
package codes.som.hibiscus.api.command.exceptions
class MissingParserException(classToParse: Class<*>) :
RuntimeException("No parser found for type: ${classToParse.name}")
class ParsingException(cause: Exception) : RuntimeException("Exception while parsing", cause)
class InvalidArgumentCount(branchName: String) : RuntimeException("Invalid amount of arguments for branch: $branchName")

View file

@ -0,0 +1,10 @@
package codes.som.hibiscus.api.command.parser
import codes.som.hibiscus.api.command.context.CommandExecutionContext
interface ArgumentParser<out T> {
fun parse(context: CommandExecutionContext): T?
fun provideSuggestions(context: CommandExecutionContext): List<String> = emptyList()
fun minimumAcceptedArguments(): Int = 1
}

View file

@ -0,0 +1,60 @@
package codes.som.hibiscus.api.command.parser
import codes.som.hibiscus.api.command.context.CommandExecutionContext
class StringParser : ArgumentParser<String> {
override fun parse(context: CommandExecutionContext): String {
return if (context.currentParameter == context.parameters.lastIndex) {
buildString {
for (arg in context.arguments) {
append(arg)
if (context.arguments.hasNext())
append(" ")
}
}
} else {
context.arguments.next()
}
}
}
class IntParser : ArgumentParser<Int> {
override fun parse(context: CommandExecutionContext): Int {
return context.arguments.next().toInt()
}
}
class FloatParser : ArgumentParser<Float> {
override fun parse(context: CommandExecutionContext): Float {
return context.arguments.next().toFloat()
}
}
class DoubleParser : ArgumentParser<Double> {
override fun parse(context: CommandExecutionContext): Double {
return context.arguments.next().toDouble()
}
}
class ShortParser : ArgumentParser<Short> {
override fun parse(context: CommandExecutionContext): Short {
return context.arguments.next().toShort()
}
}
class LongParser : ArgumentParser<Long> {
override fun parse(context: CommandExecutionContext): Long {
return context.arguments.next().toLong()
}
}
class CharParser : ArgumentParser<Char> {
override fun parse(context: CommandExecutionContext): Char {
val chars = context.arguments.next().toCharArray()
assert(chars.size == 1)
return chars.first()
}
}

View file

@ -0,0 +1,20 @@
package codes.som.hibiscus.api.command.utils
class PeekableIterator<T>(internal val wrapped: ListIterator<T>) : Iterator<T> {
override fun hasNext(): Boolean {
return wrapped.hasNext()
}
override fun next(): T {
return wrapped.next()
}
fun peek(): T {
val next = wrapped.next()
wrapped.previous()
return next
}
internal fun previous(): T = wrapped.previous()
}

View file

@ -0,0 +1,25 @@
package codes.som.hibiscus.api.command.utils
import java.util.regex.Pattern
fun join(strings: Array<String>, delimiter: String): String {
val str = StringBuilder()
for (string in strings) {
str.append(string)
str.append(delimiter)
}
return str.substring(0, str.length - delimiter.length)
}
fun splitExceptingQuotes(string: String, stripQuotes: Boolean): Array<String> {
val list = arrayListOf<String>()
val m = Pattern.compile("([^\"]\\S*|\".+?\")\\s*").matcher(string)
while (m.find())
list.add(if (stripQuotes) m.group(1).replace("\"", "") else m.group(1))
if (string.endsWith(" "))
list.add("")
return list.toTypedArray()
}

View file

@ -3,25 +3,36 @@ package codes.som.hibiscus.api.event
import java.util.concurrent.CopyOnWriteArrayList
class EventBus {
private val listenerMap = mutableMapOf<Class<out Event>, MutableList<Listener<*>>>()
private val allListeners = mutableMapOf<EventPhase, MutableMap<Class<*>, MutableList<Listener<*>>>>()
inline fun <reified T : Event> register(listener: Listener<T>, phase: EventPhase) {
this.register(T::class.java, listener, phase)
}
inline fun <reified T : Event> register(listener: Listener<T>) {
this.register(T::class.java, listener)
}
fun <T : Event> register(type: Class<T>, listener: Listener<T>) {
fun <T : Event> register(type: Class<T>, listener: Listener<T>, phase: EventPhase = EventPhase.NORMAL) {
val listenerMap = allListeners.getOrPut(phase, ::mutableMapOf)
listenerMap.getOrPut(type, ::CopyOnWriteArrayList).add(listener)
}
inline fun <reified T : Event> unregister(listener: Listener<T>) =
unregister(T::class.java, listener)
inline fun <reified T : Event> unregister(listener: Listener<T>, phase: EventPhase = EventPhase.NORMAL) =
unregister(T::class.java, listener, phase)
fun <T : Event> unregister(type: Class<T>, listener: Listener<T>) =
fun <T : Event> unregister(type: Class<T>, listener: Listener<T>, phase: EventPhase = EventPhase.NORMAL) {
val listenerMap = allListeners[phase] ?: return
listenerMap[type]?.remove(listener)
}
fun <T : Event> fire(event: T) {
@Suppress("UNCHECKED_CAST")
val listeners = (listenerMap[event.javaClass] ?: return) as List<Listener<T>>
listeners.forEach { it.on(event) }
for (phase in EventPhase.values()) {
val listenerMap = allListeners[phase] ?: continue
@Suppress("UNCHECKED_CAST")
val listeners = (listenerMap[event.javaClass] ?: continue) as List<Listener<T>>
listeners.forEach { it.on(event) }
}
}
}

View file

@ -0,0 +1,7 @@
package codes.som.hibiscus.api.event
enum class EventPhase {
BEFORE,
NORMAL,
AFTER
}

View file

@ -1,6 +1,7 @@
package codes.som.hibiscus.api.feature
import codes.som.hibiscus.HibiscusMod
import codes.som.hibiscus.api.command.Command
import codes.som.hibiscus.api.event.*
import codes.som.hibiscus.api.feature.values.ValueRegistry
@ -33,5 +34,9 @@ abstract class Feature(val name: String, val category: FeatureCategory) {
open fun onEnable() {}
open fun onDisable() {}
open fun createFeatureCommand(): Command {
return FeatureCommand(this)
}
// TODO: Module commands
}

View file

@ -0,0 +1,38 @@
package codes.som.hibiscus.api.feature
import codes.som.hibiscus.HibiscusLog
import codes.som.hibiscus.HibiscusMod
import codes.som.hibiscus.api.command.Command
import codes.som.hibiscus.api.command.CommandContext
class FeatureCommand(feature: Feature) : Command(feature.name.replace(" ", "").lowercase()) {
init {
branch {
feature.enabled = !feature.enabled
if (HibiscusMod.commands.context == CommandContext.MANUAL) {
val state = if (feature.enabled) "enabled" else "disabled"
HibiscusLog.info("${feature.name} is now $state.")
}
}
if (feature.values.exist()) {
for (value in feature.values) {
val simplifiedValueName = value.name.toLowerCase().replace(" ", "")
branch(simplifiedValueName) {
HibiscusLog.info("Value of '${value.name}': " + value.getValueAsString())
}
branch(simplifiedValueName) { newValue: String ->
try {
value.setValueFromString(newValue)
HibiscusLog.info("Value of '${value.name}': " + value.getValueAsString())
} catch (e: Exception) {
HibiscusLog.info("Could not set the value of '${value.name}' to '$newValue'")
}
}
}
}
}
}

View file

@ -1,5 +1,7 @@
package codes.som.hibiscus.events
import codes.som.hibiscus.api.event.Cancellable
import codes.som.hibiscus.api.event.Event
object PlayerTickEvent : Event
class SendChatEvent(val message: String) : Cancellable(), Event

View file

@ -2,6 +2,5 @@ package codes.som.hibiscus.features
class FeaturesRegistry {
private val features = ALL_FEATURES.map { it() }
fun getAllFeatures() = features.asSequence()
}

View file

@ -16,4 +16,9 @@ class NoFallDamage : Feature("No Fall Damage", FeatureCategory.PLAYER) {
}
}
}
override fun createFeatureCommand() =
super.createFeatureCommand().apply {
alias("nofall")
}
}

View file

@ -0,0 +1,24 @@
package codes.som.hibiscus.util.command
import codes.som.hibiscus.HibiscusLog
import codes.som.hibiscus.HibiscusMod
import codes.som.hibiscus.api.command.CommandContext
import codes.som.hibiscus.api.command.exceptions.CommandExecutionException
import codes.som.hibiscus.api.event.TypedListener
import codes.som.hibiscus.events.SendChatEvent
class ChatCommandListener : TypedListener<SendChatEvent>(SendChatEvent::class.java) {
override fun on(event: SendChatEvent) {
if (event.message.startsWith(".")) {
try {
HibiscusMod.commands.context = CommandContext.MANUAL
HibiscusMod.commands.executeCommand(event.message.substring(1))
} catch (e: CommandExecutionException) {
// e.printStackTrace()
e.cause?.message?.let { HibiscusLog.error(it) }
}
event.cancel()
}
}
}