Astral Realms Documentation Help

Members, Co-ops & Invitations

An island's social graph is three models — IslandMember, IslandCoop, IslandInvitation — backed by three services: MemberService, CoopService, InvitationService. Almost every mutation here (kick, leave, promote, demote, transfer, coop, uncoop) is reachable both from an ACF subcommand (/is …) and from an AstralCore menu action, and every write that changes who belongs to an island is broadcast to every other server over RabbitMQ so their cached Island snapshots stay correct.

Membership model

Model

Table

Key fields

Notes

IslandMember

island_members

islandId, playerUuid, isOwner, roleId (nullable), joinedAt

Placeholder namespace member. The owner row always has roleId = null — ownership is tracked by the isOwner flag, not a role. The role field is transient, hydrated onto the object by IslandService from the island's role list.

IslandCoop

island_coops

islandId, playerUuid, addedBy (nullable UUID), createdAt

Placeholder namespace coop. A co-op grant is not a membership — the player keeps their own island (if any) and is simply trusted on this one. Permissions come from the island's single COOP-kind role (see Roles, Permissions & Settings), not from a per-grant role.

IslandInvitation

island_invitations

uniqueId, islandId, senderId, recipientId, type (MEMBER \|COOP), expiresAt, createdAt

No placeholder namespace. IslandInvitation.create(...) stamps expiresAt = createdAt + 15 minutes; expired() compares that to System.currentTimeMillis().

Service

Repository

Responsibility

MemberService

MemberRepository

Member CRUD (add/kick/leave/promote/demote/transfer), cross-server member sync.

CoopService

CoopRepository

Co-op grant CRUD, O(1) isCoop lookup, cross-server sync.

InvitationService

InvitationRepository

Invitation lifecycle (create/accept/decline/cancel) and periodic pruning; composes MemberService and CoopService to fulfil an accepted invitation.

Invitations

Sending

/is invite <player> (permission INVITE_MEMBER) and /is coop <player> (permission COOP_MEMBER) both resolve the sender's island and call InvitationService.create(island, sender, recipient, type) with InvitationType.MEMBER or InvitationType.COOP respectively. The [invite-member] and [coop-player] menu actions do the same, performing the identical permission check themselves before calling create.

InvitationService.create performs no permission check of its own — that is entirely the caller's job — but does guard against redundant invitations, silently returning after messaging the sender:

Condition

Message

Recipient is already a full member of the island

player-already-member

type == COOP and the recipient is already a co-op member

player-already-coop

A non-expired invitation already exists from this island to this recipient (of either typefindPending does not filter by type)

invitation-already-sent

Otherwise a new IslandInvitation is persisted (15-minute TTL), the sender gets invitation-sent, and the recipient gets invitation-received delivered cross-server through AstralCore's ChatService.

Responding

/is accept [player] and /is decline [player] both call InvitationService.findByRecipient and filter to non-expired invitations:

  • With a [player] argument, only that sender's pending invitation is considered; none found → no-pending-invitation.

  • With no argument, all of the recipient's pending invitations are considered: zero → no-pending-invitation; more than one → multiple-pending-invitations (the player must re-run the command naming a sender to disambiguate); exactly one is auto-selected.

InvitationService.accept re-resolves the pending invitation by (islandId, recipientId), then delegates to MemberService.addMember (type MEMBER) or CoopService.add (type COOP), deletes the invitation row, and messages both sides (invitation-accepted-recipient/invitation-accepted-sender). InvitationService.decline just deletes the row and messages both sides (invitation-declined-recipient/invitation-declined-sender).

Both accept and decline resolve the island with a synchronous local-cache lookup (IslandService.repository().findCachedById) before touching the invitation at all. If the island isn't currently hydrated on the server the responding player is standing on, the call fails as invitation-not-found even though the invitation row still exists in the database.

Cancelling

/is cancel <player> calls InvitationService.cancel(island, sender, target). Only the original sender may cancel — the check is invitation.senderId().equals(sender.getUniqueId()); a mismatch (or no pending invitation) is silently reported as invitation-not-found. On success both sides are messaged (invitation-cancelled-sender/invitation-cancelled-recipient).

Expiry & pruning

  • IslandInvitation.create sets expiresAt = now + 15 * 60 * 1000 (15 minutes).

  • InvitationRepository.findPending filters expires_at > now at the SQL level, so an expired invitation is invisible to the duplicate-check in create and to accept/decline/cancel even before it is physically deleted.

  • InvitationService's constructor schedules pruneExpiredSync with Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, this::pruneExpiredSync, 1200, 1200) — every 1200 ticks (60 seconds) it bulk-deletes every island_invitations row whose expires_at is in the past.

Member operations

Kick

