Skip to content

13. Cross-Server Synchronization ​

AppelTools paper nodes and the Velocity proxy share one MySQL database but no JVM. They coordinate via Velocity plugin-message channels (see 08-messaging.md) and through shared rows in MySQL.

This document enumerates each feature that crosses the boundary and shows how state is kept consistent.

13.1 Inventory of Cross-Server Features ​

FeatureVelocity (source-of-truth)Paper (consumer)MySQL row(s)
Baninserts row; kicks targetdenies loginpunishments, tempPunishments, undonePunishments
Muteinserts row; broadcastscancels chat eventssame as above
Kickinserts rowdenies further connectssame as above
Friend listFriendsCommand mutates playerData.FriendsnothingplayerData.Friends
Friend requestFriendRequestDB.addFriendRequestnothingfriendRequests
Partyin-memory on velocitynothingnone
Private messageMessageCommand forwards via plugin messagenothingplayerData.LastMessaged
Staff chatbroadcasts to staff on velocity and paperrenders localnone
MaintenanceMaintenanceCommand toggleskicks + joins with <kick>none (state in proxy memory)
RestartRestartServerCommand schedules proxy.shutdown()shows local countdownnone
Whitelisttoggle on paper via setWhitelistedenforced in PlayerJoinListenernone
ReportsReportCommand inserts reportnothingplayerData.Reports
Staff notesStaffNotesCommand mutates playerData.Notesreads in NotesGuiCommandplayerData.Notes
Staff modeStaffModeToggleCommand on either sidelocal inventory save + tool kitplayerData.StaffModeToggled

13.2 Source-of-Truth Rules ​

  • Persistence is always in MySQL.
  • Plugin messages carry promptness, not durability. If a paper node misses a message, the next MySQL read will reconcile state.
  • Velocity is the authority for cross-server state. Paper never rejects a state change that came from velocity; it only applies locally.

13.3 Ban Flow (Full Trace) ​

/ban Steve grief (Velocity)
 ├─ BanCommand resolves Steve → UUID.
 ├─ player looks up active perm ban for Steve; rejects if active.
 ├─ PunishmentDB.insertPermPunishment(Punishment(type=BAN, ...))
 ├─ Discord embed "Punishment" posted.
 ├─ Velocity.getPlayer(steve_uuid).disconnect(reason).
 ├─ Steve's ATVPlayer saved.
 └─ Done.
Steve connects to paper node.
 ├─ PlayerJoinEvent fires.
 ├─ PlayerJoinListener loads playerData (cached after first read).
 ├─ Policy check: NOT done at join for bans — Paper does not enforce bans; the proxy already disconnected Steve.
 └─ Done.

13.4 Mute Flow ​

/mute Steve spam (Velocity)
 ├─ MuteCommand inserts PermPunishment(type=MUTE, ...).
 ├─ Discord embed.
 ├─ Broadcast to all paper nodes via... (no plugin message — each paper checks via DB).
 └─ Paper's PlayerChatListener (next chat) reads Mute state.
Steve types "hello" on paper.
 ├─ AsyncPlayerChatEvent fires.
 ├─ PlayerChatListener reads Mute state from MySQL (cached for 60s).
 ├─ If active → event.setCancelled(true); send mute message.
 └─ Else → broadcast normally.

To avoid hitting MySQL per chat, the cache is 60 seconds (configurable via cache.mute-ttl).

13.5 Friend List Flow ​

/friends add Steve (Velocity)
 ├─ FriendsCommand resolves Steve → UUID.
 ├─ FriendRequestDB.addFriendRequest(sender, receiver) → INSERT IGNORE.
 ├─ Player sends Steve a friend request notification.
 └─ Steve does /friends accept → FriendRequestDB.removeFriendRequest; PlayerDataDB.updateFriends.

Paper has no friend code; friends are a velocity-only abstraction.

13.6 Party Flow ​

/party invite Steve (Velocity, Alice is owner)
 ├─ PartyCommand adds Steve to Alice.party.invites.
 ├─ Plugin message to paper node where Steve is currently → notify Steve.
 └─ Steve does /party accept → adds Steve to Alice.party.members; plugin message back to Alice's paper to refresh.

Party is in-memory only on Velocity. Crash of Velocity loses party state — by design, parties are ephemeral sessions.

13.7 Private Message Flow ​

/msg Steve hi (Velocity, Alice is sender)
 ├─ MessageCommand reads Steve.uuid; checks IgnoredPlayers.
 ├─ Checks Steve.MessagesToggled.
 ├─ Applies RegexFilter.
 ├─ Stores Steve in Alice.data.LastMessaged.
 ├─ Sends Alice: <gray>[to Steve]<white> hi
 ├─ Sends Steve: <gray>[from Alice]<white> hi
 ├─ Plugin message to paper nodes for both to refresh their cached `lastMessaged`.
 └─ If curse: notify staff with Embeds.createCurseEmbed.

