Repository navigation
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.
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.
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};thenon a stage or on an objective names the next stage,COMPLETEorFAIL. A graph with no path toCOMPLETEfails. -
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).costsare paid per counted match, after theObjectiveProgressPreEventlet it count (a cancelled match costs nothing, a match whose cost cannot be paid does not count), and byQuestEngine.progressalike; an objective already done charges nothing. Every effect (on-match,on-start,on-complete,on-fail,on-abandon, a stage'son-complete) and every reward rule runs once the engine let go of the owner's lock: astart-questthere 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 itstartis refused; at its end an active instance pays itson-window-endrules 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 whatquest listand the journal obey:hiddenlists once started (not LOCKED, AVAILABLE nor OFFERED),secretonce no longer LOCKED. -
hytale-native- see Hytale.
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:capturecancelsNative() 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).
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.
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.
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.
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).
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 takenor 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.
@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).
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: { markers: true, npc-actions: true, spawns: true, asset: "FinalQuests:Mine_Iron" } # all off by defaultHyObjectivesBridge (: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 ofassetstarted for a player (an NPC role'sActionStartObjective) 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.
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/**/*.ymlis the engine's grammar as is, read withQuestDefinitionsinto oneQuestEngine.of(plugin, storage, section); a file that does not read keeps the version loaded before, and a file changed under the sameversionis 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. -
/fqmounts this page'sQuestCommandNodeat its root, then addsvalidate,types <kind>(completed fromTypeRegistries),reload,journal(aScreens.overrideofQUEST_JOURNALfor its own quests) andexport. -
Drafts.
quests/drafts/<name>.ymlis a quest plus adraftblock (author, created, based-on-version, layout)./fq test <draft>registers it asfinalquests:draft-<name>-meta.visibility: secret, gated byfinalquests.authorfor a player quest - and starts it for the author;/fq publish <draft> [--reset]validates the whole quest (codec,thentargets, quests waiting on each other throughrequires:or aquest-statusgate), writesversion = max(published, based-on-version) + 1and reloads it underon-version-change(keep-by-id, orreset). -
In game.
/fq createand/fq edit <draft>are fourScreens whose objective and effect palettes areObjectiveTypes.registry()/Effects.registry()filtered bygetUnsupported, each requiredParamSpecasked through aPromptSpec- 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'scom.sun.net.httpserver(bound to127.0.0.1by default, no TLS of its own): the stages as nodes andthenas arrows, a form perParamSpec, 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 isSecurebehind anhttps://public-url./fq editor closeends your own sessions;/fq editor close <author>|all(finalquests.admin) ends anyone's, and an author online withoutfinalquests.authoris 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.
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"));- 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.
-
getStatusreads a failed, abandoned or expired instance as AVAILABLE while the quest still takes new ones (the raw status andgetFailCause()stay on theQuestState). A stage closes once: when a listener cancels theQuestCompletePreEvent, the sweep retries only the completion, never the stage'son-completeagain. - 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:
QuestIndexentries,QuestStorage,QuestStatesetters (Jackson) - read them, do not drive the engine through them.
EverNifeCore · Home · made by Petrus Pradella
Getting Started
Commands & Text
Player Data & Storage
- PlayerData & PDSections
- Accounts
- Storage Backends
- Inline Backends for Plugins
- Block Data (SVWorldDataManager)
- Legacy Data Migration
- Cooldowns
Config & Minecraft Systems
- Configuration
- Logging & Debug Modules
- Events
- Regions
- Groups
- Notifications
- NPCs
- Logic
- Quests
- Provenance
- Scheduler & Threading
- Items & NBT
- Screens
- Dialogues
- Menus
- Integrations
- Economy
- Boosters
- Deliveries
- Audit Log
- Dynamic Values
- Player Ignore
- Version Compatibility
Architecture & Reference