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

Groups

The group engine (br.com.finalcraft.evernifecore.group) is the one concept of "group" in the core: a party, a clan, an arena team, an island, the group local to a region. A plugin keeps its groups through a GroupManager, in the plugin's own storage or in memory; the core answers questions about every plugin's groups through GroupLookup and applies five group flags. Like the region system it is an engine for plugins: the core has no verb that creates a group of its own, only /ecore group <player> to read and a GroupCommandNode a plugin mounts.


The model

Type What it is
GroupId owner:name, or owner:<region>/<group> for a region's group; GroupId.of(owner, name), regionLocal(region, group), parse(text)
GroupKind what a group is: GroupKind.of(owner, "party").cardinality(ONE).roles(...).flagDefault(flag, value).build(); GroupKind.REGION_LOCAL is the kind of a region's groups
GroupRole GroupRole.of(name, rank): a step of the kind's ladder. The leader is a field, not a role
GroupMember getUuid(), getJoinedAt(), getExpiresAt(), getRemaining(), getRoles(), getRank() (highest rank held, 0 for none), hasRole(name)
Invite getPlayer(), getInvitedBy(), getInvitedAt(), getExpiresAt()
LeaveCause LEFT, KICKED, EXPIRED, QUIT, ROSTER, DISSOLVED, PLUGIN, OWNER_DISABLED
DissolveCause OWNER, EMPTY, EXPIRED, EFFECT, REGION_REMOVED, OWNER_DISABLED
JoinCause ADDED, JOINED, INVITED, ROSTER

Cardinality. MANY (the default) lets a player be in several groups of the kind; ONE refuses a second one on add (an IllegalStateException that says how out) and on join (a denied GateResult): "Steve is already in the party 'alpha' (finalparty:party allows one group per player): leave it first, or make the kind MANY."

Two ways in and out. add(uuid[, ttl]) / remove(uuid, cause) / kick(uuid, reason) are the owner's direct calls; join(player) / leave(player) / accept(player) are the player's path, which also goes through the join or leave Edge (a Gate that may charge, and notices) and answers a GateResult (isPassed(), explain()). Both check the cardinality and max-members and post the events. contains(player) answers "belongs?" - an explicit member, or by permission or predicate as the MembershipCheck says; cardinality, events, roles and the lookup use only explicit members (isMember).

Invites. invite(uuid, ttl, invitedBy) (a positive TTL), revokeInvite, getInvites(); accept(player) consumes the invite and joins. The sweeper expires them (GroupInviteEvent EXPIRED).

Threads. Any thread. The writers of one manager (or one region) are serialized by its lock, reads take none, every event goes out on the thread of whoever made the change, and storage I/O never runs on the caller's thread.


GroupManager

GroupKind party = GroupKind.of(plugin, "party").cardinality(GroupKind.Cardinality.ONE)
        .roles(GroupRole.of("member", 0), GroupRole.of("officer", 10))
        .flagDefault(CoreFlags.MAX_MEMBERS, 8)
        .build();
GroupManager parties = GroupManager.of(plugin, storage);   //PERSISTENT groups, collection "groups"
parties.registerKind(party);                                //before load()
parties.load();

Group alpha = parties.create("alpha", party, GroupOptions.defaults().withLeader(steve.getUniqueId()));
alpha.invite(alex.getUniqueId(), Duration.ofMinutes(5), steve.getUniqueId());

GroupManager.of(owner) holds TRANSIENT groups only and refuses a PERSISTENT one, saying how to persist it (the storage.Persistence a region uses too). get(name), getAll(), getByKind(kind), getGroupsOf(uuid[, kind]), getGroupOf(uuid, kind) (ONE kinds), flush(), close(). A stored group whose kind is not registered stays unread and logged, and is read when the kind registers. The core closes every manager of a plugin on its shutdown, right after its regions (GroupManager.closeOwnedBy); TRANSIENT groups dissolve then with OWNER_DISABLED.

Ephemeral groups

