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:
- ActionSystem — QCL's built-in "action" execution framework (action chains, DSL programs, registry, tracing).
- 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 objectQISkillBridge.
🧩 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
ActionPipelineto string severalActions together and run them in order. - DSL program: write actions as a node tree
ActionDslProgram/ActionNode, thencompile/validate/optimizethem beforeexecute; during execution the trace is written intoDebugTraceRegistry.
1.1 The Action Interface and ActionContext
Each action implements the Action interface:
interface Action {
val id: String
fun execute(context: ActionContext)
}The context ActionContext passed in during execution:
| Member | Description |
|---|---|
player (nullable) | The player that triggered the action; may be null (e.g. command / API triggers) |
variables: MutableMap | The variable bag; actions pass values to one another through it |
traceId (defaults to UUID) | The trace id for this execution |
debug | Whether debug tracing is enabled |
Methods for reading/writing variables and tracing:
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 step1.2 Implementing a Custom Action
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
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
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:
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 DebugTraceRegistry2. 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
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 (returnsfalsewhen the plugin it depends on is not installed); defaults totrue.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:
| Field | Description |
|---|---|
trigger | The trigger identifier |
player | The triggering player |
item | The item used to trigger |
itemId | The item id |
handlerId | The target handler id |
payload | Opaque 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
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
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 value | Meaning |
|---|---|
RIGHT_CLICK | Right-click |
LEFT_CLICK | Left-click |
SHIFT_RIGHT_CLICK | Sneak + right-click |
SHIFT_LEFT_CLICK | Sneak + left-click |
SHIFT_TOGGLE | Sneak toggle (press/release) |
DOUBLE_SHIFT_TOGGLE | Double sneak toggle |
DOUBLE_RIGHT_CLICK | Double right-click |
DOUBLE_LEFT_CLICK | Double left-click |
HOLD_RIGHT_CLICK | Hold right-click |
HOLD_LEFT_CLICK | Hold left-click |
CI_TEST | CI test trigger |
COMMAND | Command trigger |
API | API trigger |
PASSIVE | Passive trigger |
UNKNOWN | Unknown |
3.1 Converting To/From Legacy Strings
val key: String = TriggerType.RIGHT_CLICK.legacyActionKey() // → "right_click"
val type = TriggerType.fromLegacy("right_click") // companion method, string → enumlegacyActionKey()— 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:
// 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)
fromInteractonly handles the main hand; both air and block for right-click / left-click are mapped, and when the player is sneaking it maps toSHIFT_*; if nothing matches it returnsnull.
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?)
| Flag | Description |
|---|---|
skillHandled | Whether the skill has been handled (a handler sets this to declare "I took it") |
castResult (nullable) | The cast result |
castAttempted | Whether a cast was attempted |
fallbackInvoked | Whether a fallback was taken |
mythicInvoked | Whether MythicMobs was invoked |
primaryPipeline | Whether the primary pipeline handled it |
cancelled | Whether 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:
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:
rawContextis only there to "record where this came from"; do not use its fields to decide how the skill should proceed — usepayloadandtriggerTypefor 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".
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:
- Build a
QISkillUseEventfrom the passed-inQinhActionContext; - Fire that event through the
PluginManager(so all listeners, such as the skill engine in §5.2, get a chance to handle it); - Return based on the result: depending on the event's
skillHandled/cancelledflags, returnHANDLEDorNOT_HANDLED.
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
- 条件与表达式.md — condition checks / expression evaluation that pair with actions
- 脚本API.md — scripted extensions
- API概览.md — the full picture of QCL's public API
- ../05-参考/术语表.md — definitions of terms such as QI / QS / handler / payload / trigger