Skip to content

Related: API概览.md · 脚本API.md · 条件与表达式.md · ../05-参考/术语表.md

⚙️ Action System & Skill Bridge (ActionSystem / QinhActionHandler)

This page targets Java / Kotlin module developers and covers two things:

  1. ActionSystem — QCL's built-in "action" execution framework (action chains, DSL programs, registry, tracing).
  2. The QinhActionHandler contract — the seam through which QI (QinhItems) hands a "trigger" over to QS (QinhSkills) and other modules to "execute a skill", including the unified trigger type enum, Bukkit event mapping, the cross-module event bus QISkillUseEvent, and the bridge object QISkillBridge.

🧩 Who should read this page: this is the module developer's contract layer. Server owners do not directly configure anything here — server owners configure "trigger → action" inside QI, and QI internally reaches the contract described on this page. If you are writing a new module that you want to plug into the Qinhuai ecosystem (for example a new skill engine), this page describes the interface you need to implement.


1. ActionSystem Overview

The action package in com.qinhuai.corelib provides a standalone action execution framework. It has two usage modes:

  • Direct action chain: use ActionPipeline to string several Actions together and run them in order.
  • DSL program: write actions as a node tree ActionDslProgram / ActionNode, then compile / validate / optimize them before execute; during execution the trace is written into DebugTraceRegistry.

1.1 The Action Interface and ActionContext

Each action implements the Action interface:

kotlin
interface Action {
    val id: String
    fun execute(context: ActionContext)
}

The context ActionContext passed in during execution:

MemberDescription
player (nullable)The player that triggered the action; may be null (e.g. command / API triggers)
variables: MutableMapThe variable bag; actions pass values to one another through it
traceId (defaults to UUID)The trace id for this execution
debugWhether debug tracing is enabled

Methods for reading/writing variables and tracing:

kotlin
context.setVar("damage", 12.5)
val dmg: Double? = context.getVar("damage")
val name: String  = context.getVarString("targetName")   // get a string
val count: Int    = context.getVarInt("count")           // get an integer
context.traceBuilder()    // get the trace builder, record what happened in this step

1.2 Implementing a Custom Action

kotlin
class HealAction : Action {
    override val id = "heal"

    override fun execute(context: ActionContext) {
        val player = context.player ?: return
        val amount = context.getVar<Double>("amount") ?: 4.0
        player.health = (player.health + amount).coerceAtMost(player.maxHealth)
        context.traceBuilder()   // record the trace
    }
}

1.3 ActionRegistry — Register and Look Up

kotlin
ActionRegistry.register(HealAction())          // register
val action = ActionRegistry.get("HEAL")        // get(id) is case-insensitive
val every  = ActionRegistry.all()              // get all
// parse an action string into an action
val parsed = ActionRegistry.parseActionString("heal{amount=6}")

get(id) is case-insensitive, so "heal", "HEAL", and "Heal" all resolve to the same one.

1.4 Direct Action Chain: ActionPipeline

kotlin
val pipeline = ActionPipeline()
pipeline.addAction(HealAction())
pipeline.addAction(SomeOtherAction())

val ctx = ActionContext(player = somePlayer, variables = mutableMapOf("amount" to 6.0))
pipeline.execute(ctx)

1.5 DSL Program: ActionDslProgram / ActionNode

Write actions as a node tree, compile and validate first, then execute; the execution process writes into DebugTraceRegistry so you can inspect each step afterwards:

kotlin
val program = ActionDslProgram(
    id = "fireball_cast",
    nodes = listOf(
        ActionNode(/* ... */),
        ActionNode(/* ... */),
    )
)

program.compile()     // compile
program.validate()    // validate
program.optimize()    // optimize
program.execute(ctx)  // execute — trace written into DebugTraceRegistry

2. The QinhActionHandler Contract (the QI → QS seam)

ActionSystem is QCL's own action framework; but when QI (items) fires a trigger, it needs to hand "which skill to execute" over to QS (the skill engine) or another module to carry out. This cross-module seam is QinhActionHandler.

2.1 The QinhActionHandler Interface

