Skip to content
Pétrus Pradella edited this page Nov 2, 2026 · 8 revisions

Quests

br.com.finalcraft.evernifecore.quest is a quest engine, not a quest plugin: the core ships no verb that creates a quest. A plugin (FinalQuests, a daily-missions plugin, a minigame) opens its own QuestEngine, registers QuestDefinitions read from its own YAML or built in code, and the core does the rest - it counts actions, keeps the progress in the plugin's own storage, pays rewards, draws pools, and serves commands, placeholders, screens and waypoints the plugin chooses to mount.

Everything here lives in :common (platform-agnostic) except the optional Hytale bridge in :hytale.

Opening an engine

public final class MyQuestSection extends QuestSection {}   // a class of your own: sections are registered by class

QuestEngine engine = QuestEngine.of(pluginData, storage, MyQuestSection.class);
engine.registerAll(definitions);
Factory Player progress Other owners (group, global, region, world)
QuestEngine.of(owner, storage, MyQuestSection.class) the plugin's MyQuestSection (a PDSection, section id quests, loaded at login) storage, entity quest_states
QuestEngine.of(owner, storage) refused by start storage
QuestEngine.of(owner) memory memory - gone at restart (arenas, minigames)

QuestEngine.of(owner, storage, MyQuestSection.class, (engine, section) -> ...) hands the section's PDSectionConfiguration.Builder<S> to the plugin (a legacy file layout, a migration, a collection) before its one registration; it runs only when that call registers the section, and the lifecycle stays ONLINE whatever it sets.

Later calls with the same arguments return the same engine; QuestEngine.ofIfPresent(owner) never opens one. The core closes the engine with the plugin: IECPluginBootstrap.onECPluginShutdownPre calls QuestEngine.closeOwnedBy(owner) (writes what is dirty), and ECPluginManager.removePluginData calls discardOwnedBy (writes nothing).

A player action only counts while the player's section is loaded (online). The state of a quest the engine does not know any more - removed from the config, or its plugin's objective type not loaded - stays in the section untouched (an orphan) until a reset.

A definition

id: mine-iron                       # the full id is <plugin>:mine-iron
version: 1
name: "&6Iron miner"
meta: { category: mining, icon: "minecraft:iron_ore", order: 10 }
repeat: { cooldown: 20h }           # RepeatPolicy: {limit, cooldown, window}
requires: [ tutorial ]              # a cycle fails at registration, naming the chain
gate: { require: [ { type: permission, node: finalquests.mine } ], cost: [ { type: money, amount: 50 } ] }
stages:
  - id: mine
    objectives:
      - { id: ore, type: break-block, block: "minecraft:iron_ore", goal: 32,
          conditions: [ { type: compare, variable: gamemode, op: "!=", amount: CREATIVE } ] }
  - id: deliver
    objectives:
      - { id: smith, type: interact-npc, npc: "citizens:12", costs: [ { type: item, item: "minecraft:raw_iron", amount: 32 } ] }
    on-complete: [ { type: message, text: "&aDelivered!" } ]
rewards:
  - effects: [ { type: money, amount: 500 } ]

