Skip to content

Dialogues

Pétrus Pradella edited this page Nov 1, 2026 · 4 revisions

Dialogues

What this page covers: the dialogue engine in br.com.finalcraft.evernifecore.dialogue (:common) - a tree of nodes and gated options, the session one player holds, what starts one (the start-dialogue effect, an NPC, a region, the plugin's own command), the default screen evernifecore:dialogue and how to replace it, the three events, the refusals at load, and DialogueTrees for tests.


The one idea to hold on to

A Dialogue is a definition: nodes, each with the speaker's line and the options the player answers with. Each option carries a Gate (may the player pick it?), effects (what picking it does) and where the talk goes next. The core keeps nothing on disk: the tree lives in the plugin's own file, Dialogues.of(plugin).register(...) keeps it in memory, and a DialogueSession - one per player, in memory too - walks it, showing each node on whatever screen answers ScreenKey.DIALOGUE.

The core has no verb that starts a dialogue. A plugin starts one from its command, an NPC trigger, a region trigger or the start-dialogue effect; the core only reads (/ecore dialogue <player>).


The model

Class What it is
DialogueId owner:name, both lowercased, [a-z0-9_-]{1,64} like a region id; DialogueId.parse("finalcolonies:tomas")
Dialogue the tree: getSpeaker(ctx), getEntryNode() (default start), getNodes() (written order), getTimeout() (default 2 min, zero = forever), getFailWhen(), getTriggers(); texts through getText(node, ctx) / getText(node, option, ctx)
DialogueNode getText, getOptions() (empty = terminal: showing it ends the talk), getRemember(), getEffectsOnEnter()
DialogueOption getText, getGate() (Gate.open() when none), getEffects(), getGoto(), isEnd(), isHidden()
DialogueTrigger npc(NpcRef) or regionEnter(RegionId)

Built in code:

Dialogue tomas = Dialogue.builder(plugin, "tomas").speaker("Tomás").timeout(Duration.ofMinutes(2))
        .node(DialogueNode.builder("start").text("Did you bring the logs?").remember("finalcolonies:tomas-answer")
                .option(DialogueOption.builder("deliver").text("Here.").gate(deliverGate).effects(thanks).goTo("thanks"))
                .option(DialogueOption.builder("later").text("Not yet.").goTo("later")))
        .node(DialogueNode.builder("thanks").text("Come back any time."))          // terminal
        .node(DialogueNode.builder("later").text("Bring them soon.").option(DialogueOption.builder("bye").text("Bye.").end()))
        .build();                                                                  // checks, or throws
Dialogues.of(plugin).register(tomas);

or read from the plugin's YAML with Dialogue.parse(plugin, "tomas", config.getRoot(), zone) - zone is what a window without its own zone is read in - or from a YAML text with Dialogue.parseYaml(plugin, "tomas", yaml), in the server's zone (Window.getServerZone()); text that is not YAML is a DialogueDefinitionException naming the dialogue:

speaker: "Tomás"
entry: start
timeout: 2m
fail-when:
  - { type: near-npc, npc: "citizens:12", radius: 6 }   # walking away ends the talk
triggers:
  - npc: "citizens:12"
nodes:
  start:
    text:
      pt-BR: "Olá! A colônia precisa de madeira. Trouxe as 32 toras?"
      en-US: "Hello! The colony needs wood. Did you bring the 32 logs?"
    remember: "finalcolonies:tomas-answer"
    on-enter: []                                         # effects run each time the node is shown
    options:
      deliver:
        text: { pt-BR: "Sim, aqui estão.", en-US: "Yes, here they are." }
        gate:
          cost:
            - { type: item, item: "minecraft:oak_log", amount: 32 }
        effects:
          - { type: add, variable: "finalcolonies:warehouse-points", amount: 10 }
          - message: "&aObrigado! A colônia agradece."
        goto: thanks
      later:
        text: { pt-BR: "Ainda não.", en-US: "Not yet." }
        goto: later
  thanks:
    text: "Volte sempre."
    options: []                                          # terminal
  later:
    text: "Volte quando tiver as toras."
    options:
      - { id: bye, text: "Até logo.", end: true }        # a list works too, each option with its id

Keys: dialogue speaker, entry, timeout, fail-when, triggers, nodes; node text, remember, on-enter, options; option id (list form), text, gate ({require, cost}, see Logic), effects, goto, end, hidden. A condition, cost or effect with two required parameters is written {type: ..., ...}.

Texts and languages

A text is a string, a list of lines or a map of locale to text. The first entry of a map is the author's language. Registering puts every text in the plugin's LocaleOverlay under the definition dialogue:<name> (paths speaker, nodes.<node>.text, nodes.<node>.options.<option>.text), so /ecore i18n export writes them for a translator and the overlay file of a locale wins over the inline entry. pt-BR and PT_BR are one locale. The text the player reads is the overlay's for the context's locale, its {variables} filled by ExecutionContext.resolve.

What the load refuses

Builder.build() (and so Dialogue.parse) throws a DialogueDefinitionException that names the dialogue, the node, the option and the way out:

Refusal Example message
a goto to a missing node The dialogue finalcolonies:tomas has option 'deliver' on node 'start' with goto 'delivery' but no node is called 'delivery'. Nodes defined: start, thanks, later. Add the node or point the option at one of these.
an entry that is not a node ... starts on the node 'hello' but no node is called 'hello'. Nodes defined: ...
a loop with no way out The dialogue finalcolonies:tomas has a loop with no way out: nodes [haggle, insist] only lead back into themselves. Give one of them an option with 'end: true' or a goto to a node that ends (thanks, later).
remember naming no custom variable, or one that is not a String ... remembers the answer of node 'start' in 'finalcolonies:tomas-answer', but no plugin defined that custom variable. Define it before the dialogue loads: Variables.custom(plugin, storage).define("<name>", String.class, "") ...
a key the reader does not know, a definition that does not parse The dialogue finalcolonies:tomas node 'start' option 'bye' has the key 'ends', which a dialogue does not read. Known here: id, text, gate, effects, goto, end, hidden.

"A loop with no way out" is measured backwards from the nodes that end (no options, or an option with end): a node that cannot reach one of them is refused. A loop that can still reach an end is fine. A fail-when condition this platform cannot run is refused by the type registry while parsing.


The session

DialogueSession session = Dialogues.start(player, tomas, Caller.of(plugin, "command:talk"),
        context -> context.npc(NpcRef.parse("citizens:12")));   // null when a DialogueStartedEvent cancelled it
  • One per player, server-wide. Starting another ends the first with REPLACED. Dialogues.sessionOf(player).
  • choose(optionId) (what a click on the default screen calls): a cancellable DialogueChoiceEvent, then the option's gate paid (Gate.pay), the option id written into the node's remember variable, the effects run, then the talk moves - to the goto, to the end (end), or stays (neither). A cancelled or denied pick charges nothing and leaves the node as it was; the ChoiceResult says CHOSEN, DENIED (with every failure), CANCELLED, UNKNOWN_OPTION or CLOSED.
  • Reaching a terminal node shows its line and ends the session with END; the screen stays until the player closes it.
  • end(cause) is idempotent: the first cause wins and posts one DialogueEndedEvent.
  • newExecutionContext() builds a context for the player under the session's Caller, carrying every DialogueContext attribute (the NPC under DialogueContext.NPC) and the session under DialogueContext.SESSION.
DialogueEndCause When
END an option with end, or a terminal node reached
CLOSED the session's screen went away (the player closed it, or something did)
TIMEOUT no answer within getTimeout(), armed on ECScheduler at the start and re-armed by every pick
FAIL_WHEN a fail-when condition failed when one of its getNeeds() sources fired for this player; a condition with no source is asked at each pick instead (no sampling timer)
PLAYER_LEFT the player quit
REPLACED another dialogue started for the player
OWNER_DISABLED the owning plugin shut down (Dialogues.closeOwnedBy, called by the core before its screens close) or registered a dialogue of the same name again
API a plugin ended it: session.end(...), the end-dialogue effect

A reboot ends everything: sessions are engine state, never saved. The core listens to quits and screen closes only while a session is open.


What starts a dialogue

Entry Who How
effect start-dialogue any gate, quest, menu, flag {type: start-dialogue, dialogue: owner:name, node: <optional>}; the context's caller becomes the session's origin, its npc attribute the session's NPC. end-dialogue ends the player's session (and does nothing without one). The dialogue is looked up when the effect runs, so its owner may load later
NPC a trigger npc: <provider:id> a player using that NPC with the right click (InteractNpcActionEvent) starts it - a left click (a punch) opens nothing - origin npc:<ref>, the NPC in the context. The core hears NPC uses only while a registered dialogue has an npc: trigger - that is what keeps the interact-npc source needed
region a trigger region-enter: <owner:name> a RegionCrossedEvent entering that region starts it, origin region-enter:<id>; holds MOVE while such a trigger exists
command the plugin @Arg("<dialogue>") Dialogue dialogue (ArgParserDialogue: owner:name, or a bare name exactly one plugin has) then Dialogues.start(...)

A trigger never interrupts a dialogue the player is already in.


The default screen, and replacing it

DialogueScreen answers ScreenKey.DIALOGUE (evernifecore:dialogue) by default: the speaker as the title and on a label with the node's line as lore, then one Choice per option (element id option:<id>). Each draw checks the option's gate with the session's context:

  • passes: the choice is active, the costs of the gate as its last lore line;
  • fails: the choice is drawn disabled with every failure as its reason, one per line - the chest draws the name grey and each line in red at the end of the lore, a renderer that draws by element shows it as a tooltip;
  • fails and hidden: true (Choice.hiddenUntilPassed()): not drawn; the gate's sources redraw it, so it appears when the gate passes.

That one check per redraw decides all three, so a redraw posts one GateCheckEvent per option. A name with no colour of its own reads green when the option can be picked and grey when not.

The admin restyles it in guis/DialogueLayout.yml (SPEAKER, OPTION - its slots are where options go, in order -, BACKGROUND). A plugin replaces it for its own dialogues, reading the session from the request:

Screens.override(plugin, ScreenKey.DIALOGUE, ScreenScope.ownedBy(plugin), ctx -> {
    DialogueSession session = ctx.getRequest(DialogueSession.class);
    return myPixelmonDialogScreen(session);   // a click calls session.choose(optionId)
});

Each node is a new screen: the session replaces the previous one in place (ScreenNavigation.replace), and only the closing of its current screen ends it with CLOSED. An engine of its own can reuse the two pieces the default screen uses: Choice.pickedBy(click -> ...) hands the pick over instead of paying the gate in the choice, and Screens.build(player, key, request) resolves the four override layers without opening.


Events (dialogue.event)

All extend DialogueEvent (getSession(), getPlayer(), getDialogue(), getCaller()), are posted only when something listens, and pass ECEventConformance - see Events.

Event Fired when Carries
DialogueStartedEvent a session is about to open - cancellable: cancelled, no session -
DialogueChoiceEvent an option was picked, before its gate is paid - cancellable: nothing charged, the node stays getNode(), getOption()
DialogueEndedEvent a session ended, once getCause()

Reading it live: /ecore dialogue <player>

Permission evernifecore.command.dialogue. Prints the dialogue a player is in, the node, since when, who started it (the caller) and the NPC. It never starts or ends one.


Testing

DialogueTrees in evernifecore-common-tests works over ScreenWorld (the screen) and LogicWorld (the owner, its custom variables and the clock):

Dialogue talk = DialogueTrees.linear(owner, "greeting", "Hello!", "Bye!");      // n0 --next--> n1 (terminal)
Dialogue.Builder trap = DialogueTrees.withLoopWithoutExit(owner, "haggler");     // build() refuses it
Dialogue tomas = DialogueTrees.fromYaml(owner, "tomas", yamlText);
List<DialogueTrees.DrawnChoice> drawn = DialogueTrees.openDialogueScreen(screens, alex, talk); // registers, starts, reads
drawn.get(0).isEnabled(); drawn.get(0).getLore(); drawn.get(0).getReason();
List<DialogueTrees.DrawnChoice> now = DialogueTrees.drawn(screens, alex);          // after a refresh/advanceTicks

A node change replaces the screen one tick later (screens.advanceTicks(1)); the timeout runs on the shared TestClock (logic.advance(...), logic.runDue()). Close the owner's dialogues (Dialogues.closeOwnedBy) and any screen left open before the worlds close.


See also

  • Logic - Gate, Condition, Effect, custom variables
  • Screens - Choice, overrides, the renderer SPI
  • NPCs - NpcRef, interact-npc, near-npc
  • Regions - RegionCrossedEvent
  • Localization - LocaleOverlay and /ecore i18n export
  • Events - the dialogue events in the canonical list

Clone this wiki locally