13.8 Maintenance Flow ​

/mt on (Velocity)
 ├─ MaintenanceCommand flips maintenance flag (in-memory + MySQL for cross-node visibility).
 ├─ Posts Embeds.createMaintenanceEmbed(true).
 ├─ Plugin message to all paper servers: "appeltools:maintenance true".
 ├─ Each paper server's PluginMessageListener updates its local enforcement.
 ├─ ProxyPingListener overrides MOTD/server-icon/version-name.
 └─ PreLoginListener refuses new connections without bypass permission.
Steve (no bypass) tries to join during maintenance.
 ├─ PreLoginEvent fires on Velocity.
 ├─ PreLoginListener returns ResultedEvent.ComponentResult.denied("<red>Server is in maintenance.").
 └─ Steve is bounced.

13.9 Restart Flow ​

/rs 1h (Velocity)
 ├─ RestartServerCommand parses "1h" → Duration.
 ├─ Schedules broadcast at 60/30/10/1 minutes and 5/4/3/2/1 seconds.
 ├─ Each tick broadcasts a Component message and posts an Embeds.createRestartEmbed.
 ├─ At expiry → proxy.shutdown("Restarting...").
 └─ During countdown, `/rs cancel` aborts.

Paper is unaware; on Velocity restart, paper's plugin-message listener simply disconnects.

13.10 Server Registration ​

/servers register hub 192.168.1.5 25565 (Velocity)
 ├─ ServerDataDB.updateServerData("hub", "192.168.1.5", 25565).
 ├─ ProxyServer.registerServer(ServerInfo("hub", InetSocketAddress("192.168.1.5", 25565))).
 └─ Done.

When a paper node starts, it sends its port via plugin message back to velocity so velocity can register it. Actually: each paper node on first start writes to MySQL servers table; velocity reads the table and registers matching servers.

Paper startup (port=25565, name=hub)
 ├─ Database setup.
 ├─ ServerDataDB.getServerData(25565) → returns ServerData(name=hub, ip=..., port=25565).
 ├─ Plugin message back to velocity: "appeltools:register <hub> <ip> <port>".
 └─ Velocity registers the server in its ProxyServer registry.

13.11 Whitelist (Paper-side Emergency) ​

Admin: /atp whitelist on (Paper)
 ├─ Paper's whitelist setter calls Bukkit.setWhitelist(true).
 └─ PlayerJoinListener checks isWhitelisted() and kicks non-bypass.

The whitelist state lives on the Paper node, not in MySQL. Each Paper node has its own whitelist.

13.12 Reports ​

/report Steve "x-ray" (Velocity)
 ├─ ReportCommand rejects self-report; cooldown.
 ├─ PlayerDataDB.updatePlayerData(reported.appendReport(...)).
 ├─ Broadcasts to staff.
 ├─ Posts Embeds.createReportEmbed to Discord.
 └─ Calls StaffNotificationService.sendReport(report).

Paper has no reports code.

13.13 Staff Notes ​

/notes add Steve "Watched for X-ray" (Velocity)
 ├─ StaffNotesCommand resolves Steve.
 ├─ PlayerDataDB.updatePlayerData(steve.appendNote(note)).
 ├─ Posts Embeds.createWatchlistNoteEmbed.
 └─ Done.

Paper reads notes in NotesGuiCommand via PlayerDataDB.getPlayerData(...).

13.14 Staff Mode ​

/smtoggle (Velocity or Paper)
 ├─ ATVPlayer.staffModeToggled = !value; save.
 ├─ If new value is true:
 │   ├─ Velocity: nothing special (player is already on a paper server).
 │   └─ Paper: PremiumVanish.hide(), setInventory(StaffInventoryManager.giveTools()).
 ├─ If new value is false:
 │   ├─ Paper: restoreInventory(); PremiumVanish.show().
 │   └─ Plugin message back to velocity for the other node.
 └─ Done.

The Staff Inventory persistence (Base64-encoded) is in the staffInventory table keyed by (Server, Uuid). Only Paper writes/reads this.

13.15 Race Conditions ​

  • Two paper nodes try to write the same playerData row: MySQL row-level locks via the auto-commit isolation. If the columns differ, the later write wins. The auto-save ensures the latest state survives.
  • Plugin message lost: all persistent state is in MySQL; the next read refreshes.
  • Two velocity proxies (not supported): not supported. Only one Velocity per network.

13.16 Reliability Matrix ​

ChannelLost OK?Recovered by
appeltools:chatyesno impact — chat is ephemeral
appeltools:commandyesplayer must retry
appeltools:staffmodepartialnext MySQL read after join
appeltools:staffchatyesephemeral
appeltools:maintenancenonext MySQL read (60s cache)
appeltools:freezenonext MySQL read
appeltools:switchyesnext MySQL read

In practice, plugin messages are reliable on a local network; the MySQL fallback is for catastrophic losses (server restart mid-broadcast).

Built with VitePress.