MemberCommand#onKick (permission KICK_MEMBER, checked in the command) and the [kick-member] action both call MemberService.kick(island, kicker, targetUuid), which re-checks everything itself:

  1. Kicker must have KICK_MEMBER → else no-permission.

  2. Target must be a current member → else member-not-found.

  3. Target cannot be the owner → else member-higher-role.

  4. A non-owner kicker must strictly outrank the target (kicker.role().weight() > target.role().weight()) — owners always bypass this check → else member-higher-role.

On success the row is deleted, IslandMemberLeaveEvent fires locally with Reason.KICKED, a MemberLeavePacket is broadcast, the kicker gets member-kicked-sender, and the kicked player gets member-kicked-target cross-server.

Leave

/is leave calls MemberService.leave(island, player):

  • No island → no-island.

  • The owner cannot leaveowner-cannot-leave ("Transfer ownership first"). Ownership must be transferred to another member before the former owner can leave.

  • Otherwise the row is deleted, IslandMemberLeaveEvent fires with Reason.VOLUNTARY, a MemberLeavePacket is broadcast, and the player gets island-left.

Promote & demote

Both walk a role ladder: island.roles() filtered to kind == MEMBER, sorted ascending by weight (memberRoleLadder). The island owner holds no role at all and is never part of this ladder. With the shipped roles.yml, the MEMBER-kind ladder is just member (weight 2) → admin (weight 3) — visitor and co-op are VISITOR/COOP-kind roles and are never promote/demote targets.

MemberService.promote(island, sender, targetUuid) (permission PROMOTE_MEMBERS):

  1. Target must exist and not be the owner → else member-not-found/member-higher-role.

  2. If the target is already at the top of the ladder → member-already-highest-role.

  3. A non-owner sender cannot promote a target to a role whose weight is >= the sender's own role weight → member-promote-higher.

  4. Otherwise the target's role is set to the next rung up; the sender gets member-promoted-sender, the promoted player gets member-promoted-target cross-server.

MemberService.demote(island, sender, targetUuid) (permission DEMOTE_MEMBERS):

  1. Target must exist and not be the owner → else member-not-found/member-higher-role.

  2. A non-owner sender must strictly outrank the target (same weight comparison as kick) → else member-higher-role.

  3. If the target is already at the bottom of the ladder → member-already-lowest-role.

  4. Otherwise the target's role is set to the rung below; the sender gets member-demoted-sender, the demoted player gets member-demoted-target cross-server.

Both persist through MemberRepository.setRole and notify the target directly via ChatService — unlike kick/leave, no IslandMemberJoinEvent/IslandMemberLeaveEvent fires for a promote or demote, and no MemberJoinPacket/MemberLeavePacket is sent (see Cross-server sync below).

Transfer ownership

MemberCommand#onTransfer/ the [transfer-ownership] action require the caller to already be the island owner (else not-island-owner), then resolve the target as an existing IslandMember (else member-not-found) and call MemberService.transfer(island, currentOwner, newOwner). Only the current owner may invoke it — enforced again inside transfer itself.

transfer picks the highest-weight MEMBER-kind role on the island (with the shipped roles this is admin, weight 3) and runs a single transaction: the ex-owner is demoted into that role and their isOwner flag cleared, then the new owner's isOwner flag is set and their roleId cleared. On success the ex-owner gets ownership-transferred-sender, the new owner gets ownership-transferred-target cross-server. Like promote/demote, transfer does not fire IslandMemberJoinEvent/IslandMemberLeaveEvent or send a MemberJoinPacket/MemberLeavePacket — cross-server consistency comes entirely from the generic repository sync described below.

Default role on join

MemberService.addMember (used by both invitation acceptance and any other membership grant) always assigns the role flagged default: true in roles.ymlRoleService.defaultRoleSeeds carries that flag onto every IslandRole it seeds for a new island. In the shipped configuration this is the member role (weight 2). If no role on the island is marked default, addMember throws IllegalStateException rather than silently picking one.

Co-op operations

CoopService has no invite-free "add" entry point exposed to players — a co-op grant is only ever created by accepting a COOP-type invitation (InvitationService.acceptCoopService.add). CoopService.add itself performs no permission check (same "caller's responsibility" pattern as InvitationService.create); it persists the IslandCoop row, appends it to the island's in-memory coops() collection, fires IslandCoopAddEvent, and broadcasts a CoopAddPacket.

/is uncoop <player> and the [uncoop-player] action both call CoopService.remove(island, remover, playerUuid) (permission UNCOOP_MEMBER, checked inside remove):

  • Remover must have UNCOOP_MEMBER → else no-permission.

  • Target must currently be a co-op member (isCoop) → else coop-not-found.

  • Otherwise the row is deleted, the island's local coops() collection is pruned, IslandCoopRemoveEvent fires, a CoopRemovePacket is broadcast, the remover gets coop-removed-sender, and the removed player gets coop-removed-target cross-server.

Read access: CoopService.isCoop(islandId, playerUuid) is an O(1) check backed by CoopRepository's in-memory secondary index (no database or shared-cache hit); CoopService.findByIsland(islandId) returns the full co-op roster, priming the cache slice on first access.