QuestDefinitions.parseYaml(owner, yaml) / parse(owner, JsonNode, zone) read it; every type: is a key of a TypeRegistry (objectives, conditions, costs, effects, reward pieces), and a definition that does not read throws an IllegalArgumentException naming the quest, the key (with its list index: fail-when[1], rewards[0], on-start[2]), for an objective its stage too, and the fix (Quest 'x', stage 'dig', objective 'iron' key 'mode': 'bogus' is not a progress mode - write count or each-match.). The top-level keys are QuestDefinitions.KEYS:

  • stages are graph nodes: a stage's objectives are a list (all) or {mode: all|any|at-least, count, items}; then on a stage or on an objective names the next stage, COMPLETE or FAIL. A graph with no path to COMPLETE fails.
  • objectives - goal, mode (each-match), conditions, costs, then, on-match, hidden, description, platforms, provenance ({natural, spawner, own-drop}: each key written is a test on the origin the Provenance tracker recorded). costs are paid per counted match, after the ObjectiveProgressPreEvent let it count (a cancelled match costs nothing, a match whose cost cannot be paid does not count), and by QuestEngine.progress alike; an objective already done charges nothing. Every effect (on-match, on-start, on-complete, on-fail, on-abandon, a stage's on-complete) and every reward rule runs once the engine let go of the owner's lock: a start-quest there takes another owner's lock without two owners waiting for each other.
  • params freeze per instance ({mob: [zombie, skeleton]}, random: true); {mob} in any objective text is replaced.
  • version + on-version-change: keep-by-id | reset: a higher version keeps the progress of objectives whose id still exists, or sends open instances back to available - applied when the definition is registered, when the states are loaded and when a player logs in, so an instance read from storage after a restart moves too.
  • fail-when (conditions), time-limit, expires-at, window: {from, to, zone} (outside it start is refused; at its end an active instance pays its on-window-end rules and expires), abandonable, on-start|complete|fail|abandon.
  • owner: player | group | global | region | world ({type: group, provider: <kind>} for one group kind).
  • meta.visibility: visible | hidden | secret - QuestMeta.Visibility.lists(status) is what quest list and the journal obey: hidden lists once started (not LOCKED, AVAILABLE nor OFFERED), secret once no longer LOCKED.
  • hytale-native - see Hytale.

Objective types

One stock event type per action source the platform raises (Logic, action.ActionEvents): break-block, place-block, kill, damage, pickup (a stack counts its amount), drop, craft, smelt, enchant, fish, tame, breed, shear, consume, command, xp, interact, interact-npc, spawn, respawn, vehicle, explore, explosion, fluid, fire-spread, flight-toggle (interact-npc counts the right click; click: left counts the punch instead, { id: punch, type: interact-npc, npc: "citizens:12", click: left }); plus state (any Condition, checked again after the sources its getNeeds() names, else by the 60 s sweep), timer, quest-completed, and the region ones below. An objective on a source this platform never raises fails at registration, naming the objective, the source and the platform.

A plugin adds its own type (ObjectiveType<C> SPI, usually ActionObjectiveType over its own EventSource):

EventSource capture = EventSource.register(pluginData, "pokemon-capture");
ObjectiveTypes.register(pluginData, new CaptureObjectiveType(capture));   // key pokequest:capture

cancelsNative() true cancels the action's Pre when the match completes the objective. targetOf(filter) gives the objective a place (the tracked quest's waypoint, the Hytale marker).

Region objectives and conditions

enter-region, leave-region, stay-in-region (measure: continuous | accumulated) and reach-location (a TRANSIENT region tagged evernifecore:quest-target, made when the quest registers and removed when it unregisters) count on the region system's crossings. stay-in-region closes at the moment the duration ends - every crossing that leaves the owner inside schedules one check for then, and the 60 s sweep is only the safety net. On a group's quest presence: all | any (default all) says when the group is inside: every online member in the region, or at least one; no member online is outside. Any member's crossing measures the group again; continuous counts from when the presence last started to hold (with any, the longest stay of a member inside), accumulated adds up the stretches it held. A player's quest ignores presence. Conditions in-region (for-at-least), region-flag, region-member and the variable region-time are in logic.core - see Regions.

The index

QuestIndex.get() is one per process: it subscribes to an action source's Post only while some active objective of any engine needs it, and offers each action only to the owners it counts for (the player; their groups and the regions they stand in only when a quest of that owner kind waits on the source). /ecore quest <player> lists the sources it listens to.

Owners and rewards

A group quest is started (or offered) by a member and belongs to the group (QuestEngine.getOwnerFor(player, quest)): every member counts, and the engine keeps each member's contribution. A dissolved group or a removed region fails its quests with FailCause.OWNER_GONE.

The gate and costs of a group or global quest are checked and paid with the member who starts it (a permission or item gate reads that member); a money cost still takes from the owner's wallet. QuestEngine.getStatus(owner, id, viewer) checks the gate with viewer - quest list and the journal pass the player who looks - and getStatus(owner, id) uses the owner's own player (none for a group or global owner). Gates that reach each other through quest-status answer LOCKED where the loop closes, and the chain is logged once.

When a start is refused

QuestEngine.start / offer answer a StartResult: getOutcome() (STARTED, OFFERED, DENIED, REFUSED), getWhy() (the text), getGate() (the failures when the gate denied) and getReason(), which check said no:

StartResult.Reason When
UNKNOWN_QUEST no quest is registered under the id
NO_GROUP a group quest asked for a player in no group of its kind (StartResult.noGroup(definition, requested))
NOT_LOADED the owner's quest states are not loaded (offline, or an engine without player quests)
STATUS already active, done or cooling down (an offer also refuses one already offered); a failed, abandoned or expired quest begins again
REQUIRES a quest of requires is not completed
WINDOW its window has not opened yet, or has closed, or past expires-at (start and offer)
OWNER_KIND asked for an owner of another kind than the quest's owner
GATE the gate's conditions failed or its costs could not be paid (getGate())
CANCELLED a listener cancelled the pre event