Group wave = Groups.ephemeral(theDuel, TEAM).name("arena-1-wave-7").scope("theduel:arena-1/wave-7")
        .roster(Roster.live(inArena))
        .onJoin(Edge.gate(entryGate).notify(EdgeAudience.MEMBERS, "&a{player} joined"))
        .onLeave(Edge.silent())
        .kickWhen(fellThreeTimes, FancyText.of("&cYou fell 3 times"))
        .dissolveWhen(Dissolution.whenEmpty(), Dissolution.after(Duration.ofMinutes(20)))
        .create();
  • Roster.snapshot(uuids), Roster.snapshot(condition) (once, at creation), Roster.live(condition) (members follow the condition; a candidate the edge refused is tried again only after it stopped passing once).
  • Edge.silent(), Edge.gate(gate), then .notify(audience, text) (PLAYER, MEMBERS, MEMBERS_AND_PLAYER, LEADER; text with {player}, {group} and placeholders) and .via(NotifyKind) (default INFO).
  • QuitPolicy.LEAVE (default for TRANSIENT groups) takes a quitting member out; one quit listener for the whole engine, on the bus only while such a group exists.
  • Dissolution.whenEmpty() (the member who emptied it is in getLastMembers()), after(duration), at(instant).
  • The same live behaviour on any group through its setters (setJoinEdge, setKickWhen, setQuitPolicy, ...) and GroupOptions. The effect evernifecore:dissolve-group dissolves a group from logic.

Events

All in group.event, base GroupEvent (getGroup()), posted only when something listens. Loading is no change.