Seven AstralSkyblock actions back the member/co-op menus, registered directly in onEnable:

Action

Args

Delegates to

[invite-member]

<island> <target>

Checks INVITE_MEMBER itself, then InvitationService.create(..., MEMBER).

[kick-member]

<island> <targetUuid>

MemberService.kick

[promote-member]

<island> <targetUuid>

MemberService.promote

[demote-member]

<island> <targetUuid>

MemberService.demote

[transfer-ownership]

<island> <newOwner:IslandMember>

MemberService.transfer

[coop-player]

<island> <target>

Checks COOP_MEMBER itself, then InvitationService.create(..., COOP).

[uncoop-player]

<island> <targetUuid>

CoopService.remove

These are wired into menus/members.yml, menus/member-manage.yml, menus/member-role.yml and the co-op equivalents rather than exposed as full subcommands — see the GUI-driven UX section of Overview.

Cross-server sync

Two independent layers keep every island server's copy of the roster correct:

  1. Generic repository sync (MemberRepository only). Every write that goes through it — add, remove, setRole, transferOwnership — invalidates the shared cache by broadcasting a MemberObjectUpdatePacket or MemberObjectDeletePacket. Every server's handler refreshes (or evicts) its local IslandMember entry and then calls IslandService.refreshRelationships(islandId) to re-hydrate the cached Island's owner/members snapshot. This layer alone is what keeps promote, demote, and ownership transfer consistent network-wide — they send no other packet. CoopRepository has no equivalent (its publishUpdate/publishInvalidation are no-ops); coop sync relies entirely on layer 2.

  2. Event-replay packets, sent explicitly by the service layer on top of layer 1:

    Packet

    Sent by

    Re-fires locally on every other server

    MemberJoinPacket

    MemberService.addMember

    IslandMemberJoinEvent

    MemberLeavePacket

    MemberService.kick/leave

    IslandMemberLeaveEvent (Reason.KICKED/VOLUNTARY)

    CoopAddPacket

    CoopService.add

    IslandCoopAddEvent

    CoopRemovePacket

    CoopService.remove

    IslandCoopRemoveEvent

    Handlers for these packets only fire the Bukkit event — they never write to the database (the originating server already did). This is why listeners on any server (notifications, analytics, …) see the join/leave/coop-add/coop-remove regardless of which server the player who triggered it was on, while promote/demote/transfer — which have no such packet — do not raise any event off the originating server.

All five packets are registered in ASPacketRegistry: MemberJoinPacket (0x102), MemberLeavePacket (0x103), CoopAddPacket (0x104), CoopRemovePacket (0x105).

Events

Event

Fired by

Cancellable

IslandMemberJoinEvent

MemberService.addMember (and the MemberJoinPacket handler, network-wide)

No

IslandMemberLeaveEvent

MemberService.kick/leave (and the MemberLeavePacket handler); carries a Reason of VOLUNTARY, KICKED, or BANNED

No

IslandCoopAddEvent

CoopService.add (and the CoopAddPacket handler)

No

IslandCoopRemoveEvent

CoopService.remove (and the CoopRemovePacket handler)

No

Reason.BANNED is a defined enum constant but nothing in the current codebase constructs an IslandMemberLeaveEvent with it — see Bans below. See Developer API for the full event catalogue and listening examples.

Bans

The IslandBan model (island_bans: islandId, playerUuid, bannedBy, nullable reason, createdAt, placeholder namespace ban) exists, IslandPermission.BAN_MEMBER is a real permission, and the GUI is fully built: menus/bans.yml (island-bans, view-gated on BAN_MEMBER) and menus/confirm/confirm-ban.yml (island-confirm-ban) both reference [ban-member] and [unban-player] actions, and menus/member-manage.yml shows a Bannir button gated the same way.

As of this source snapshot, however, no ban-member or unban-player action is registered anywhere in AstralSkyblock#onEnable, and there is no BanService, BanRepository, or ban command class — clicking those menu buttons invokes an action id AstralCore's registry doesn't know, so they currently no-op. The data model and permission are in place; the enforcement path is not yet wired up.

See also

  • Roles, Permissions & Settings — the full IslandPermission catalogue (KICK_MEMBER, PROMOTE_MEMBERS, DEMOTE_MEMBERS, INVITE_MEMBER, COOP_MEMBER, UNCOOP_MEMBER, BAN_MEMBER, …) and how role weight gates Island#hasPermission/Island#canEditRole.

  • Commands — full syntax, permissions and tab-completion for invite, coop, accept, decline, cancel, kick, leave, promote, demote, transfer, uncoop.

  • Islands — island creation, blueprints and upgrades (including the MEMBERS_LIMIT/COOP_LIMIT upgrade tracks).

  • Developer API — listening to IslandMemberJoinEvent and friends from another plugin.

  • Placeholders — the member, coop, and ban placeholder namespaces.

Last modified: 25 July 2026