ellipog
FTB Quests → TenetHave an FTB quest book? Convert it with one button

KubeJS scripting

Minecraft 1.21.1Fabric + NeoForgeTenet 0.2.0

A questline can be driven from a KubeJS script: stages, quest state, completions, custom taskTaskOne thing a quest asks for — items, a kill count, a dimension reached. A quest has several; all of them must be done unless minRequired says otherwise.See also Reward and rewardRewardWhat a quest gives on completion: items, experience, a command, or a loot crate. Separate from tasks, and evaluated only once.See also Task handlers, and the six lifecycle events. The integration is NeoForge only, and that is KubeJS's doing rather than a choice — KubeJS 7, the 1.21 line, publishes no Fabric artifact. The operations themselves live in TenetScripts, which names no KubeJS type, so the Fabric build carries the same Java API for addons; only the script binding is NeoForge's.

KubeJS is optional. Nothing requires it to build, to launch, or to play, and the mod does nothing script-shaped when it is absent.

The Tenet binding#

A server script sees one object, Tenet. Every method is one call over the same service the commands use, so a script cannot do what an operator could not.

MethodAnswers
Tenet.hasStage(player, 'my_pack:inducted')whether this player has the stage
Tenet.addStage(player, 'my_pack:inducted')whether anything changed — false when they already had it
Tenet.removeStage(player, 'my_pack:inducted')whether it was there to take
Tenet.stages(player)every stage they have, as strings, sorted
Tenet.hasTeamStage(player, 'my_pack:inducted')whether this player's team holds the stage
Tenet.addTeamStage(player, 'my_pack:inducted')whether anything changed — granted to the team, told to every online member
Tenet.removeTeamStage(player, 'my_pack:inducted')whether it was there to take
Tenet.teamStages(player)every stage the team holds, as strings, sorted
Tenet.state(player, 'quest_id')LOCKED, UNLOCKED, STARTED, COMPLETED, or UNKNOWN for an id that resolves to no quest
Tenet.complete(player, 'quest_id')finishes it exactly as /tenet complete does — the same service call, so a script cannot complete what the command would refuse
Tenet.resolve(player, 'quest_id')the quest a string names, or null — for a script deciding whether to bother
Tenet.customTaskIds()the ids this build has a custom-task handler for
Tenet.customRewardIds()the same for custom rewards
Tenet.registerTask(id, handler)registers what a tenet:custom task measures
Tenet.registerReward(id, handler)registers what a tenet:custom reward does

There are no permission checks on these, deliberately: the person who wrote the script owns the questline, and a permission check would be asking them for their own permission. A stage id that does not parse, or a player who is null, gets a plain false or UNKNOWN rather than an error thrown into the script.

Tenet.state answers from stored progress — the team's — so a quest gated on a stage this player lacks still reads UNLOCKED there: the gate is a per-player overlay applied when that player's view is sent. Ask Tenet.hasStage for the fact the gate reads.

Events#

TenetEvents is a server event group with one entry per lifecycle moment. Every listener is a function, and every event object is a description of what happened — public fields, not getters.

EventFields
TenetEvents.questStartedplayer, quest, title
TenetEvents.questCompletedplayer, quest, title
TenetEvents.taskCompletedplayer, quest, taskIndex
TenetEvents.rewardClaimedplayer, quest, type
TenetEvents.stageAddedplayer, stage
TenetEvents.stageRemovedplayer, stage
TenetEvents.clickEventplayer, id, chapter, element

A worked script#

// kubejs/server_scripts/tenet.js

// Finishing a quest grants a stage. This is how a pack writes "finishing this opens that chapter"
// when the thing linking the two is not a dependency edge -- a stage gate is per player, and a
// dependency is not.
TenetEvents.questCompleted(event => {
  if (event.quest === 'the_calling') {
    Tenet.addStage(event.player, 'my_pack:inducted')
  }
})

// Stages have their own events, so a script can watch the flag rather than the quest that set it.
TenetEvents.stageAdded(event => {
  console.info('[tenet] stage granted: ' + event.stage)
})

// A canvas press whose click is a script event: the picture names an id, and the script sorts
// presses by it. The chapter and the element travel because a press is a place as well as an
// event -- two pictures firing one id are still two pictures:
//   { "type": "image", "id": "sounder", "click": { "type": "custom_event", "data": "my_pack:sounded" } }
TenetEvents.clickEvent(event => {
  if (event.id === 'my_pack:sounded') {
    console.info('[tenet] ' + event.player.name.string + ' sounded ' + event.element)
  }
})

// A custom task: the handler answers how far along the player is, counted the way the task's own
// `value` counts. The quest names the id:
//   { "type": "tenet:custom", "id": "my_pack:inducted_check", "value": 1 }
//
// The context is a Java record, so its fields are method calls -- `context.player()` and not
// `context.player`. Without the parentheses the handler receives the method itself, and the
// mistake only shows when the task is evaluated. A handler that throws reads as zero and warns
// once, rather than crashing the tick that polled it.
Tenet.registerTask('my_pack:inducted_check', (task, context) => {
  return Tenet.hasStage(context.player(), 'my_pack:inducted') ? 1 : 0
})

// A custom reward: the handler does whatever the pack needs when the reward is collected.
Tenet.registerReward('my_pack:announce', (player, context) => {
  console.info('[tenet] reward collected on ' + context.questId)
})

Register handlers at the top level of a server script, not inside a listener: the registry is consulted when a quest file is read, and handlers are forgotten on a server reload — so a reload replaces them rather than stacking them, and a deleted script's handler stops existing. A client script reload does not clear them, only a server one does.

What has been verified, and what has not#

The binding was run, not assumed. A :neoforge:runServer with KubeJS on the run classpath and a script at neoforge/run/kubejs/server_scripts/tenet_verify.js produces, in the server log: KubeJS's own Found plugin source tenet, then the script's lines — Tenet is an object, its methods are callable, Tenet.state(null, "nothing") answers UNKNOWN, a TenetEvents.stageAdded listener attaches, and a JS arrow function passed to registerTask/registerReward converts into a Java handler and appears in customTaskIds()/customRewardIds(). The load reports 0 errors and 0 warnings.

What that run cannot prove, said plainly: no player exists at server start, so a handler's body running against a real quest is the pack's own test, and the client-side look of a stage-gated quest is on the manual playtest list in TESTING.md.

Support the workko-fi.com/ellipog