kotlin
interface QinhActionHandler {
    val handlerId: String
    fun isAvailable(): Boolean = true                          // available by default
    fun dispatch(context: QinhActionContext): ActionDispatchResult
}
  • handlerId — the handler's unique id (for example, the skill bridge uses "qinhskills:cast").
  • isAvailable() — whether the handler is currently available (returns false when the plugin it depends on is not installed); defaults to true.
  • dispatch(...) — actually handles one action dispatch and returns a three-state result.

2.2 QinhActionContext — The Dispatch Context

QI packages the trigger information into a QinhActionContext and hands it to the handler:

FieldDescription
triggerThe trigger identifier
playerThe triggering player
itemThe item used to trigger
itemIdThe item id
handlerIdThe target handler id
payloadOpaque payload — QI passes it through verbatim; the handler interprets it itself
compileEpoch (nullable)Compile epoch, used for invalidation checks
providerSnapshot (nullable)Provider snapshot
triggerType (nullable)The unified trigger type (see the TriggerType section below)
rawContext (nullable)The raw context (RawSkillContext, for audit tracing only)

🔑 The payload is opaque: QI does not interpret its contents and passes it to the handler verbatim; the exact format is agreed upon between QI and the handler, and the handler is responsible for parsing it.

2.3 ActionDispatchResult — The Three-State Result

kotlin
enum class ActionDispatchResult {
    HANDLED,              // handled
    NOT_HANDLED,         // not handled (fall through to the next handler or to a fallback)
    HANDLER_UNAVAILABLE  // the handler is currently unavailable
}

2.4 Implementing a Custom Handler

kotlin
class MyCustomHandler : QinhActionHandler {
    override val handlerId = "mymodule:do"

    override fun isAvailable(): Boolean = MyModule.isLoaded()

    override fun dispatch(context: QinhActionContext): ActionDispatchResult {
        if (!isAvailable()) return ActionDispatchResult.HANDLER_UNAVAILABLE

        val player = context.player ?: return ActionDispatchResult.NOT_HANDLED
        // the payload is interpreted by this handler itself
        val spec = context.payload as? Map<*, *> ?: return ActionDispatchResult.NOT_HANDLED

        // ... run your logic ...
        return ActionDispatchResult.HANDLED
    }
}

3. TriggerType — The Unified Trigger Type Enum

The entire Qinhuai ecosystem uses one set of trigger types, avoiding each module writing its own strings.

Enum valueMeaning
RIGHT_CLICKRight-click
LEFT_CLICKLeft-click
SHIFT_RIGHT_CLICKSneak + right-click
SHIFT_LEFT_CLICKSneak + left-click
SHIFT_TOGGLESneak toggle (press/release)
DOUBLE_SHIFT_TOGGLEDouble sneak toggle
DOUBLE_RIGHT_CLICKDouble right-click
DOUBLE_LEFT_CLICKDouble left-click
HOLD_RIGHT_CLICKHold right-click
HOLD_LEFT_CLICKHold left-click
CI_TESTCI test trigger
COMMANDCommand trigger
APIAPI trigger
PASSIVEPassive trigger
UNKNOWNUnknown

3.1 Converting To/From Legacy Strings

kotlin
val key: String = TriggerType.RIGHT_CLICK.legacyActionKey()   // → "right_click"
val type = TriggerType.fromLegacy("right_click")              // companion method, string → enum
  • legacyActionKey() — converts the enum into the legacy string key (e.g. right_click), for compatibility with old configs.
  • fromLegacy(string) — companion object method, parses a legacy string back into an enum.

4. QiTriggerMapper — Mapping Bukkit Events to TriggerType

QiTriggerMapper is responsible for translating native Bukkit events into the unified TriggerType:

kotlin
// Map from a PlayerInteractEvent (main hand only; air/block for right/left-click, mapped to SHIFT_* when sneaking)
val type: TriggerType? = QiTriggerMapper.fromInteract(interactEvent)

// Map from a sneak toggle event
val sneakType = QiTriggerMapper.fromSneakToggle(toggleEvent)   // → SHIFT_TOGGLE

// Get the legacy string key
val legacy: String = QiTriggerMapper.legacyKey(type)