Event When Carries
GroupKindRegisteredEvent GroupManager.registerKind finished - after reading the stored groups of the kind when the manager has loaded; a plugin waiting for a kind listens to this, not to the first GroupCreatedEvent. Not a GroupEvent getManager(), getKind()
GroupCreatedEvent a group was created (manager, ephemeral, a region's first getGroup)
GroupMemberPreJoinEvent before any way in - cancellable getPlayer(), getFPlayer(), getCause() (JoinCause), setDenyReason(FancyText)
GroupMemberJoinedEvent after the same and getMember()
GroupMemberLeftEvent after any way out getPlayer(), getMember(), getCause() (LeaveCause)
GroupRoleChangedEvent a member's roles changed getOldRoles(), getNewRoles()
GroupLeaderChangedEvent setLeader, or the leader left getOldLeader(), getNewLeader()
GroupInviteEvent an invite was sent, accepted, revoked or expired getInvite(), getChange()
GroupFlagChangedEvent a flag value of a group that is not a region's changed the flag, old/new FlagValue, FlagChangeCause
GroupDissolvedEvent dissolve getCause(), getLastMembers()

A region's group posts the same events with getGroup().getRegion() naming the region; its flag changes stay a RegionFlagChangedEvent.


Group flags

Five ACTOR flags of CoreFlags, applied by the core (the catalog rows are on Regions):

Flag Value What the core does
deny-friendly-fire Tristate, not bypassable denies a hit between two players who share a group (GroupLookup.shareGroup, any provider, a region's own groups aside); on a place for everyone there, on a group wherever its members are. deny-pvp: TRUE already denies every hit; deny-pvp: FALSE + this TRUE = PvP only between different groups
max-members integer, 0 = no cap add answers false, join and accept refuse with "The party 'alpha' is full (8/8, max-members): raise the flag or remove a member."
share-xp fraction each OTHER online member in range gets a copy of amount * fraction of the experience a member picks up (XpGainActionEvent), as evernifecore:exp-points, caller evernifecore:group/share-xp
share-money fraction the same for a positive change of money (BalanceChangeEvent); the copies it gives carry evernifecore:group/share-money and are never shared again
glow-members Tristate TRUE makes the online members glow for everyone, coloured by one scoreboard team per group, through the platform's IGroupGlowProvider (Bukkit 1.9+); a player on another plugin's team keeps it and glows white. A member who logs in starts glowing, one who quits stops while still online (no Glowing tag rides the logout); nothing listens while no group glows

The share flags read ShareSettings (radius, default 50 blocks, 0 = any distance; sameWorld, default true). A gain is shared once per member, by the best fraction among the sharing groups they have with the gainer. Their listener is one for the engine and is on the bus only while some group shares that gain. A group's own value wins; without one, its kind's flagDefault - in the flag resolution too (Group.getFlagValue answers it as a permanent value set when the group was created), so a kind that defaults deny-friendly-fire: TRUE protects its members with no value written on any group. max-members caps a region's group too; the other four mean nothing on a region or on a region's group, and the load warns once.

Platforms: Hytale has no XP source (share-xp logs once) and no glow provider (glow-members logs once, /ecore group says unsupported on this platform); deny-friendly-fire, max-members and share-money work everywhere.

The victim speaks for the victim. invincible, deny-damage-from-mobs and damage-multiplier resolve the groups and following holders of the player hit (Flag.getSubject(context)): a party's invincible shields its member from a zombie in the open field, and no one its members hit.


Providers and the lookup

GroupLookup.get().register(plugin, new GroupProvider() { ... });   //a clans plugin's own groups, read only

GroupProvider answers getKinds(), getGroupIdsOf(uuid[, kind]), exists(id), getMembers(id), getLeader(id) and, for a provider whose groups carry flags, getHoldersOf(player). Every GroupManager is one. GroupLookup.get() asks every manager and every registered provider: getGroupsOf, byId (managed groups only), exists, getMembers, getLeader, hasOnlineMember, shareGroup, getHoldersOf (the ACTOR layer of the flag resolution, highest priority first), byScope(prefix). One provider per kind key; a plugin's providers leave with its shutdown. A registered provider that throws, in any of these, counts as one that does not answer: the lookup goes on with the other providers, and the server is warned once per registration, naming the provider's plugin and the exception (registering it again re-arms the warning). The core's own managers are not shielded. A member joining, leaving or expiring and a flag change of a group invalidate the member's region session.

ProgressOwner.group(id) (owner kind group) is answered by the core over the same lookup: contains is membership, isAlive is exists.


The network

if (Groups.hasNetwork()) {
    Groups.network().send(party, "survival").thenAccept(result -> result.getLeft().forEach(...));
}

GroupNetwork is the contract to move groups between the servers of a network: send(group, server) and follow(group) (one shot to the leader's server) answer a SendResult (getMoved(), getLeft() with why); getServer(player) says where a player is. The core ships no implementation: until a plugin calls Groups.registerNetwork(owner, impl) (one per server; a second is refused naming the first owner), every call of Groups.network() fails with GroupNetworkUnavailableException, whose message teaches Groups.registerNetwork and Groups.hasNetwork(). The network leaves with its owner's shutdown.


Commands

  • /ecore group <player> (evernifecore.command.group): every group of the player in every manager and provider - id, kind, leader, online/total, the player's roles, member since and expiry, the group's flags with origin and time left. Only reads.
  • GroupCommandNode(manager, kind, permissionBase), mounted by a plugin:
@FinalCMD.Node(subcmd = "party", permission = "myplugin.party")
private final GroupCommandNode parties = new GroupCommandNode(manager, PARTY, "myplugin.party");

create <name> [leader], invite <group> <player> [ttl] (5 minutes by default), join <group> (through the invite when there is one), leave [group] (the sender's in a ONE kind), kick <group> <player>, promote|demote <group> <player> <role>, disband <group>, info <group>, list, flag <group> <flag> [value|-] [-t ttl]. With a permissionBase each verb also needs <base>.<verb>, checked before any argument by NodeVerbAccess - the same gate the region node uses (VerbPermissionBase). Every message is @FCLocale EN_US and PT_BR.


Rewards

br.com.finalcraft.evernifecore.logic.reward pays a group - or any ProgressOwner - by one RewardRule, without a quest. A quest, a duel, a colony and a party all pay through the same Rewards.pay(rule, context). The core does not know who contributed or whose action closed it: the caller measures both and hands them in through RewardContext.

A rule is five pieces plus its effects:

Piece Stock (evernifecore:<name> in Rewards.registry(), "reward-piece") Default
eligibility (all must accept) any-member, online, present (in the owner's region/world; online for a group or a player), role: leader|<role> (group owners only), last-actor, contribution-at-least: N, contribution-share: 60% (of total / contributors), top: K, condition: <Condition> any-member
distribution each (the whole reward to everyone), split-equal, proportional (to the contribution), top-takes-all, tiers: [50%, 30%, 20%] each
absent (an eligible member offline at pay time) deliver (a pending delivery, claimed on the next login), forfeit, redistribute (among the online eligible, by the same distribution) deliver
moment on-complete, on-window-end, on-owner-gone - a pay whose RewardContext trigger the moment does not take pays nothing and posts nothing on-complete
leaver / joiner leaver: keeps|forfeits (does a contributor who left stay a candidate), joiner: from-now|inherits-zero keeps, from-now

The candidates are the owner's members (offline included for a group) plus every contributor while the leaver keeps. Each eligible member gets the rule's effects multiplied by its share (Effect.withShare: money, exp-points, give-item divide; an effect without an amount goes whole). A split share is cut at 10 places, never rounded up, so a split never pays more than the whole; the currency then rounds what it gives. An offline member's share runs with the evernifecore:pending destination, so an amount or an item becomes one pending delivery from the caller.

Every payment (applied or delivered) posts a RewardPaidEvent (getMember(), getShare(), isDelivered()) and writes an AuditKind.REWARD_PAID line in the caller plugin's AuditLog when it has one open. pay completes, once every effect applied or was kept, with the share each paid member got - delivered included, forfeited left out. It refuses role: on an owner that is not a group with a message naming ProgressOwner.group(id). Any thread.

// TheDuel's boss arena, no quest involved: the plugin measured the damage itself
RewardContext ctx = RewardContext.builder(Caller.of(plugin, "boss-arena"), ProgressOwner.group(arenaGroup.getId()))
        .contributions(fightTracker.damageByPlayer())        // Map<UUID, BigDecimal>, the plugin's own book
        .lastActor(killerUuid)
        .build();
RewardRule rule = RewardRule.builder()
        .eligibility(Eligibility.contributionShare(new BigDecimal("0.6")))   // 60% of the fair share
        .distribution(Distribution.proportional())
        .absent(Absence.deliver())
        .effects(Effects.parseList(plugin, rewardNode, Window.getServerZone()))
        .build();
Rewards.pay(rule, ctx).thenAccept(paid -> paid.forEach((uuid, share) -> log(uuid, share)));

The same rule as a quest writes it, read by RewardRule.parse(plugin, node) (a bare name is {type: <name>}; a piece under another role's key is refused naming the key it belongs under):

rewards:
  - eligibility: [ { type: contribution-share, at-least: 60% } ]
    distribution: { type: proportional }
    absent: deliver
    effects: [ { type: money, amount: 10000 } ]

The five modes the owner listed, each a named case of RewardsTest:

Mode Eligibility Distribution Absent
the leader takes it all role: leader each deliver
whose last action closed it takes it all last-actor each deliver
split among the online online split-equal forfeit
split by contribution (the caller measures it) contribution-at-least: 1 proportional deliver
split equally, offline included any-member split-equal deliver

A plugin adds a piece by registering a TypeDescriptor<RewardPiece> on Rewards.registry() (its role comes from the interface it implements: Eligibility, Distribution, Absence, Moment, RewardMembership); a piece that reads another engine's state reads it through RewardContext.getAttribute(key), which its caller filled. A test opens a GroupWorld and a LogicWorld together (one clock, one online list; deliveries() and auditLog() from the LogicWorld, which registers the pieces with its stock).


FinalParty: the reference product

FinalParty (a separate plugin) is the shape of a product over this engine, and the place to copy from. Its party is a GroupKind finalparty:party of cardinality ONE, registered on GroupManager.of(plugin, storage) before load(), with the ladder member(0) < officer(10) < leader(20) and the config defaults of the five group flags as flagDefaults (the core resolves them for every party that sets nothing). What the engine leaves to the product:

  • One leader, transferable. The member holding leader is also Group.getLeader(); transfer sets both and leaves the old leader officer. remove of a leader leaves the group without one, so the product picks the successor (the oldest officer, else the oldest member) right after a leader leaves.
  • Refusals that teach. Before Group.accept/join (which would answer the engine's English text), the product checks its own rules - already in a party, full (getEffectiveFlag(MAX_MEMBERS) at invite time), expired invite (the stored invite is still there until the sweep: getStoredInvites() tells expired from absent) - and answers its own lang message.
  • Dissolve when empty. A PERSISTENT group has no Dissolution.whenEmpty(); the product dissolves when the last member leaves (dissolve(DissolveCause.EMPTY)).
  • Notices on chains of its own. Instead of Edge.notify (one NotifyKind chain), each edge sends through the Notifier with the chain the server configured (Notification.channels(NotifyChain.parse(...))), validated on enable with Notifier.validate.
  • A teleport behind a gate. Gate.parse(plugin, node, zone) of tp.gate plus RepeatPolicy.unlimited().cooldown(...).asCost(counter) appended last, so a denied try never spends the cooldown and a failed cost refunds the money.
  • Placeholders over any replacer. register(RegexReplacer<O>, parties, Function<O, UUID>), so a test drives it with a RegexReplacer<UUID>.

Its tests run on GroupWorld + the command harness + LogicWorld/PlayerDataWorld for the teleport (a player cooldown lives in the core's PlayerData), with the relocated kit evernifecore-minecraft-tests because the gate is read as a relocated JsonNode.


Testing

GroupWorld in evernifecore-common-tests is one plugin that owns groups and regions on one storage and one clock; Platforms.withGlow() gives a platform that can glow. See Testing Toolkit.

See also

  • Regions - a region's groups, the flag catalog and the resolution.
  • Events - the event bus.
  • Logic - Gate, Condition, ProgressOwner.
  • Deliveries - where an offline member's reward waits; Audit Log - the REWARD_PAID lines.

Clone this wiki locally