QuestPresenter.refusal(player, definition, result) returns the text the quest command node and the offer screen send for a refusal, or null to keep the core's.

rewards: is a list of logic.reward RewardRules (eligibility, distribution, absent: deliver | forfeit, moment: on-complete | on-window-end | on-owner-gone), paid by Rewards.pay with the contributions and the last actor the engine measured - see Groups - Rewards. The quest adds one piece of its own: completed-objective (only who counted on that objective).

Pools

QuestPool daily = engine.pool("daily", pool -> pool.fromTag("daily").draw(2).window(Window.daily(LocalTime.of(4, 0), zone))
        .noRepeatLast(3).reroll(Gate.parse(pluginData, rerollGate, zone), Duration.ofHours(24))
        .rerollsPerWindow(1).expireActiveOnTurn(true));
List<QuestId> today = daily.current(ProgressOwner.player(uuid));
RerollResult again = daily.reroll(player);       // pays the gate, redraws only what was not taken

or YAML QuestDefinitions.parsePools(engine, node, zone): {daily-mission: {from-tag: daily, draw: 1, window: 'daily at 04:00 zone UTC', no-repeat-last: 3, reroll: {cooldown: 24h, per-window: 1}, expire-active-on-turn: true}}. noRepeatLast(K) keeps the last K drawn ids out; weights come from meta.extra.weight. reroll.per-window (rerollsPerWindow, 0 = no limit) caps an owner's rerolls per window, counted by PoolDraw.getRerolls() and given back by a new draw. expire-active-on-turn (expireActiveOnTurn) expires the previous draw's quests the owner still has active when the window turns, as a closed window does (QuestExpireEvent, on-window-end rules); by default they stay active.

Commands, placeholders, screens

@FinalCMD.Node(subcmd = "quest", permission = "finalquests.quest")
private final QuestCommandNode quests = new QuestCommandNode(engine, "finalquests.quest");

