Appearance
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
| Feature | Velocity (source-of-truth) | Paper (consumer) | MySQL row(s) |
|---|---|---|---|
| Ban | inserts row; kicks target | denies login | punishments, tempPunishments, undonePunishments |
| Mute | inserts row; broadcasts | cancels chat events | same as above |
| Kick | inserts row | denies further connects | same as above |
| Friend list | FriendsCommand mutates playerData.Friends | nothing | playerData.Friends |
| Friend request | FriendRequestDB.addFriendRequest | nothing | friendRequests |
| Party | in-memory on velocity | nothing | none |
| Private message | MessageCommand forwards via plugin message | nothing | playerData.LastMessaged |
| Staff chat | broadcasts to staff on velocity and paper | renders local | none |
| Maintenance | MaintenanceCommand toggles | kicks + joins with <kick> | none (state in proxy memory) |
| Restart | RestartServerCommand schedules proxy.shutdown() | shows local countdown | none |
| Whitelist | toggle on paper via setWhitelisted | enforced in PlayerJoinListener | none |
| Reports | ReportCommand inserts report | nothing | playerData.Reports |
| Staff notes | StaffNotesCommand mutates playerData.Notes | reads in NotesGuiCommand | playerData.Notes |
| Staff mode | StaffModeToggleCommand on either side | local inventory save + tool kit | playerData.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
| Channel | Lost OK? | Recovered by |
|---|---|---|
appeltools:chat | yes | no impact — chat is ephemeral |
appeltools:command | yes | player must retry |
appeltools:staffmode | partial | next MySQL read after join |
appeltools:staffchat | yes | ephemeral |
appeltools:maintenance | no | next MySQL read (60s cache) |
appeltools:freeze | no | next MySQL read |
appeltools:switch | yes | next MySQL read |
In practice, plugin messages are reliable on a local network; the MySQL fallback is for catastrophic losses (server restart mid-broadcast).