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 |
|---|---|---|---|
|
|
| Placeholder namespace |
|
|
| Placeholder namespace |
|
|
| No placeholder namespace. |
Service | Repository | Responsibility |
|---|---|---|
|
| Member CRUD (add/kick/leave/promote/demote/transfer), cross-server member sync. |
|
| Co-op grant CRUD, O(1) |
|
| Invitation lifecycle (create/accept/decline/cancel) and periodic pruning; composes |
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 |
|
|
|
A non-expired invitation already exists from this island to this recipient (of either type — |
|
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.createsetsexpiresAt = now + 15 * 60 * 1000(15 minutes).InvitationRepository.findPendingfiltersexpires_at > nowat the SQL level, so an expired invitation is invisible to the duplicate-check increateand toaccept/decline/canceleven before it is physically deleted.InvitationService's constructor schedulespruneExpiredSyncwithBukkit.getScheduler().runTaskTimerAsynchronously(plugin, this::pruneExpiredSync, 1200, 1200)— every 1200 ticks (60 seconds) it bulk-deletes everyisland_invitationsrow whoseexpires_atis 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:
Kicker must have
KICK_MEMBER→ elseno-permission.Target must be a current member → else
member-not-found.Target cannot be the owner → else
member-higher-role.A non-owner kicker must strictly outrank the target (
kicker.role().weight() > target.role().weight()) — owners always bypass this check → elsemember-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 leave →
owner-cannot-leave("Transfer ownership first"). Ownership must be transferred to another member before the former owner can leave.Otherwise the row is deleted,
IslandMemberLeaveEventfires withReason.VOLUNTARY, aMemberLeavePacketis broadcast, and the player getsisland-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):
Target must exist and not be the owner → else
member-not-found/member-higher-role.If the target is already at the top of the ladder →
member-already-highest-role.A non-owner sender cannot promote a target to a role whose weight is
>=the sender's own role weight →member-promote-higher.Otherwise the target's role is set to the next rung up; the sender gets
member-promoted-sender, the promoted player getsmember-promoted-targetcross-server.
MemberService.demote(island, sender, targetUuid) (permission DEMOTE_MEMBERS):
Target must exist and not be the owner → else
member-not-found/member-higher-role.A non-owner sender must strictly outrank the target (same weight comparison as kick) → else
member-higher-role.If the target is already at the bottom of the ladder →
member-already-lowest-role.Otherwise the target's role is set to the rung below; the sender gets
member-demoted-sender, the demoted player getsmember-demoted-targetcross-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.yml — RoleService.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.accept → CoopService.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→ elseno-permission.Target must currently be a co-op member (
isCoop) → elsecoop-not-found.Otherwise the row is deleted, the island's local
coops()collection is pruned,IslandCoopRemoveEventfires, aCoopRemovePacketis broadcast, the remover getscoop-removed-sender, and the removed player getscoop-removed-targetcross-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.
Menu actions
Seven AstralSkyblock actions back the member/co-op menus, registered directly in onEnable:
Action | Args | Delegates to |
|---|---|---|
|
| Checks |
|
|
|
|
|
|
|
|
|
|
|
|
|
| Checks |
|
|
|
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:
Generic repository sync (
MemberRepositoryonly). Every write that goes through it —add,remove,setRole,transferOwnership— invalidates the shared cache by broadcasting aMemberObjectUpdatePacketorMemberObjectDeletePacket. Every server's handler refreshes (or evicts) its localIslandMemberentry and then callsIslandService.refreshRelationships(islandId)to re-hydrate the cachedIsland's owner/members snapshot. This layer alone is what keeps promote, demote, and ownership transfer consistent network-wide — they send no other packet.CoopRepositoryhas no equivalent (itspublishUpdate/publishInvalidationare no-ops); coop sync relies entirely on layer 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
MemberJoinPacketMemberService.addMemberIslandMemberJoinEventMemberLeavePacketMemberService.kick/leaveIslandMemberLeaveEvent(Reason.KICKED/VOLUNTARY)CoopAddPacketCoopService.addIslandCoopAddEventCoopRemovePacketCoopService.removeIslandCoopRemoveEventHandlers 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 |
|---|---|---|
|
| No |
|
| No |
|
| No |
|
| 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
IslandPermissioncatalogue (KICK_MEMBER,PROMOTE_MEMBERS,DEMOTE_MEMBERS,INVITE_MEMBER,COOP_MEMBER,UNCOOP_MEMBER,BAN_MEMBER, …) and how role weight gatesIsland#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_LIMITupgrade tracks).Developer API — listening to
IslandMemberJoinEventand friends from another plugin.Placeholders — the
member,coop, andbanplaceholder namespaces.