fromInteract only handles the main hand; both air and block for right-click / left-click are mapped, and when the player is sneaking it maps to SHIFT_*; if nothing matches it returns null.


5. QISkillUseEvent — The Cross-Module Event Bus

QI and QS share a single event QISkillUseEvent; it is a PlayerEvent and is Cancellable, and it is the only event bus between the two modules.

5.1 Fields and Flags

Constructor: QISkillUseEvent(player, payload, trigger, item?, itemId?, triggerType, rawContext?)

FlagDescription
skillHandledWhether the skill has been handled (a handler sets this to declare "I took it")
castResult (nullable)The cast result
castAttemptedWhether a cast was attempted
fallbackInvokedWhether a fallback was taken
mythicInvokedWhether MythicMobs was invoked
primaryPipelineWhether the primary pipeline handled it
cancelledWhether it was cancelled (Cancellable)

There is also a static HANDLER_LIST (the handler list required by Bukkit events).

5.2 Listener Example: Read the payload, Set skillHandled

A typical listener on the skill engine side — after receiving the event, interpret the payload, and once done mark skillHandled as true:

kotlin
class SkillEngineListener : Listener {

    @EventHandler
    fun onSkillUse(event: QISkillUseEvent) {
        val player = event.player
        val payload = event.payload          // opaque payload, interpreted by this engine itself
        val trigger = event.triggerType

        val skill = resolveSkill(payload) ?: return   // don't take it if no skill can be parsed
        val result = cast(player, skill)

        event.castAttempted = true
        if (result.success) {
            event.skillHandled = true        // declare: I handled this one
            event.castResult = result
        }
        // if needed you can also set event.isCancelled = true
    }
}

5.3 RawSkillContext — Read-Only Audit Context

Constructor: RawSkillContext(itemId?, item?, sneak, source = "qi")

RawSkillContext is the read-only context that QI passes through to QS, for audit tracing only.

⚠️ Do not branch business logic on it: rawContext is only there to "record where this came from"; do not use its fields to decide how the skill should proceed — use payload and triggerType for business decisions.


6. QISkillBridge — The Default Skill Bridge Object

QISkillBridge is an object (singleton) that wraps the event bus above into a standard handler, with HANDLER_ID = "qinhskills:cast".

kotlin
object QISkillBridge {
    const val HANDLER_ID = "qinhskills:cast"
    fun peekCurrentDispatch(): /* current dispatch snapshot */
    fun dispatchViaEvent(context: QinhActionContext): ActionDispatchResult
    fun clearDispatch()
}

6.1 The dispatchViaEvent Flow

dispatchViaEvent(context) is the core method, with the following flow:

  1. Build a QISkillUseEvent from the passed-in QinhActionContext;
  2. Fire that event through the PluginManager (so all listeners, such as the skill engine in §5.2, get a chance to handle it);
  3. Return based on the result: depending on the event's skillHandled / cancelled flags, return HANDLED or NOT_HANDLED.
kotlin
val result = QISkillBridge.dispatchViaEvent(qinhActionContext)
when (result) {
    ActionDispatchResult.HANDLED       -> { /* an engine caught it */ }
    ActionDispatchResult.NOT_HANDLED   -> { /* no one handled it, can fall back */ }
    ActionDispatchResult.HANDLER_UNAVAILABLE -> { /* unavailable */ }
}

Helper methods: peekCurrentDispatch() views the current dispatch snapshot, and clearDispatch() clears the dispatch state.


7. The Whole Chain in Brief

Player interaction (Bukkit event)
   └─ QiTriggerMapper.fromInteract → TriggerType
        └─ QI packages QinhActionContext (trigger / payload / triggerType / rawContext)
             └─ QinhActionHandler.dispatch(...)   ← the contract each module implements
                  └─ (default implementation) QISkillBridge.dispatchViaEvent
                       └─ builds and fires QISkillUseEvent (shared bus)
                            └─ each engine listens → sets skillHandled
                                 └─ returns HANDLED / NOT_HANDLED / HANDLER_UNAVAILABLE

📖 Continue Reading