Repository navigation
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.
| 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.
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.
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)(defaultINFO). -
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 ingetLastMembers()),after(duration),at(instant). - The same live behaviour on any group through its setters (
setJoinEdge,setKickWhen,setQuitPolicy, ...) andGroupOptions. The effectevernifecore:dissolve-groupdissolves a group from logic.
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.
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.
GroupLookup.get().register(plugin, new GroupProvider() { ... }); //a clans plugin's own groups, read onlyGroupProvider 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.
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.
-
/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.
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 (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
leaderis alsoGroup.getLeader();transfersets both and leaves the old leaderofficer.removeof 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(oneNotifyKindchain), each edge sends through theNotifierwith the chain the server configured (Notification.channels(NotifyChain.parse(...))), validated on enable withNotifier.validate. -
A teleport behind a gate.
Gate.parse(plugin, node, zone)oftp.gateplusRepeatPolicy.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 aRegexReplacer<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.
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.
- 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_PAIDlines.
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