Verbs list [status] [player], info <quest>, start <quest> [player] [k=v...], offer, abandon, admin reset, complete, progress <quest> <objective> <delta> [player] and track <quest|off>; with a permissionBase each verb also needs <base>.<verb>. <quest> is read by ArgParserQuest (a short name in the node's engine, or a full id). /ecore quest <player> (evernifecore.command.quest) reads every engine.

QuestPlaceholders.register(replacer, QuestPlaceholderScope.ownedBy(plugin)) serves, under the plugin's prefix, quest_[quest]_status|stage|percent|times|cooldown|time_left|name|description|progress_[obj]|goal_[obj]|param_[k], tracked_<op>, quests_active_count, quests_completed_count and pool_[pool]_<n>|count|reroll_in; the core serves the same over every engine by full id as %evernifecore_quest_[<plugin>:<quest>]_status%. Unknown quests answer "".

Default screens: ScreenKey.QUEST_JOURNAL (QuestJournalScreen, filter by status, a click tracks) and ScreenKey.QUEST_OFFER (QuestOfferScreen: accept starts, the accept button lists what the gate still misses); restyle them in guis/QuestJournalLayout.yml / guis/QuestOfferLayout.yml or override the key (Screens).

Presenter and waypoints

QuestPresenter is how an engine shows quest moments; NotifierQuestPresenter is the default (progress on the PROGRESS chain, completion as COMPLETE with a sound, the tracked quest as TRACKED with the stage's progress) and points a Waypoint at each target of the tracked quest. engine.setPresenter(...) swaps it. A presenter runs on the thread that moved the quest, inside the owner's lock: no I/O.

A subscriber of a quest event (QuestStartPreEvent, QuestCompleteEvent, ...) runs there too. It may call the engine back for the same owner; for another owner it leaves the thread first (ECScheduler.runAsync(() -> engine.start(...))). Asked for another owner inside a held lock, the engine throws an IllegalStateException that says so, where two threads nesting two owners in opposite orders would wait for each other forever.

Hytale native objectives

hytale-native: { markers: true, npc-actions: true, spawns: true, asset: "FinalQuests:Mine_Iron" }   # all off by default

HyObjectivesBridge (:hytale, registered by the core while it sets up) bridges such a quest to the server's Hytale:Objectives. The native plugin raises no event, so one System scans each world once a second:

  • markers - each active objective with a place gets a native objective started from an asset made at runtime (one per block), whose only task carries the map marker; it is cancelled when our objective, stage or quest ends. The asset is loaded off the world thread (loading waits on the worlds) and started on the next scan.
  • npc-actions - a native objective of asset started for a player (an NPC role's ActionStartObjective) starts the quest; each native task set it closes completes the quest objective of the same position.
  • spawns - when the quest ends without completing, that native objective is cancelled, which takes back what its spawn beacon brought.

A quest with a switch on and an objective the native side cannot express is refused on registration: objective 'talk': hytale-native cannot express the type 'chat' ... it maps: break-block, place-block, kill, pickup, craft, interact, reach-location. The hook is QuestEngine.addDefinitionCheck, open to any platform.

FinalQuests, the authoring product

The engine reads no file and has no authoring verb; FinalQuests is the product that does, the quest counterpart of FinalProtection over regions. Everything it adds sits on the APIs above - nothing in the core knows it:

  • Files. quests/published/**/*.yml is the engine's grammar as is, read with QuestDefinitions into one QuestEngine.of(plugin, storage, section); a file that does not read keeps the version loaded before, and a file changed under the same version is refused (the version is what moves open instances). A quest owned by a group kind nobody registered waits, unregistered, until that kind registers (GroupKindRegisteredEvent), before any group of it exists.
  • /fq mounts this page's QuestCommandNode at its root, then adds validate, types <kind> (completed from TypeRegistries), reload, journal (a Screens.override of QUEST_JOURNAL for its own quests) and export.
  • Drafts. quests/drafts/<name>.yml is a quest plus a draft block (author, created, based-on-version, layout). /fq test <draft> registers it as finalquests:draft-<name> - meta.visibility: secret, gated by finalquests.author for a player quest - and starts it for the author; /fq publish <draft> [--reset] validates the whole quest (codec, then targets, quests waiting on each other through requires: or a quest-status gate), writes version = max(published, based-on-version) + 1 and reloads it under on-version-change (keep-by-id, or reset).
  • In game. /fq create and /fq edit <draft> are four Screens whose objective and effect palettes are ObjectiveTypes.registry() / Effects.registry() filtered by getUnsupported, each required ParamSpec asked through a PromptSpec - so a type another plugin registers appears with no change to FinalQuests.
  • On the web. /fq editor open <draft> sends a single-use link to a graph editor served by the JDK's com.sun.net.httpserver (bound to 127.0.0.1 by default, no TLS of its own): the stages as nodes and then as arrows, a form per ParamSpec, save validated with the same text as /fq validate, and the draft's saves, tests and publishes pushed live as server-sent events. The session cookie is Secure behind an https:// public-url. /fq editor close ends your own sessions; /fq editor close <author>|all (finalquests.admin) ends anyone's, and an author online without finalquests.author is refused by the API on the next call and loses the session. A port that does not bind is a warning; every other path keeps working.
  • Party quests are owner: { type: group, provider: <plugin>:party }; the product names the kind only in text, and a test run outside a group of the kind teaches /<kind> create.

Its placeholders are %finalquests_...% (Placeholders) and its texts translate through the overlay (/fq export en_US, Localization). The full manual - grammar, every verb, the editor's routes and how to put it behind a proxy or an SSH tunnel - is the README of the FinalQuests repository.

Testing

QuestWorld (in evernifecore-common-tests, Testing Toolkit) opens a fake plugin's engine on a scripted storage with its section registered, the shared test clock (advance / runDue), player(name), the action factories (breakBlock, pickup, kill, contexts()), regions (region, move), recorded(Class) and a close() that fails if a subscription leaked. QuestDefinitionsFixture loads the sample quests (tutorial, mine-iron, daily-zombies, lab-catch, arena-night, arena-warmup); RecordingQuestPresenter records the presenter calls.

QuestWorld quests = QuestWorld.open("FakeQuests", tempDir);
quests.registerFixtures("mine-iron");
TestFPlayerSender alex = quests.player("alex");
QuestWorld.await(quests.getEngine().start(quests.owner(alex), quests.id("mine-iron"), QuestParams.NONE));
quests.post(quests.breakBlock(alex, "minecraft:iron_ore"));

Threads and contracts

  • Every public engine method is any-thread; each owner's moves run under that owner's lock, shared by every engine (two threads progressing the same owner lose nothing, and an engine reading another engine's quest of the same owner never deadlocks with it). Writes are batched and flushed off the caller's thread.
  • getStatus reads a failed, abandoned or expired instance as AVAILABLE while the quest still takes new ones (the raw status and getFailCause() stay on the QuestState). A stage closes once: when a listener cancels the QuestCompletePreEvent, the sweep retries only the completion, never the stage's on-complete again.
  • One server at a time progresses a non-player owner: two servers moving the same global quest on a network storage is not a supported setup.
  • Public but internal: QuestIndex entries, QuestStorage, QuestState setters (Jackson) - read them, do not drive the engine through them.

Clone this wiki locally