Merge pull request #171 from Team-Arcadia/1.21.1-dev
merge branch from laforebrut
This commit is contained in:
commit
aabca45812
13
.gitignore
vendored
13
.gitignore
vendored
|
|
@ -35,3 +35,16 @@ runs
|
|||
run-data
|
||||
|
||||
repo
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
|
||||
# BMad
|
||||
.agent/
|
||||
_bmad/
|
||||
_bmad-output/
|
||||
_bmb/
|
||||
|
||||
# compat mods (local jars for analysis)
|
||||
compat-mods/*.jar
|
||||
|
|
|
|||
105
CHANGELOG.md
Normal file
105
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to **PlayerSync** are documented here.
|
||||
|
||||
---
|
||||
|
||||
## [2.1.5] - 2026-04-22 (cont.)
|
||||
|
||||
### Added (Phase 8: configs + admin commands)
|
||||
|
||||
- **Structured config sections** — `connection`, `general`, `save_triggers`, `sync_toggles`, `performance`, `safety`, `observability`. Old keys still accepted thanks to NeoForge's lenient loader.
|
||||
- **Sync toggles** — `sync_inventory`, `sync_ender_chest`, `sync_xp`, `sync_effects`, `sync_health_food`, `sync_curios`, `sync_accessories`, `sync_backpacks`, `sync_cosmetic_armor`, `sync_refined_storage`. All default true. Wired as restore-side guards in each mod-compat path.
|
||||
- **Save triggers** — `save_on_death` (default true), `save_on_respawn` (default true). `save_on_dimension_change` kept from Phase 4.
|
||||
- **Perf configs** — `heartbeat_interval_seconds` (default 30), `peer_stale_threshold_seconds` (default 60), `join_poll_max_attempts` (default 120), `join_poll_interval_ms` (default 500), `pool_stats_interval_minutes` (default 5, 0 to disable), `hikari_pool_max_size` (default 15), `hikari_leak_threshold_ms` (default 25000).
|
||||
- **Safety configs** — `refuse_empty_inventory_write` (default true) now enforced inside `writeSnapshotToDB`: if the snapshot inventory is empty/tiny AND the DB row currently has real data, the write is refused and logged as `DATA_LOSS`. `max_inventory_size_bytes` (default 10 MB) rejects oversized snapshots. `skip_saves_when_tps_below` placeholder for future use. `kick_message`, `kick_grace_period_ms`.
|
||||
- **Observability configs** — `log_structured_json` (future), `log_rotation_size_mb`, `log_rotation_max_files`.
|
||||
- **Admin commands — `/playersync`** — full toolkit for diagnosis and maintenance:
|
||||
- `status` — server id, heartbeat age, executor + Hikari pool snapshot, online count
|
||||
- `poolstats` — immediate log of current pool stats
|
||||
- `flush [player]` — force save of all online players or a specific one
|
||||
- `info <player>` — DB row metadata (last_server, online flag, data sizes)
|
||||
- `dump <player>` — full DB row dump into server log
|
||||
- `resync <player>` — clear player_synced tag and kick to force fresh restore
|
||||
- `wipe <player> confirm` — DANGER: DELETE all rows for a player
|
||||
- `orphans` — list online=1 rows whose peer is dead/stale
|
||||
- `clearorphans [server_id]` — clear orphaned online flags
|
||||
- `peers` — list all peer servers with their heartbeat age and ALIVE/STALE/STOPPED tag
|
||||
- `peerkill <server_id>` — force-disable a zombie peer
|
||||
- `cleanup` — one-shot orphans + stale peers cleanup
|
||||
- `reload` — status note about runtime config reload
|
||||
- `help` — in-chat command reference
|
||||
- All commands require permission level 2 (op) and log to `SyncLogger` as `ADMIN_*` events for audit trail.
|
||||
|
||||
### Changed
|
||||
|
||||
- `JDBCsetUp.executePreparedUpdate` now delegates to `executePreparedUpdateRet` which returns rows affected. Existing callers unchanged; admin commands use the ret version for meaningful counts.
|
||||
- `HeartbeatService` + `PoolStatsReporter` + `doPlayerJoin` poll all read their interval/threshold from the new config keys instead of hardcoded constants.
|
||||
|
||||
### Ajouts (French mirror — Phase 8)
|
||||
|
||||
- **Sections config structurées** — `connection`, `general`, `save_triggers`, `sync_toggles`, `performance`, `safety`, `observability`.
|
||||
- **Toggles de sync** — 10 clés pour activer/désactiver la sync par catégorie.
|
||||
- **Triggers de sauvegarde** — `save_on_death`, `save_on_respawn`, `save_on_dimension_change`.
|
||||
- **Configs perf** — intervalles heartbeat/poll/pool-stats/hikari, seuils peer-stale.
|
||||
- **Configs sécurité** — `refuse_empty_inventory_write` (enforce-wipe protection), `max_inventory_size_bytes` (anti-bloat), `kick_message`, `kick_grace_period_ms`.
|
||||
- **Commandes admin `/playersync`** — 14 commandes pour diagnostic et maintenance (status, flush, info, dump, resync, wipe, orphans, clearorphans, peers, peerkill, cleanup, poolstats, reload, help).
|
||||
- Toutes les commandes requièrent permission op (niveau 2) et logguent dans `SyncLogger` pour traçabilité.
|
||||
|
||||
---
|
||||
|
||||
## [2.1.5] - 2026-04-22
|
||||
|
||||
### Fixed (English first)
|
||||
|
||||
- **Critical item duplication on drop + quick disconnect + reconnect** — Race condition between the auto-save background task and the logout background task could commit a stale snapshot AFTER the logout save, resurrecting dropped items. Triple guard now applied: `pendingLogoutSaves` check (early + under lock) and `SELECT online FROM player_data` skip if logout already committed. Logout BG now acquires `bgLock` with blocking `.lock()` for proper serialization.
|
||||
- **Backpack / Sophisticated Storage merge-on-restore duplication** — `setBackpackContents` / `setStorageContents` upstream are shallow merges, not replaces. Restore now calls `removeBackpackContents` / `removeStorageContents` (with reflection fallback if absent) AND passes a defensive NBT copy. Fixes mass-duplication of items in backpacks/shulkers on every cross-server transfer.
|
||||
- **Cross-server save overwrite** — When `writeSnapshotToDB`'s `last_server` guard blocked the core player_data UPDATE, the downstream backpack/SS/RS2 saves still executed and overwrote the claiming server's data. The function now returns a boolean; all 5 callers short-circuit downstream writes on guard block.
|
||||
- **30-second join delay on zombie peer servers** — `doPlayerJoin` poll waited the full 60 attempts (30s) for server_ids that no longer existed (legacy `server_id=0` rows, or peers that crashed without clearing `online=0`). New `isPeerServerStale` check (peer_id=0 OR heartbeat >60s) takes over immediately and force-clears the orphaned flag. Poll max raised from 60 to 120 attempts (60s) for legitimate slow shutdowns.
|
||||
- **Curios wipe on dead player** — Legacy `StoreCurios` wrote an empty flatMap when the Curios capability was unavailable, wiping DB data. Now early-returns with a WARN log.
|
||||
|
||||
### Added
|
||||
|
||||
- **JVM shutdown hook (kill -9 / OOM / SIGTERM recovery)** — New `CrashRecovery.installShutdownHook` registers a non-daemon hook that calls `VanillaSync.emergencyFlushAll` synchronously to snapshot and write every online player before process exit. Marks `server_info.enable=0` so peers detect the shutdown.
|
||||
- **Startup orphan-flag recovery** — `CrashRecovery.clearOrphanedOnlineFlags` runs at `onServerStarting` to clear any `player_data.online=1` rows left by a previous ungraceful exit. Logs the count via `SyncLogger`.
|
||||
- **Zombie-peer reporter** — `CrashRecovery.reportZombiePeers` logs peer `server_id`s whose heartbeat is stale or missing at boot time.
|
||||
- **Server heartbeat service** — `HeartbeatService` pings `server_info.last_update` every 10 seconds so peer servers can distinguish live from dead via the new `isPeerServerStale` check.
|
||||
- **Periodic full-save scheduler** — `PeriodicSaveService` triggers a complete save (player data + backpacks + SS + RS2) for every online synced player every `auto_save_interval_minutes` (new config, default 10, range 0-1440). Independent of NeoForge's vanilla `PlayerEvent.SaveToFile` cadence.
|
||||
- **Dimension-change save trigger** — New `onPlayerChangeDimension` handler, gated by `save_on_dimension_change` config (default false). Protects against mid-teleport crashes.
|
||||
- **Executor + HikariCP pool stats reporter** — `PoolStatsReporter` logs `[POOL] executor active/queue/idle, hikari active/idle` every 5 minutes. WARN thresholds trigger when queue >400/512 or Hikari active >=14/15.
|
||||
- **Structured logging events** — `SyncLogger` gained `containerForceClosed`, `modCompatSkip`, `modCompatSaved`, `modCompatRestored`, `storageSave`, `poolStats`, `warnPlayer`, `nbtAnomaly` for finer-grained diagnostics.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`writeSnapshotToDB` signature** — Now returns `boolean` instead of `void`. `true` means the core UPDATE persisted, `false` means the `last_server` guard blocked. All callers MUST check the return before firing downstream backpack/SS/RS2 writes.
|
||||
- **Default `auto_save_interval_minutes`** — 10 min (new config key). Trades data-loss window on crash for DB load. Set to 0 to disable.
|
||||
- **Backpack / SS restore** — Now uses two-step clear (public API + reflection fallback) and defensive NBT copy before upstream setter. Full log line per restore with `cleared_via=api|reflection` and `nbt_keys=N`.
|
||||
|
||||
---
|
||||
|
||||
### Correctifs (French mirror)
|
||||
|
||||
- **Duplication d'items critique lors d'un drop + déconnexion rapide + reconnexion** — Race condition entre la task auto-save background et la task logout background pouvait commiter un snapshot périmé APRÈS le save logout, ressuscitant les items drop. Triple garde maintenant appliquée : check `pendingLogoutSaves` (early + sous lock) et skip via `SELECT online FROM player_data` si le logout a déjà commité. La task logout BG acquiert maintenant `bgLock` en blocking `.lock()` pour sérialiser proprement.
|
||||
- **Duplication Backpack / Sophisticated Storage par merge au restore** — `setBackpackContents` / `setStorageContents` en amont sont des merges shallow, pas des replaces. Le restore appelle maintenant `removeBackpackContents` / `removeStorageContents` (avec fallback reflection si absent) ET passe une copie défensive du NBT. Corrige la duplication massive d'items dans les backpacks/shulkers à chaque transfert cross-server.
|
||||
- **Écrasement cross-server des saves** — Quand le guard `last_server` de `writeSnapshotToDB` bloquait l'UPDATE core player_data, les saves downstream backpack/SS/RS2 s'exécutaient quand même et écrasaient les données du serveur ayant claim. La fonction retourne maintenant un boolean ; les 5 callers court-circuitent les writes downstream en cas de guard block.
|
||||
- **Délai de 30 secondes à la connexion sur serveurs zombies** — Le poll `doPlayerJoin` attendait les 60 tentatives (30s) pour des `server_id` n'existant plus (lignes legacy `server_id=0`, ou peers ayant crashé sans clear `online=0`). Nouveau check `isPeerServerStale` (peer_id=0 OU heartbeat >60s) prend la main immédiatement et force-clear le flag orphelin. Poll max passé de 60 à 120 tentatives (60s) pour couvrir les shutdowns lents légitimes.
|
||||
- **Wipe Curios sur joueur mort** — La méthode legacy `StoreCurios` écrivait un flatMap vide quand la capability Curios était absente, wipant les données DB. Elle early-return maintenant avec un log WARN.
|
||||
|
||||
### Ajouts (French mirror)
|
||||
|
||||
- **Hook JVM shutdown (kill -9 / OOM / SIGTERM recovery)** — Nouveau `CrashRecovery.installShutdownHook` enregistre un hook non-daemon qui appelle `VanillaSync.emergencyFlushAll` synchronement pour snapshot et écrire chaque joueur online avant la fin du process. Marque `server_info.enable=0` pour que les peers détectent le shutdown.
|
||||
- **Recovery des flags orphelins au boot** — `CrashRecovery.clearOrphanedOnlineFlags` tourne au `onServerStarting` pour clear les rows `player_data.online=1` laissées par une sortie ungracieuse précédente. Log le compte via `SyncLogger`.
|
||||
- **Reporter de peers zombies** — `CrashRecovery.reportZombiePeers` log les `server_id` peers dont le heartbeat est stale ou absent au boot.
|
||||
- **Service heartbeat** — `HeartbeatService` ping `server_info.last_update` toutes les 10 secondes pour que les peers distinguent live vs dead via le nouveau check `isPeerServerStale`.
|
||||
- **Scheduler de sauvegarde périodique** — `PeriodicSaveService` déclenche une save complète (player data + backpacks + SS + RS2) pour chaque joueur online synced toutes les `auto_save_interval_minutes` (nouvelle config, défaut 10, plage 0-1440). Indépendant de la cadence vanilla `PlayerEvent.SaveToFile` de NeoForge.
|
||||
- **Trigger save sur changement de dimension** — Nouveau handler `onPlayerChangeDimension`, gated par la config `save_on_dimension_change` (défaut false). Protège contre les crashes en plein téléport.
|
||||
- **Reporter stats executor + HikariCP** — `PoolStatsReporter` log `[POOL] executor active/queue/idle, hikari active/idle` toutes les 5 min. Seuils WARN quand queue >400/512 ou Hikari active >=14/15.
|
||||
- **Événements structurés** — `SyncLogger` a gagné `containerForceClosed`, `modCompatSkip`, `modCompatSaved`, `modCompatRestored`, `storageSave`, `poolStats`, `warnPlayer`, `nbtAnomaly` pour un diagnostic plus fin.
|
||||
|
||||
### Modifications
|
||||
|
||||
- **Signature `writeSnapshotToDB`** — Retourne maintenant `boolean` au lieu de `void`. `true` = l'UPDATE core a persisté, `false` = le guard `last_server` a bloqué. Tous les callers DOIVENT vérifier le retour avant de déclencher les writes downstream backpack/SS/RS2.
|
||||
- **Défaut `auto_save_interval_minutes`** — 10 min (nouvelle clé config). Trade-off entre fenêtre de perte de données sur crash et charge DB. 0 pour désactiver.
|
||||
- **Restore Backpack / SS** — Utilise maintenant un clear en deux étapes (API publique + fallback reflection) et une copie défensive NBT avant le setter upstream. Log complet par restore avec `cleared_via=api|reflection` et `nbt_keys=N`.
|
||||
|
||||
---
|
||||
141
ERROR_LOG.md
Normal file
141
ERROR_LOG.md
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
# PlayerSync — Error Log
|
||||
|
||||
Journal des erreurs rencontrées et corrigées. Chaque entrée documente un bug, sa cause racine, son correctif et la règle de prévention à appliquer systématiquement.
|
||||
|
||||
---
|
||||
|
||||
## [2026-04-22 02:54] — Item duplication on drop + quick disconnect + reconnect
|
||||
|
||||
**Context** : Un joueur drop un item au sol, se déconnecte très rapidement, puis se reconnecte → l'item est présent deux fois (en inventory restauré + encore au sol).
|
||||
|
||||
**Error** : Duplication systématique reproductible en production.
|
||||
|
||||
**Root cause** : Race condition entre `onPlayerSaveToFile` background task (auto-save périodique) et `onPlayerLogout` background task.
|
||||
1. `SaveToFile` capture un snapshot sur main thread AVANT le drop (item encore en inventory) → task async soumise.
|
||||
2. Le joueur drop l'item → inventory vide, ItemEntity dans le monde.
|
||||
3. Le joueur disconnect → logout capture un snapshot FRESH (sans item), soumet le write.
|
||||
4. Les deux BG tasks s'exécutent en parallèle. Si la task auto-save (qui portait une snapshot STALE avec l'item) commit APRÈS la task logout (qui portait FRESH sans l'item), la DB finit en STALE.
|
||||
5. Reconnexion → inventory restauré avec l'item + ItemEntity toujours au sol → 2 copies.
|
||||
|
||||
**Fix** (commit `bea5f80`) : Triple guard dans l'auto-save BG task :
|
||||
- Early skip si `pendingLogoutSaves.containsKey(uuid)` avant tryLock.
|
||||
- Re-check sous lock après tryLock (race window fermée).
|
||||
- `SELECT online FROM player_data WHERE uuid=?` — skip si online=0 (logout a committé).
|
||||
|
||||
Logout BG task acquiert maintenant `bgLock.lock()` (blocking) pour sérialiser proprement avec les auto-save BG qui utilisent `tryLock`. `removePlayerLock` réordonné avant `bgLock.unlock()` pour que les auto-save BG qui wake après unlock voient `containsKey=false` et skip.
|
||||
|
||||
**Prevention** : **JAMAIS de BG task qui modifie la DB sans un guard `online=0` + `pendingLogoutSaves` check**. Si deux paths peuvent écrire le même row, ils DOIVENT partager un lock blocking OU le path "fresh" doit être détectable via DB state (online flag, version column).
|
||||
|
||||
---
|
||||
|
||||
## [2026-04-22 03:15] — Backpack duplication on cross-server transfer
|
||||
|
||||
**Context** : Un joueur utilise un backpack Sophisticated Backpacks sur Server A, change de serveur, et constate que le contenu du backpack est dupliqué.
|
||||
|
||||
**Error** : Duplication systématique d'items dans les backpacks et shulkers Sophisticated Storage lors de transferts cross-server ou reconnexions.
|
||||
|
||||
**Root cause** : `BackpackStorage.setBackpackContents()` et `ItemContentsStorage.setStorageContents()` en amont sont des **merges shallow**, pas des replaces. Quand le restore applique le snapshot sauvegardé, il MERGE avec les contents existants en mémoire (SavedData persistée sur disk localement ou vue ouverte par un autre joueur). Les sous-tags "items" survivent → duplication.
|
||||
|
||||
**Fix** (commit `c84f920`) :
|
||||
- Backpack : appel `store.removeBackpackContents(uuid)` EXPLICITE avant `setBackpackContents`. Si l'API throw (absent dans certaines versions), fallback reflection qui parcourt les champs `Map` de `BackpackStorage` et remove l'entrée directement.
|
||||
- SS : nouveau helper `clearSSStorageContents` qui tente `removeStorageContents(UUID)` via reflection, puis fallback reflection sur champs Map. `setDirty()` appelé pour forcer le flush.
|
||||
- Les deux paths passent maintenant une **copie défensive** (`nbt.copy()`) à l'upstream setter, jamais la référence partagée.
|
||||
|
||||
**Prevention** :
|
||||
- **Toujours clear avant restore pour toute structure qui merge au lieu de replace** (backpack, SS, RS2 disks).
|
||||
- **Toujours passer une copie défensive** d'un CompoundTag à un setter qui peut la stocker en interne.
|
||||
- **Logger `clear_via=api/reflection`** pour diagnostiquer les régressions upstream.
|
||||
|
||||
---
|
||||
|
||||
## [2026-04-22 03:20] — Cross-server saves can overwrite claimed data
|
||||
|
||||
**Context** : Deux serveurs sauvent un même joueur simultanément (edge case lors de changements de serveurs rapides).
|
||||
|
||||
**Error** : Les données de l'un écrasent silencieusement les données de l'autre. Backpack/SS/RS2 perdus.
|
||||
|
||||
**Root cause** : `writeSnapshotToDB` retournait `void`. Même si son guard `last_server=?` bloquait le write du core player_data (rows affected = 0), les appels downstream `saveBackpackSnapshots` / `saveSSSnapshots` / `saveRS2DisksByLevel` s'exécutaient INCONDITIONNELLEMENT et écrasaient `backpack_data` (qui n'a pas de guard propre — keyé par storage UUID, pas player UUID).
|
||||
|
||||
**Fix** (commit `c84f920`) : `writeSnapshotToDB` retourne maintenant `boolean`. Les 5 callers (logout, shutdown, auto-save SaveToFile, staggered auto-save, death-save) vérifient le retour et **short-circuitent** les writes downstream si le core a été blocké.
|
||||
|
||||
**Prevention** : **Une fonction qui a un guard silencieux DOIT signaler son résultat au caller**. Ne jamais supposer que les writes downstream sont implicitement protégés par un guard en amont — vérifier explicitement.
|
||||
|
||||
---
|
||||
|
||||
## [2026-04-22 03:25] — 30s delay on player join (RACE timeout 60/60)
|
||||
|
||||
**Context** : À chaque connexion, log flood `Waiting for server X to finish saving (attempt 60/60)` et le joueur attend 30s avant de récupérer ses données.
|
||||
|
||||
**Error** : Poll timeout systématique sur des server_ids qui n'existent plus ou sur un server_id=0.
|
||||
|
||||
**Root cause** :
|
||||
- Le poll `doPlayerJoin` attend que l'autre serveur clear `online=0`. Si l'autre serveur a crashé sans le faire (pas de shutdown hook), le poll attend jusqu'à épuisement des 60 tentatives.
|
||||
- `server_id=0` est une ligne orpheline héritée d'une écriture legacy (avant que le default `Random().nextInt(1, MAX-1)` soit appliqué).
|
||||
|
||||
**Fix** (commit `c84f920`) :
|
||||
- Nouvelle méthode `isPeerServerStale(peerId, staleMs)` qui check `server_info.last_update`. Si l'heartbeat est vieux de >60s OU si `peerId == 0`, le poll considère le serveur comme zombie et force-clear `online=0`.
|
||||
- Poll max passé de 60 à 120 tentatives (60s total) pour couvrir les shutdowns lents.
|
||||
- Phase 3 : `HeartbeatService` tick toutes les 10s → permet aux peers de détecter les zombies.
|
||||
- Phase 3 : `CrashRecovery.clearOrphanedOnlineFlags()` au boot → nettoie les rows stuck à online=1 après un crash ungracieux.
|
||||
|
||||
**Prevention** : **Tout état "en cours" en DB doit avoir un heartbeat OU un timeout**. Un flag `online=1` sans heartbeat est un bug en attendant de se produire (le process qui l'a set peut crasher).
|
||||
|
||||
---
|
||||
|
||||
## [2026-04-22 03:30] — StoreCurios NPE / data wipe on dead player
|
||||
|
||||
**Context** : Un joueur meurt puis se déconnecte rapidement. Son curios sont vidés de la DB.
|
||||
|
||||
**Error** : Méthode legacy `StoreCurios` écrivait un flatMap vide quand `CuriosApi.getCuriosInventory(player)` retournait un `Optional.empty()` (capability détachée après death).
|
||||
|
||||
**Root cause** : La méthode utilisait `handlerOpt.ifPresent(...)` mais fallait au `REPLACE INTO` même si le flatMap était vide → wipe DB data pour un joueur mort.
|
||||
|
||||
**Fix** (commit `c84f920`) : Early return avec log `WARN [store-curios] handler unavailable for UUID — skipping write to avoid wiping DB data` si `handlerOpt.isEmpty()`.
|
||||
|
||||
**Prevention** : **Ne JAMAIS écrire un état "vide" dans la DB si la source est incertaine**. Une capability absente ≠ joueur sans curios — c'est un état indéterminé. Skip write + log.
|
||||
|
||||
---
|
||||
|
||||
## [2026-04-22 03:40] — Player data loss on kill -9 / OOM
|
||||
|
||||
**Context** : Process serveur tué via `kill -9` ou OOM — au redémarrage, les joueurs qui étaient online ne récupèrent pas leurs données des dernières minutes.
|
||||
|
||||
**Error** : `ServerStoppingEvent` n'est pas déclenché lors d'un kill ungracieux, donc aucune save n'est exécutée. Les rows `player_data` restent aussi à `online=1` → le poll de doPlayerJoin sur un autre serveur attend 30s pour rien.
|
||||
|
||||
**Fix** (commit `746cb56`, Phase 3) :
|
||||
- `CrashRecovery.installShutdownHook(() -> emergencyFlushAll())` — JVM hook non-daemon enregistré au boot. Appelle une méthode synchrone qui snapshot et write tous les joueurs online sans passer par l'executor (qui peut être déjà mort).
|
||||
- Marque `server_info.enable=0` à la sortie pour notifier les peers.
|
||||
- `CrashRecovery.clearOrphanedOnlineFlags()` au boot suivant — clear les rows stuck et log le nombre via SyncLogger.
|
||||
- `HeartbeatService` tick toutes les 10s pendant le run — permet aux peers de détecter la mort.
|
||||
|
||||
**Prevention** :
|
||||
- **Tout process long-running doit avoir un JVM shutdown hook** pour couvrir SIGTERM / kill doux / OOM soft.
|
||||
- **Tout flag "en cours" persistant doit avoir un recovery path au boot suivant**.
|
||||
- **Un heartbeat périodique est obligatoire** si d'autres processus dépendent de savoir si on est alive.
|
||||
|
||||
---
|
||||
|
||||
## [2026-04-22 03:50] — Inventory loss window of 30 min between auto-saves
|
||||
|
||||
**Context** : Les auto-saves ne se déclenchaient que lors des PlayerEvent.SaveToFile natifs (cadence vanilla = autosave world, typiquement 6000 ticks). Si un crash survenait entre deux saves, jusqu'à 15+ minutes de jeu étaient perdus.
|
||||
|
||||
**Fix** (commit `c70ca9f`, Phase 4) :
|
||||
- `PeriodicSaveService` — scheduler indépendant qui déclenche un full-flush toutes les `auto_save_interval_minutes` (défaut 10). Hops au main thread pour snapshotter, puis soumet les writes async via `snapshotAndQueueSave`.
|
||||
- `onPlayerChangeDimension` — trigger additionnel gated par `save_on_dimension_change` (défaut false). Sauve avant teleport cross-dimension.
|
||||
|
||||
**Prevention** : **Ne jamais dépendre uniquement des events du framework** pour déclencher une sauvegarde critique. Doubler avec un scheduler indépendant et rendre l'intervalle configurable.
|
||||
|
||||
---
|
||||
|
||||
## [2026-04-22 04:00] — Executor queue saturation invisible
|
||||
|
||||
**Context** : Sous charge (35+ joueurs), l'executor `PlayerSync` peut saturer (queue >400) et déclencher `CallerRunsPolicy` qui bloque le main thread. Aucune alerte dans les logs.
|
||||
|
||||
**Fix** (commit `bd0482c`, Phase 5) :
|
||||
- `PoolStatsReporter` — scheduler dédié 5-min qui log `[POOL] executor active/queue/idle, hikari active/idle`.
|
||||
- WARN log si queue > 400/512 ou hikari active >= 14/15.
|
||||
- Accesseur `JDBCsetUp.getPoolMXBean()` pour exposer Hikari en read-only.
|
||||
|
||||
**Prevention** : **Tout pool/queue critique doit être monitoré périodiquement** avec des seuils d'alerte sous la capacité max. Invisible ≠ sain.
|
||||
|
||||
---
|
||||
523
TEST_PROCEDURE_v2.1.5.html
Normal file
523
TEST_PROCEDURE_v2.1.5.html
Normal file
|
|
@ -0,0 +1,523 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Test Procedure — PlayerSync v2.1.5</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; max-width: 1100px; margin: 2em auto; padding: 0 1em; color: #222; line-height: 1.55; }
|
||||
h1 { border-bottom: 3px solid #007acc; padding-bottom: 0.3em; }
|
||||
h2 { color: #007acc; margin-top: 1.8em; border-bottom: 1px solid #ddd; padding-bottom: 0.2em; }
|
||||
h3 { color: #333; margin-top: 1.3em; }
|
||||
.scenario { background: #f6f8fa; border-left: 4px solid #007acc; padding: 1em 1.2em; margin: 1em 0; border-radius: 4px; }
|
||||
.scenario.critical { border-left-color: #d73a49; }
|
||||
.scenario.high { border-left-color: #f0983a; }
|
||||
.meta { font-size: 0.9em; color: #666; margin-bottom: 0.5em; }
|
||||
.steps, .expected { margin: 0.5em 0; }
|
||||
.steps li, .expected li { margin: 0.25em 0; }
|
||||
code { background: #eef1f4; padding: 0.1em 0.4em; border-radius: 3px; font-size: 0.92em; }
|
||||
.lang-sep { margin: 4em 0 2em; border-top: 2px dashed #999; padding-top: 2em; }
|
||||
.checkbox { margin-right: 0.5em; }
|
||||
.tag { display: inline-block; padding: 0.1em 0.5em; border-radius: 3px; font-size: 0.8em; font-weight: bold; color: white; }
|
||||
.tag-critical { background: #d73a49; }
|
||||
.tag-high { background: #f0983a; }
|
||||
.tag-medium { background: #6f42c1; }
|
||||
.regression { background: #fff5f5; border: 1px solid #feb2b2; padding: 1em; border-radius: 4px; margin-top: 2em; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 1em 0; }
|
||||
th, td { border: 1px solid #ddd; padding: 0.5em 0.8em; text-align: left; }
|
||||
th { background: #007acc; color: white; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>Test Procedure — PlayerSync v2.1.5</h1>
|
||||
<div class="meta">Date : <strong>2026-04-22</strong> | Branch: <code>1.21.1-dev</code> | Minecraft 1.21.1 / NeoForge 21.1.137 / Java 21</div>
|
||||
|
||||
<h2>Setup</h2>
|
||||
|
||||
<ol>
|
||||
<li>Démarrer MariaDB dev : <code>docker compose up -d</code></li>
|
||||
<li>Build : <code>./gradlew build</code> — le JAR apparaît dans <code>build/libs/playersync-1.21.1-2.1.5.jar</code></li>
|
||||
<li>Deux instances serveur nécessaires : <code>./gradlew runServer</code> (Server A) + copie avec <code>Server_id</code> différent dans <code>run-2/config/playersync-common.toml</code> (Server B)</li>
|
||||
<li>Adminer : <code>http://localhost:8080</code> (login <code>playersync</code>/<code>playersync</code>)</li>
|
||||
<li>Monitorer en continu : <code>tail -f run/logs/playersync/sync.log</code></li>
|
||||
</ol>
|
||||
|
||||
<h2>Scenarios to test</h2>
|
||||
|
||||
<div class="scenario critical">
|
||||
<span class="tag tag-critical">CRITICAL</span>
|
||||
<h3>1. Drop + deco rapide + reco (regression Phase 0)</h3>
|
||||
<div class="steps">
|
||||
<strong>Steps:</strong>
|
||||
<ol>
|
||||
<li><input type="checkbox" class="checkbox"> Join Server A, fill inventory with a diamond sword</li>
|
||||
<li><input type="checkbox" class="checkbox"> Drop the sword with Q</li>
|
||||
<li><input type="checkbox" class="checkbox"> Immediately disconnect (within 1 second)</li>
|
||||
<li><input type="checkbox" class="checkbox"> Rejoin Server A</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="expected">
|
||||
<strong>Expected:</strong>
|
||||
<ul>
|
||||
<li>Inventory does NOT contain the sword</li>
|
||||
<li>The ItemEntity is still on the ground where dropped</li>
|
||||
<li>Player has exactly 1 copy of the sword total</li>
|
||||
<li>Log shows <code>[SAVE] LOGOUT completed</code> then either no SaveToFile BG write or a <code>[GUARD] SaveToFile BG skipped — player already offline in DB</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scenario critical">
|
||||
<span class="tag tag-critical">CRITICAL</span>
|
||||
<h3>2. Backpack duplication (Sophisticated Backpacks)</h3>
|
||||
<div class="steps">
|
||||
<strong>Steps:</strong>
|
||||
<ol>
|
||||
<li><input type="checkbox" class="checkbox"> Join Server A, craft a SophisticatedBackpack, fill with 10 diamond blocks</li>
|
||||
<li><input type="checkbox" class="checkbox"> Disconnect</li>
|
||||
<li><input type="checkbox" class="checkbox"> Join Server B (configure different Server_id)</li>
|
||||
<li><input type="checkbox" class="checkbox"> Open backpack, count diamond blocks</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="expected">
|
||||
<strong>Expected:</strong>
|
||||
<ul>
|
||||
<li>Exactly 10 diamond blocks (no duplication)</li>
|
||||
<li>Log shows <code>[restore-backpack] uuid=... nbt_keys=... cleared_via=api</code> (or <code>reflection</code> as fallback)</li>
|
||||
<li>No WARN about failed removeBackpackContents</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scenario critical">
|
||||
<span class="tag tag-critical">CRITICAL</span>
|
||||
<h3>3. Sophisticated Storage shulker duplication</h3>
|
||||
<div class="steps">
|
||||
<strong>Steps:</strong>
|
||||
<ol>
|
||||
<li><input type="checkbox" class="checkbox"> Join Server A, pack a diamond-filled shulker into your inventory</li>
|
||||
<li><input type="checkbox" class="checkbox"> Have Player B (on same server) open your inventory via admin / trade / viewer</li>
|
||||
<li><input type="checkbox" class="checkbox"> Disconnect Player A</li>
|
||||
<li><input type="checkbox" class="checkbox"> Player A reconnects to Server B</li>
|
||||
<li><input type="checkbox" class="checkbox"> Unpack shulker, count diamonds</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="expected">
|
||||
<strong>Expected:</strong>
|
||||
<ul>
|
||||
<li>Exactly original diamond count</li>
|
||||
<li>Log shows <code>[CONTAINER_CLOSE]</code> for Player B (viewer forced closed)</li>
|
||||
<li>Log shows <code>[restore-ss] uuid=... nbt_keys=...</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scenario critical">
|
||||
<span class="tag tag-critical">CRITICAL</span>
|
||||
<h3>4. Kill -9 / OOM recovery</h3>
|
||||
<div class="steps">
|
||||
<strong>Steps:</strong>
|
||||
<ol>
|
||||
<li><input type="checkbox" class="checkbox"> Join Server A, set inventory to known state (put a named diamond)</li>
|
||||
<li><input type="checkbox" class="checkbox"> Find server java PID : <code>jps | grep Forge</code></li>
|
||||
<li><input type="checkbox" class="checkbox"> Kill brutally : <code>kill -9 <pid></code> (or Task Manager → End Task on Windows)</li>
|
||||
<li><input type="checkbox" class="checkbox"> Restart server A</li>
|
||||
<li><input type="checkbox" class="checkbox"> Join Server A, check inventory</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="expected">
|
||||
<strong>Expected:</strong>
|
||||
<ul>
|
||||
<li>On startup log: <code>[crash-recovery] cleared N orphan online=1 rows</code></li>
|
||||
<li>On startup log: <code>[crash-recovery] JVM shutdown hook installed</code> AND ideally (if hook ran): <code>[emergency-flush] flushed N players</code></li>
|
||||
<li>Inventory matches last state before kill (within ~10 min auto-save window)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scenario high">
|
||||
<span class="tag tag-high">HIGH</span>
|
||||
<h3>5. Zombie peer server join (no 30s wait)</h3>
|
||||
<div class="steps">
|
||||
<strong>Steps:</strong>
|
||||
<ol>
|
||||
<li><input type="checkbox" class="checkbox"> In Adminer, manually set <code>player_data.last_server=99999</code> and <code>online=1</code> for a test UUID</li>
|
||||
<li><input type="checkbox" class="checkbox"> Join any running server with that UUID</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="expected">
|
||||
<strong>Expected:</strong>
|
||||
<ul>
|
||||
<li>Join happens within a few seconds (not 30s)</li>
|
||||
<li>Log shows <code>[RACE] Peer server 99999 is dead/zombie — taking over</code></li>
|
||||
<li>DB now shows <code>last_server=<thisServer></code>, <code>online=1</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scenario high">
|
||||
<span class="tag tag-high">HIGH</span>
|
||||
<h3>6. Periodic auto-save (10 min)</h3>
|
||||
<div class="steps">
|
||||
<strong>Steps:</strong>
|
||||
<ol>
|
||||
<li><input type="checkbox" class="checkbox"> Set <code>auto_save_interval_minutes=1</code> in config for quick test</li>
|
||||
<li><input type="checkbox" class="checkbox"> Join server, add items to inventory</li>
|
||||
<li><input type="checkbox" class="checkbox"> Wait 1 minute (watch sync.log)</li>
|
||||
<li><input type="checkbox" class="checkbox"> Kill -9 server</li>
|
||||
<li><input type="checkbox" class="checkbox"> Restart, rejoin, check inventory</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="expected">
|
||||
<strong>Expected:</strong>
|
||||
<ul>
|
||||
<li>Log shows <code>[periodic-save] queued snapshots for N player(s)</code> after 1 min</li>
|
||||
<li>Post-crash inventory reflects the state AT the last periodic tick</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scenario high">
|
||||
<span class="tag tag-high">HIGH</span>
|
||||
<h3>7. Pool saturation WARN log</h3>
|
||||
<div class="steps">
|
||||
<strong>Steps:</strong>
|
||||
<ol>
|
||||
<li><input type="checkbox" class="checkbox"> Wait 5 minutes after server start (for first PoolStatsReporter tick)</li>
|
||||
<li><input type="checkbox" class="checkbox"> Grep sync.log for <code>[POOL]</code></li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="expected">
|
||||
<strong>Expected:</strong>
|
||||
<ul>
|
||||
<li>At least one line like <code>[POOL] executor active=0 queue=0 pool_idle=4 | hikari active=0 idle=4</code></li>
|
||||
<li>No WARN unless under load</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scenario high">
|
||||
<span class="tag tag-high">HIGH</span>
|
||||
<h3>8. Heartbeat updates server_info</h3>
|
||||
<div class="steps">
|
||||
<strong>Steps:</strong>
|
||||
<ol>
|
||||
<li><input type="checkbox" class="checkbox"> In Adminer, watch <code>server_info.last_update</code> for this server's id</li>
|
||||
<li><input type="checkbox" class="checkbox"> Refresh every 20s for 1 minute</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="expected">
|
||||
<strong>Expected:</strong>
|
||||
<ul>
|
||||
<li><code>last_update</code> advances by ~10000 ms at every refresh</li>
|
||||
<li>Log shows <code>[heartbeat] started</code> on boot</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scenario">
|
||||
<span class="tag tag-medium">MEDIUM</span>
|
||||
<h3>9. Curios capability unavailable — no wipe</h3>
|
||||
<div class="steps">
|
||||
<strong>Steps:</strong>
|
||||
<ol>
|
||||
<li><input type="checkbox" class="checkbox"> Equip curios items, die in lava</li>
|
||||
<li><input type="checkbox" class="checkbox"> Force-disconnect during death animation</li>
|
||||
<li><input type="checkbox" class="checkbox"> Reconnect</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="expected">
|
||||
<strong>Expected:</strong>
|
||||
<ul>
|
||||
<li>If cap was unavailable: log shows <code>[store-curios] handler unavailable for ... skipping write</code></li>
|
||||
<li>Curios row in DB NOT wiped</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scenario">
|
||||
<span class="tag tag-medium">MEDIUM</span>
|
||||
<h3>10. Cross-server claim + downstream short-circuit</h3>
|
||||
<div class="steps">
|
||||
<strong>Steps:</strong>
|
||||
<ol>
|
||||
<li><input type="checkbox" class="checkbox"> Player connected on Server A</li>
|
||||
<li><input type="checkbox" class="checkbox"> Disconnect then immediately join Server B (within 200ms)</li>
|
||||
<li><input type="checkbox" class="checkbox"> Check sync.log on Server A</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="expected">
|
||||
<strong>Expected:</strong>
|
||||
<ul>
|
||||
<li>Server A may log <code>[GUARD]</code> (last_server guard blocked) if B claimed during A's save</li>
|
||||
<li>If blocked: <code>[SAVE_SKIP] LOGOUT skipped: core guard blocked</code></li>
|
||||
<li>Player inventory on B = inventory as it was on A (no merge, no overwrite)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Regression checks</h2>
|
||||
|
||||
<div class="regression">
|
||||
<strong>Watch for these regressions after Phase 0-5 deployment:</strong>
|
||||
<ul>
|
||||
<li>TPS drop during auto-save ticks (periodic save at 10 min should be invisible to gameplay)</li>
|
||||
<li>HikariCP leak warnings — <code>leakDetectionThreshold=25000</code>, warnings mean a connection held >25s</li>
|
||||
<li>CallerRunsPolicy triggering (queue full) — look for WARN <code>[pool-stats] executor queue high</code></li>
|
||||
<li>Deadlock on logout → join (bgLock serialization) — log should show <code>[SAVE] LOGOUT completed</code> within ~500ms</li>
|
||||
<li>Reflection fallback firing repeatedly — means upstream removeBackpackContents / removeStorageContents API broke</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="lang-sep"></div>
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- FRENCH VERSION -->
|
||||
<!-- ================================================================ -->
|
||||
|
||||
<h1>Procédure de Test — PlayerSync v2.1.5 (Version Française)</h1>
|
||||
<div class="meta">Date : <strong>2026-04-22</strong> | Branche : <code>1.21.1-dev</code> | Minecraft 1.21.1 / NeoForge 21.1.137 / Java 21</div>
|
||||
|
||||
<h2>Mise en place</h2>
|
||||
|
||||
<ol>
|
||||
<li>Démarrer MariaDB dev : <code>docker compose up -d</code></li>
|
||||
<li>Build : <code>./gradlew build</code> — le JAR sort dans <code>build/libs/playersync-1.21.1-2.1.5.jar</code></li>
|
||||
<li>Deux instances serveur nécessaires : <code>./gradlew runServer</code> (Serveur A) + copie avec <code>Server_id</code> différent dans <code>run-2/config/playersync-common.toml</code> (Serveur B)</li>
|
||||
<li>Adminer : <code>http://localhost:8080</code> (login <code>playersync</code>/<code>playersync</code>)</li>
|
||||
<li>Monitorer en continu : <code>tail -f run/logs/playersync/sync.log</code></li>
|
||||
</ol>
|
||||
|
||||
<h2>Scénarios à tester</h2>
|
||||
|
||||
<div class="scenario critical">
|
||||
<span class="tag tag-critical">CRITIQUE</span>
|
||||
<h3>1. Drop + déco rapide + reco (régression Phase 0)</h3>
|
||||
<div class="steps">
|
||||
<strong>Étapes :</strong>
|
||||
<ol>
|
||||
<li><input type="checkbox" class="checkbox"> Join Serveur A, remplir l'inventaire avec une épée de diamant</li>
|
||||
<li><input type="checkbox" class="checkbox"> Drop l'épée avec Q</li>
|
||||
<li><input type="checkbox" class="checkbox"> Déconnecter immédiatement (moins d'une seconde)</li>
|
||||
<li><input type="checkbox" class="checkbox"> Rejoin Serveur A</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="expected">
|
||||
<strong>Résultat attendu :</strong>
|
||||
<ul>
|
||||
<li>L'inventaire ne contient PAS l'épée</li>
|
||||
<li>L'ItemEntity est toujours au sol où elle a été drop</li>
|
||||
<li>Le joueur a exactement 1 copie de l'épée au total</li>
|
||||
<li>Logs : <code>[SAVE] LOGOUT completed</code> puis soit aucun write SaveToFile BG, soit <code>[GUARD] SaveToFile BG skipped — player already offline in DB</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scenario critical">
|
||||
<span class="tag tag-critical">CRITIQUE</span>
|
||||
<h3>2. Duplication Backpack (Sophisticated Backpacks)</h3>
|
||||
<div class="steps">
|
||||
<strong>Étapes :</strong>
|
||||
<ol>
|
||||
<li><input type="checkbox" class="checkbox"> Join Serveur A, craft un SophisticatedBackpack, remplir avec 10 blocs de diamant</li>
|
||||
<li><input type="checkbox" class="checkbox"> Déconnecter</li>
|
||||
<li><input type="checkbox" class="checkbox"> Join Serveur B (configurer un Server_id différent)</li>
|
||||
<li><input type="checkbox" class="checkbox"> Ouvrir le backpack, compter les blocs de diamant</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="expected">
|
||||
<strong>Résultat attendu :</strong>
|
||||
<ul>
|
||||
<li>Exactement 10 blocs de diamant (pas de duplication)</li>
|
||||
<li>Logs : <code>[restore-backpack] uuid=... nbt_keys=... cleared_via=api</code> (ou <code>reflection</code> en fallback)</li>
|
||||
<li>Aucun WARN sur un removeBackpackContents raté</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scenario critical">
|
||||
<span class="tag tag-critical">CRITIQUE</span>
|
||||
<h3>3. Duplication shulker Sophisticated Storage</h3>
|
||||
<div class="steps">
|
||||
<strong>Étapes :</strong>
|
||||
<ol>
|
||||
<li><input type="checkbox" class="checkbox"> Join Serveur A, packer un shulker plein de diamants dans l'inventaire</li>
|
||||
<li><input type="checkbox" class="checkbox"> Faire ouvrir l'inventaire par un autre Joueur B (via admin / échange / viewer)</li>
|
||||
<li><input type="checkbox" class="checkbox"> Déconnecter le Joueur A</li>
|
||||
<li><input type="checkbox" class="checkbox"> Joueur A se reconnecte sur Serveur B</li>
|
||||
<li><input type="checkbox" class="checkbox"> Déballer le shulker, compter les diamants</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="expected">
|
||||
<strong>Résultat attendu :</strong>
|
||||
<ul>
|
||||
<li>Compte de diamants identique à l'original</li>
|
||||
<li>Logs : <code>[CONTAINER_CLOSE]</code> pour le Joueur B (viewer force-fermé)</li>
|
||||
<li>Logs : <code>[restore-ss] uuid=... nbt_keys=...</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scenario critical">
|
||||
<span class="tag tag-critical">CRITIQUE</span>
|
||||
<h3>4. Recovery kill -9 / OOM</h3>
|
||||
<div class="steps">
|
||||
<strong>Étapes :</strong>
|
||||
<ol>
|
||||
<li><input type="checkbox" class="checkbox"> Join Serveur A, mettre l'inventaire dans un état connu (poser un diamant nommé)</li>
|
||||
<li><input type="checkbox" class="checkbox"> Trouver le PID java du serveur : <code>jps | grep Forge</code></li>
|
||||
<li><input type="checkbox" class="checkbox"> Kill brutal : <code>kill -9 <pid></code> (ou Task Manager → End Task sur Windows)</li>
|
||||
<li><input type="checkbox" class="checkbox"> Redémarrer le serveur A</li>
|
||||
<li><input type="checkbox" class="checkbox"> Join Serveur A, vérifier l'inventaire</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="expected">
|
||||
<strong>Résultat attendu :</strong>
|
||||
<ul>
|
||||
<li>Au boot : log <code>[crash-recovery] cleared N orphan online=1 rows</code></li>
|
||||
<li>Au boot : <code>[crash-recovery] JVM shutdown hook installed</code> ET idéalement (si le hook a tourné) : <code>[emergency-flush] flushed N players</code></li>
|
||||
<li>L'inventaire correspond au dernier état avant kill (dans la fenêtre auto-save ~10 min)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scenario high">
|
||||
<span class="tag tag-high">HIGH</span>
|
||||
<h3>5. Join sur serveur peer zombie (pas d'attente 30s)</h3>
|
||||
<div class="steps">
|
||||
<strong>Étapes :</strong>
|
||||
<ol>
|
||||
<li><input type="checkbox" class="checkbox"> Dans Adminer, setter manuellement <code>player_data.last_server=99999</code> et <code>online=1</code> pour un UUID test</li>
|
||||
<li><input type="checkbox" class="checkbox"> Joindre n'importe quel serveur en cours avec cet UUID</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="expected">
|
||||
<strong>Résultat attendu :</strong>
|
||||
<ul>
|
||||
<li>La connexion se fait en quelques secondes (pas 30s)</li>
|
||||
<li>Logs : <code>[RACE] Peer server 99999 is dead/zombie — taking over</code></li>
|
||||
<li>La DB affiche maintenant <code>last_server=<thisServer></code>, <code>online=1</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scenario high">
|
||||
<span class="tag tag-high">HIGH</span>
|
||||
<h3>6. Auto-save périodique (10 min)</h3>
|
||||
<div class="steps">
|
||||
<strong>Étapes :</strong>
|
||||
<ol>
|
||||
<li><input type="checkbox" class="checkbox"> Setter <code>auto_save_interval_minutes=1</code> en config pour un test rapide</li>
|
||||
<li><input type="checkbox" class="checkbox"> Join le serveur, ajouter des items à l'inventaire</li>
|
||||
<li><input type="checkbox" class="checkbox"> Attendre 1 minute (surveiller sync.log)</li>
|
||||
<li><input type="checkbox" class="checkbox"> Kill -9 du serveur</li>
|
||||
<li><input type="checkbox" class="checkbox"> Redémarrer, rejoin, vérifier l'inventaire</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="expected">
|
||||
<strong>Résultat attendu :</strong>
|
||||
<ul>
|
||||
<li>Log : <code>[periodic-save] queued snapshots for N player(s)</code> après 1 min</li>
|
||||
<li>L'inventaire post-crash reflète l'état AU dernier tick périodique</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scenario high">
|
||||
<span class="tag tag-high">HIGH</span>
|
||||
<h3>7. Log WARN sur saturation pool</h3>
|
||||
<div class="steps">
|
||||
<strong>Étapes :</strong>
|
||||
<ol>
|
||||
<li><input type="checkbox" class="checkbox"> Attendre 5 minutes après le boot du serveur (premier tick PoolStatsReporter)</li>
|
||||
<li><input type="checkbox" class="checkbox"> Grep sync.log pour <code>[POOL]</code></li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="expected">
|
||||
<strong>Résultat attendu :</strong>
|
||||
<ul>
|
||||
<li>Au moins une ligne comme <code>[POOL] executor active=0 queue=0 pool_idle=4 | hikari active=0 idle=4</code></li>
|
||||
<li>Aucun WARN sauf sous charge</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scenario high">
|
||||
<span class="tag tag-high">HIGH</span>
|
||||
<h3>8. Heartbeat update server_info</h3>
|
||||
<div class="steps">
|
||||
<strong>Étapes :</strong>
|
||||
<ol>
|
||||
<li><input type="checkbox" class="checkbox"> Dans Adminer, surveiller <code>server_info.last_update</code> pour l'id de ce serveur</li>
|
||||
<li><input type="checkbox" class="checkbox"> Refresh toutes les 20s pendant 1 minute</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="expected">
|
||||
<strong>Résultat attendu :</strong>
|
||||
<ul>
|
||||
<li><code>last_update</code> avance de ~10000 ms à chaque refresh</li>
|
||||
<li>Log : <code>[heartbeat] started</code> au boot</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scenario">
|
||||
<span class="tag tag-medium">MEDIUM</span>
|
||||
<h3>9. Capability Curios absente — pas de wipe</h3>
|
||||
<div class="steps">
|
||||
<strong>Étapes :</strong>
|
||||
<ol>
|
||||
<li><input type="checkbox" class="checkbox"> Équiper des items curios, mourir dans la lave</li>
|
||||
<li><input type="checkbox" class="checkbox"> Force-déconnecter pendant l'animation de mort</li>
|
||||
<li><input type="checkbox" class="checkbox"> Reconnexion</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="expected">
|
||||
<strong>Résultat attendu :</strong>
|
||||
<ul>
|
||||
<li>Si cap absente : log <code>[store-curios] handler unavailable for ... skipping write</code></li>
|
||||
<li>Row curios en DB NON wipée</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scenario">
|
||||
<span class="tag tag-medium">MEDIUM</span>
|
||||
<h3>10. Claim cross-server + court-circuit downstream</h3>
|
||||
<div class="steps">
|
||||
<strong>Étapes :</strong>
|
||||
<ol>
|
||||
<li><input type="checkbox" class="checkbox"> Joueur connecté sur Serveur A</li>
|
||||
<li><input type="checkbox" class="checkbox"> Déco puis immédiatement join Serveur B (<200ms)</li>
|
||||
<li><input type="checkbox" class="checkbox"> Vérifier sync.log sur Serveur A</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="expected">
|
||||
<strong>Résultat attendu :</strong>
|
||||
<ul>
|
||||
<li>Serveur A peut logger <code>[GUARD]</code> (last_server guard a bloqué) si B a claim pendant la save de A</li>
|
||||
<li>Si blocké : <code>[SAVE_SKIP] LOGOUT skipped: core guard blocked</code></li>
|
||||
<li>Inventaire joueur sur B = inventaire tel qu'il était sur A (pas de merge, pas d'overwrite)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Vérifications régressions</h2>
|
||||
|
||||
<div class="regression">
|
||||
<strong>Surveiller ces régressions après le déploiement Phases 0-5 :</strong>
|
||||
<ul>
|
||||
<li>TPS drop pendant les ticks auto-save (la save périodique à 10 min doit être invisible gameplay)</li>
|
||||
<li>Warnings HikariCP leak — <code>leakDetectionThreshold=25000</code>, warnings = connexion tenue >25s</li>
|
||||
<li>Déclenchement CallerRunsPolicy (queue pleine) — WARN <code>[pool-stats] executor queue high</code></li>
|
||||
<li>Deadlock sur logout → join (sérialisation bgLock) — le log doit montrer <code>[SAVE] LOGOUT completed</code> en ~500ms</li>
|
||||
<li>Fallback reflection qui tourne répétitivement — signifie que l'API upstream removeBackpackContents / removeStorageContents a été cassée</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p style="text-align: center; color: #999; margin-top: 3em; font-size: 0.9em;">
|
||||
Author: vyrriox | PlayerSync v2.1.5 | 2026-04-22
|
||||
</p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
44
build.gradle
44
build.gradle
|
|
@ -117,18 +117,58 @@ dependencies {
|
|||
compileOnly "curse.maven:curios-309927:6529130"
|
||||
compileOnly "curse.maven:sophisticated-backpacks-422301:7169832"
|
||||
compileOnly "curse.maven:sophisticated-core-618298:7168230"
|
||||
compileOnly "curse.maven:sophisticated-storage-619320:7744168"
|
||||
|
||||
compileOnly "thedarkcolour:kotlinforforge:5.10.0"
|
||||
compileOnly "curse.maven:cobblemon-687131:7273151"
|
||||
|
||||
// Mod compatibility - Cosmetic Armor Reworked
|
||||
compileOnly "curse.maven:cosmetic-armor-reworked-237307:5610814"
|
||||
// Mod compatibility - Apotheosis + Placebo
|
||||
compileOnly "curse.maven:apotheosis-313970:7444906"
|
||||
compileOnly "curse.maven:placebo-283644:6926281"
|
||||
// Mod compatibility - The Aether + Accessories API
|
||||
compileOnly "curse.maven:aether-255308:7043502"
|
||||
compileOnly "curse.maven:accessories-938917:7046407"
|
||||
// Mod compatibility - Refined Storage 2 + Extra Disks
|
||||
compileOnly "curse.maven:refined-storage-243076:7610477"
|
||||
compileOnly "curse.maven:extra-disks-351491:7032487"
|
||||
|
||||
runtimeOnly "curse.maven:curios-309927:6529130"
|
||||
runtimeOnly "curse.maven:sophisticated-backpacks-422301:7169832"
|
||||
runtimeOnly "curse.maven:sophisticated-core-618298:7168230"
|
||||
// embedd the JDBC driver in the mod using jarJar
|
||||
// Embed the JDBC driver in the mod using jarJar.
|
||||
// FIX COMPAT: declare a version RANGE so multi-mod setups (eg. arcadia-lib which
|
||||
// requires [8.3.0, 9.0.0)) can resolve a single shared MySQL driver instance
|
||||
// without jarJar complaining about incompatible constraints. The `prefer` keeps
|
||||
// 9.3.0 as our baseline when PlayerSync is the only consumer.
|
||||
runtimeOnly "com.mysql:mysql-connector-j:${jdbc_version}"
|
||||
jarJar "com.mysql:mysql-connector-j:${jdbc_version}"
|
||||
jarJar("com.mysql:mysql-connector-j") {
|
||||
version {
|
||||
strictly "[8.3.0, 10.0.0)"
|
||||
prefer "${jdbc_version}"
|
||||
}
|
||||
}
|
||||
additionalRuntimeClasspath "com.mysql:mysql-connector-j:${jdbc_version}"
|
||||
|
||||
// HikariCP connection pool — eliminates isValid() ping on every query (no more pingInternal in Spark)
|
||||
// Exclude slf4j-api: NeoForge already ships it.
|
||||
// FIX COMPAT: declare a range matching arcadia-lib's [5.1.0, 6.0.0) so jarJar
|
||||
// resolution succeeds with a single shared instance.
|
||||
implementation("com.zaxxer:HikariCP:${hikari_version}") {
|
||||
exclude group: "org.slf4j", module: "slf4j-api"
|
||||
}
|
||||
jarJar("com.zaxxer:HikariCP") {
|
||||
version {
|
||||
strictly "[5.1.0, 6.0.0)"
|
||||
prefer "${hikari_version}"
|
||||
}
|
||||
exclude group: "org.slf4j", module: "slf4j-api"
|
||||
}
|
||||
additionalRuntimeClasspath("com.zaxxer:HikariCP:${hikari_version}") {
|
||||
exclude group: "org.slf4j", module: "slf4j-api"
|
||||
}
|
||||
|
||||
// For more info:
|
||||
// http://www.gradle.org/docs/current/userguide/artifact_dependencies_tutorial.html
|
||||
// http://www.gradle.org/docs/current/userguide/dependency_management.html
|
||||
|
|
|
|||
2
compat-mods/.gitignore
vendored
Normal file
2
compat-mods/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
*.jar
|
||||
!.gitkeep
|
||||
0
compat-mods/.gitkeep
Normal file
0
compat-mods/.gitkeep
Normal file
31
compat-mods/README.md
Normal file
31
compat-mods/README.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# Compat Mods
|
||||
|
||||
Drop `.jar` files of mods that must be compatible with **PlayerSync** here for local analysis and testing.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Reference bundles for writing compatibility shims (see `src/main/java/vip/fubuki/playersync/sync/addons/`).
|
||||
- Local inspection of mod APIs, capabilities, and data structures.
|
||||
- NOT loaded by the dev runtime — purely a staging folder for analysis.
|
||||
|
||||
## Rules
|
||||
|
||||
- `.jar` files are **git-ignored** — do not commit mod binaries.
|
||||
- Keep one version per mod; rename with version suffix if multiple are needed (e.g. `sophisticatedbackpacks-1.21.1-3.23.0.jar`).
|
||||
|
||||
---
|
||||
|
||||
# Mods de compatibilité
|
||||
|
||||
Déposez les fichiers `.jar` des mods qui doivent être compatibles avec **PlayerSync** ici pour analyse et tests locaux.
|
||||
|
||||
## Objectif
|
||||
|
||||
- Bundles de référence pour écrire des shims de compatibilité (voir `src/main/java/vip/fubuki/playersync/sync/addons/`).
|
||||
- Inspection locale des APIs, capabilities et structures de données des mods.
|
||||
- Non chargé par le runtime de dev — dossier de staging uniquement pour analyse.
|
||||
|
||||
## Règles
|
||||
|
||||
- Les fichiers `.jar` sont **ignorés par git** — ne pas commit les binaires de mods.
|
||||
- Une seule version par mod ; renommer avec le suffixe de version si plusieurs sont nécessaires (ex : `sophisticatedbackpacks-1.21.1-3.23.0.jar`).
|
||||
|
|
@ -43,3 +43,6 @@ mod_description=make multiserver players' data sync
|
|||
# JDBC driver version
|
||||
# see https://dev.mysql.com/doc/relnotes/connector-j/en/ for latest version
|
||||
jdbc_version=9.3.0
|
||||
|
||||
# HikariCP connection pool version
|
||||
hikari_version=5.1.0
|
||||
|
|
|
|||
|
|
@ -1,25 +1,657 @@
|
|||
package vip.fubuki.playersync;
|
||||
|
||||
import com.mojang.brigadier.Command;
|
||||
import com.mojang.brigadier.CommandDispatcher;
|
||||
import com.mojang.brigadier.arguments.IntegerArgumentType;
|
||||
import com.mojang.brigadier.arguments.StringArgumentType;
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import com.zaxxer.hikari.HikariPoolMXBean;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.commands.arguments.EntityArgument;
|
||||
import net.minecraft.commands.arguments.GameProfileArgument;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.neoforged.bus.api.SubscribeEvent;
|
||||
import net.neoforged.fml.common.EventBusSubscriber;
|
||||
import net.neoforged.neoforge.event.RegisterCommandsEvent;
|
||||
import vip.fubuki.playersync.config.JdbcConfig;
|
||||
import vip.fubuki.playersync.sync.VanillaSync;
|
||||
import vip.fubuki.playersync.util.JDBCsetUp;
|
||||
import vip.fubuki.playersync.util.SyncLogger;
|
||||
import vip.fubuki.playersync.util.Tables;
|
||||
|
||||
@EventBusSubscriber()
|
||||
import java.sql.ResultSet;
|
||||
import java.util.Collection;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
|
||||
/**
|
||||
* Admin commands for PlayerSync. All commands require permission level 2 (op).
|
||||
*
|
||||
* <p>Root: {@code /playersync}
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code status} — server + pool + heartbeat summary</li>
|
||||
* <li>{@code flush [player]} — force an immediate save</li>
|
||||
* <li>{@code info <player>} — show DB row metadata</li>
|
||||
* <li>{@code reload} — reload config from disk</li>
|
||||
* <li>{@code orphans} — list stuck online=1 rows</li>
|
||||
* <li>{@code clearorphans [server_id]} — clear them</li>
|
||||
* <li>{@code peers} — list peer servers</li>
|
||||
* <li>{@code peerkill <id>} — force-disable a zombie peer</li>
|
||||
* <li>{@code cleanup} — clear orphans + stale peers in one go</li>
|
||||
* <li>{@code dump <player>} — dump DB row keys & sizes</li>
|
||||
* <li>{@code resync <player>} — force re-apply from DB</li>
|
||||
* <li>{@code poolstats} — immediate pool stats</li>
|
||||
* <li>{@code wipe <player>} — DANGER: delete all rows for a player</li>
|
||||
* <li>{@code version} — mod version</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author vyrriox
|
||||
*/
|
||||
@EventBusSubscriber(modid = PlayerSync.MODID)
|
||||
public class CommandInit {
|
||||
|
||||
private static final int PERM_OP = 2;
|
||||
|
||||
@SubscribeEvent
|
||||
public static void registerCommand(RegisterCommandsEvent event) {
|
||||
CommandDispatcher<CommandSourceStack> dispatcher=event.getDispatcher();
|
||||
// dispatcher.register(Commands.literal("playersync")
|
||||
// .requires(cs->cs.hasPermission(2))
|
||||
// .then(Commands.literal("reconnect")
|
||||
// .executes(context -> {
|
||||
//// context.getSource().sendSuccess(()->MutableComponent.create(new TranslatableContents("playersync.command.reconnect")),true);
|
||||
// return 0;
|
||||
// }
|
||||
// ))
|
||||
// );
|
||||
CommandDispatcher<CommandSourceStack> d = event.getDispatcher();
|
||||
|
||||
d.register(Commands.literal("playersync")
|
||||
.requires(cs -> cs.hasPermission(PERM_OP))
|
||||
|
||||
// ---- Status / info ----
|
||||
.then(Commands.literal("version").executes(CommandInit::runVersion))
|
||||
.then(Commands.literal("status").executes(CommandInit::runStatus))
|
||||
.then(Commands.literal("poolstats").executes(CommandInit::runPoolStats))
|
||||
|
||||
// ---- Player ops ----
|
||||
.then(Commands.literal("flush")
|
||||
.executes(CommandInit::runFlushAll)
|
||||
.then(Commands.argument("target", EntityArgument.player())
|
||||
.executes(CommandInit::runFlushPlayer)))
|
||||
.then(Commands.literal("info")
|
||||
.then(Commands.argument("player", GameProfileArgument.gameProfile())
|
||||
.executes(CommandInit::runInfo)))
|
||||
.then(Commands.literal("dump")
|
||||
.then(Commands.argument("player", GameProfileArgument.gameProfile())
|
||||
.executes(CommandInit::runDump)))
|
||||
.then(Commands.literal("resync")
|
||||
.then(Commands.argument("target", EntityArgument.player())
|
||||
.executes(CommandInit::runResync)))
|
||||
.then(Commands.literal("wipe")
|
||||
.then(Commands.argument("player", GameProfileArgument.gameProfile())
|
||||
.then(Commands.literal("confirm")
|
||||
.executes(CommandInit::runWipe))))
|
||||
|
||||
// ---- Inventory viewer ----
|
||||
.then(Commands.literal("inventory")
|
||||
.then(Commands.argument("player", GameProfileArgument.gameProfile())
|
||||
.executes(ctx -> runInventoryView(ctx, "all"))
|
||||
.then(Commands.literal("main")
|
||||
.executes(ctx -> runInventoryView(ctx, "main")))
|
||||
.then(Commands.literal("armor")
|
||||
.executes(ctx -> runInventoryView(ctx, "armor")))
|
||||
.then(Commands.literal("ender")
|
||||
.executes(ctx -> runInventoryView(ctx, "ender")))
|
||||
.then(Commands.literal("curios")
|
||||
.executes(ctx -> runInventoryView(ctx, "curios")))
|
||||
.then(Commands.literal("all")
|
||||
.executes(ctx -> runInventoryView(ctx, "all")))))
|
||||
|
||||
// ---- Cluster ops ----
|
||||
.then(Commands.literal("orphans").executes(CommandInit::runOrphans))
|
||||
.then(Commands.literal("clearorphans")
|
||||
.executes(CommandInit::runClearOrphansAll)
|
||||
.then(Commands.argument("server_id", IntegerArgumentType.integer(0))
|
||||
.executes(CommandInit::runClearOrphansId)))
|
||||
.then(Commands.literal("peers").executes(CommandInit::runPeers))
|
||||
.then(Commands.literal("peerkill")
|
||||
.then(Commands.argument("server_id", IntegerArgumentType.integer(0))
|
||||
.executes(CommandInit::runPeerKill)))
|
||||
.then(Commands.literal("cleanup").executes(CommandInit::runCleanup))
|
||||
|
||||
// ---- Config ----
|
||||
.then(Commands.literal("reload").executes(CommandInit::runReload))
|
||||
.then(Commands.literal("help").executes(CommandInit::runHelp))
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Command handlers
|
||||
// ========================================================================
|
||||
|
||||
private static int runVersion(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx) {
|
||||
ctx.getSource().sendSuccess(() -> Component.literal("§ePlayerSync §f" + PlayerSync.MODID + " §7(NeoForge 1.21.1)"), false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static int runStatus(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx) {
|
||||
CommandSourceStack src = ctx.getSource();
|
||||
final int serverId = JdbcConfig.SERVER_ID.get();
|
||||
|
||||
// Executor stats
|
||||
ThreadPoolExecutor exec = VanillaSync.getExecutor();
|
||||
final int active = exec != null ? exec.getActiveCount() : -1;
|
||||
final int queue = exec != null ? exec.getQueue().size() : -1;
|
||||
final int pool = exec != null ? exec.getPoolSize() : -1;
|
||||
|
||||
// Hikari stats
|
||||
HikariPoolMXBean hk = JDBCsetUp.getPoolMXBean();
|
||||
final int hA = hk != null ? hk.getActiveConnections() : -1;
|
||||
final int hI = hk != null ? hk.getIdleConnections() : -1;
|
||||
|
||||
// Heartbeat age of this server
|
||||
long hbAgeTmp = -1;
|
||||
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
||||
"SELECT last_update FROM " + Tables.serverInfo() + " WHERE id=?", serverId)) {
|
||||
ResultSet rs = qr.resultSet();
|
||||
if (rs.next()) hbAgeTmp = System.currentTimeMillis() - rs.getLong("last_update");
|
||||
} catch (Exception ignored) {}
|
||||
final long hbAge = hbAgeTmp;
|
||||
|
||||
final int online = src.getServer().getPlayerList().getPlayerCount();
|
||||
|
||||
src.sendSuccess(() -> Component.literal("§a=== PlayerSync status ==="), false);
|
||||
src.sendSuccess(() -> Component.literal("§7server_id: §f" + serverId
|
||||
+ " §7heartbeat_age: §f" + (hbAge >= 0 ? hbAge + "ms" : "§c?")), false);
|
||||
src.sendSuccess(() -> Component.literal("§7players online (this server): §f" + online), false);
|
||||
src.sendSuccess(() -> Component.literal("§7executor: §factive=" + active + " §7queue=§f" + queue + " §7pool=§f" + pool), false);
|
||||
src.sendSuccess(() -> Component.literal("§7hikari: §factive=" + hA + " §7idle=§f" + hI), false);
|
||||
src.sendSuccess(() -> Component.literal("§7auto_save: §f" + JdbcConfig.AUTO_SAVE_INTERVAL_MINUTES.get() + "min"
|
||||
+ " §7heartbeat_interval: §f" + JdbcConfig.HEARTBEAT_INTERVAL_SECONDS.get() + "s"), false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static int runPoolStats(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx) {
|
||||
ThreadPoolExecutor exec = VanillaSync.getExecutor();
|
||||
HikariPoolMXBean hk = JDBCsetUp.getPoolMXBean();
|
||||
int active = exec != null ? exec.getActiveCount() : -1;
|
||||
int queue = exec != null ? exec.getQueue().size() : -1;
|
||||
int idle = exec != null ? exec.getPoolSize() - exec.getActiveCount() : -1;
|
||||
int hA = hk != null ? hk.getActiveConnections() : -1;
|
||||
int hI = hk != null ? hk.getIdleConnections() : -1;
|
||||
SyncLogger.poolStats(active, queue, idle, hA, hI);
|
||||
ctx.getSource().sendSuccess(() -> Component.literal("§aPool stats logged to sync.log §7(exec a=" + active
|
||||
+ " q=" + queue + "/" + (exec != null ? exec.getQueue().size() + exec.getQueue().remainingCapacity() : "?")
|
||||
+ ", hikari a=" + hA + "/" + JdbcConfig.HIKARI_POOL_MAX_SIZE.get() + ")"), false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static int runFlushAll(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx) {
|
||||
int count = 0;
|
||||
for (ServerPlayer p : ctx.getSource().getServer().getPlayerList().getPlayers()) {
|
||||
if (p.getTags().contains("player_synced") && !p.isDeadOrDying()) {
|
||||
VanillaSync.snapshotAndQueueSave(p, "ADMIN_FLUSH");
|
||||
count++;
|
||||
}
|
||||
}
|
||||
final int queued = count;
|
||||
ctx.getSource().sendSuccess(() -> Component.literal("§aFlush queued for §f" + queued + " §aplayer(s)"), true);
|
||||
SyncLogger.playerEvent("SYSTEM", "ADMIN_FLUSH_ALL", "Triggered by " + ctx.getSource().getTextName() + " (" + queued + " players)");
|
||||
return queued;
|
||||
}
|
||||
|
||||
private static int runFlushPlayer(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx)
|
||||
throws CommandSyntaxException {
|
||||
ServerPlayer p = EntityArgument.getPlayer(ctx, "target");
|
||||
VanillaSync.snapshotAndQueueSave(p, "ADMIN_FLUSH");
|
||||
ctx.getSource().sendSuccess(() -> Component.literal("§aFlush queued for §f" + p.getName().getString()), true);
|
||||
SyncLogger.playerEvent(p.getUUID().toString(), "ADMIN_FLUSH",
|
||||
"Triggered by " + ctx.getSource().getTextName());
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static int runInfo(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx)
|
||||
throws CommandSyntaxException {
|
||||
Collection<com.mojang.authlib.GameProfile> profiles =
|
||||
GameProfileArgument.getGameProfiles(ctx, "player");
|
||||
if (profiles.isEmpty()) {
|
||||
ctx.getSource().sendFailure(Component.literal("§cNo matching player"));
|
||||
return 0;
|
||||
}
|
||||
com.mojang.authlib.GameProfile profile = profiles.iterator().next();
|
||||
UUID uuid = profile.getId();
|
||||
String name = profile.getName();
|
||||
|
||||
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
||||
"SELECT last_server, online, LENGTH(inventory) AS inv_len, LENGTH(enderchest) AS ec_len,"
|
||||
+ " LENGTH(armor) AS arm_len, xp, health FROM " + Tables.playerData() + " WHERE uuid=?",
|
||||
uuid.toString())) {
|
||||
ResultSet rs = qr.resultSet();
|
||||
if (!rs.next()) {
|
||||
ctx.getSource().sendFailure(Component.literal("§cNo DB row for " + name + " (" + uuid + ")"));
|
||||
return 0;
|
||||
}
|
||||
int lastSrv = rs.getInt("last_server");
|
||||
int onlineFlag = rs.getInt("online");
|
||||
int invLen = rs.getInt("inv_len");
|
||||
int ecLen = rs.getInt("ec_len");
|
||||
int armLen = rs.getInt("arm_len");
|
||||
int xp = rs.getInt("xp");
|
||||
int hp = rs.getInt("health");
|
||||
ctx.getSource().sendSuccess(() -> Component.literal("§a=== Info: §f" + name + " §7(" + uuid + ")"), false);
|
||||
ctx.getSource().sendSuccess(() -> Component.literal("§7last_server: §f" + lastSrv
|
||||
+ (lastSrv == JdbcConfig.SERVER_ID.get() ? " §8(this server)" : "")), false);
|
||||
ctx.getSource().sendSuccess(() -> Component.literal("§7online: §f" + onlineFlag
|
||||
+ " §7xp: §f" + xp + " §7health: §f" + hp), false);
|
||||
ctx.getSource().sendSuccess(() -> Component.literal("§7data sizes: §finventory=" + invLen
|
||||
+ "B armor=" + armLen + "B enderchest=" + ecLen + "B"), false);
|
||||
} catch (Exception e) {
|
||||
ctx.getSource().sendFailure(Component.literal("§cQuery failed: " + e.getMessage()));
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static int runDump(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx)
|
||||
throws CommandSyntaxException {
|
||||
Collection<com.mojang.authlib.GameProfile> profiles =
|
||||
GameProfileArgument.getGameProfiles(ctx, "player");
|
||||
if (profiles.isEmpty()) {
|
||||
ctx.getSource().sendFailure(Component.literal("§cNo matching player"));
|
||||
return 0;
|
||||
}
|
||||
UUID uuid = profiles.iterator().next().getId();
|
||||
PlayerSync.LOGGER.info("[admin-dump] dumping full row for {} (triggered by {})", uuid, ctx.getSource().getTextName());
|
||||
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
||||
"SELECT * FROM " + Tables.playerData() + " WHERE uuid=?", uuid.toString())) {
|
||||
ResultSet rs = qr.resultSet();
|
||||
if (!rs.next()) {
|
||||
ctx.getSource().sendFailure(Component.literal("§cNo row found"));
|
||||
return 0;
|
||||
}
|
||||
int cols = rs.getMetaData().getColumnCount();
|
||||
StringBuilder sb = new StringBuilder("[admin-dump] ").append(uuid).append(" {");
|
||||
for (int i = 1; i <= cols; i++) {
|
||||
String col = rs.getMetaData().getColumnName(i);
|
||||
Object v = rs.getObject(i);
|
||||
String val = v == null ? "null" : (v instanceof byte[] ? "<" + ((byte[]) v).length + " bytes>"
|
||||
: v instanceof String ? "<" + ((String) v).length() + " chars>"
|
||||
: v.toString());
|
||||
sb.append(col).append("=").append(val);
|
||||
if (i < cols) sb.append(", ");
|
||||
}
|
||||
sb.append("}");
|
||||
PlayerSync.LOGGER.info(sb.toString());
|
||||
SyncLogger.playerEvent(uuid.toString(), "ADMIN_DUMP", "Dumped by " + ctx.getSource().getTextName());
|
||||
ctx.getSource().sendSuccess(() -> Component.literal("§aDumped to server log — search §f[admin-dump]"), false);
|
||||
} catch (Exception e) {
|
||||
ctx.getSource().sendFailure(Component.literal("§cDump failed: " + e.getMessage()));
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static int runResync(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx)
|
||||
throws CommandSyntaxException {
|
||||
ServerPlayer p = EntityArgument.getPlayer(ctx, "target");
|
||||
p.removeTag("player_synced");
|
||||
ctx.getSource().sendSuccess(() -> Component.literal("§eKicking §f" + p.getName().getString()
|
||||
+ " §eto force resync on rejoin"), true);
|
||||
SyncLogger.playerEvent(p.getUUID().toString(), "ADMIN_RESYNC", "Triggered by " + ctx.getSource().getTextName());
|
||||
p.connection.disconnect(Component.literal("§ePlayerSync resync — please reconnect"));
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static int runWipe(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx)
|
||||
throws CommandSyntaxException {
|
||||
Collection<com.mojang.authlib.GameProfile> profiles =
|
||||
GameProfileArgument.getGameProfiles(ctx, "player");
|
||||
if (profiles.isEmpty()) {
|
||||
ctx.getSource().sendFailure(Component.literal("§cNo matching player"));
|
||||
return 0;
|
||||
}
|
||||
UUID uuid = profiles.iterator().next().getId();
|
||||
try {
|
||||
int d1 = JDBCsetUp.executePreparedUpdateRet("DELETE FROM " + Tables.playerData() + " WHERE uuid=?", uuid.toString());
|
||||
int d2 = JDBCsetUp.executePreparedUpdateRet("DELETE FROM " + Tables.curios() + " WHERE uuid=?", uuid.toString());
|
||||
int d3 = JDBCsetUp.executePreparedUpdateRet("DELETE FROM " + Tables.modPlayerData() + " WHERE uuid=?", uuid.toString());
|
||||
final int total = d1 + d2 + d3;
|
||||
ctx.getSource().sendSuccess(() -> Component.literal("§cWiped §f" + total
|
||||
+ " §crow(s) for player §f" + uuid + " §8(player_data=" + d1 + ", curios=" + d2 + ", mod=" + d3 + ")"), true);
|
||||
SyncLogger.playerEvent(uuid.toString(), "ADMIN_WIPE",
|
||||
"Wiped " + total + " rows by " + ctx.getSource().getTextName());
|
||||
PlayerSync.LOGGER.warn("[admin-wipe] {} wiped by {} ({} rows)", uuid, ctx.getSource().getTextName(), total);
|
||||
} catch (Exception e) {
|
||||
ctx.getSource().sendFailure(Component.literal("§cWipe failed: " + e.getMessage()));
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static int runOrphans(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx) {
|
||||
CommandSourceStack src = ctx.getSource();
|
||||
long staleMs = JdbcConfig.PEER_STALE_THRESHOLD_SECONDS.get() * 1000L;
|
||||
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
||||
"SELECT p.uuid, p.last_server, s.last_update FROM " + Tables.playerData() + " p"
|
||||
+ " LEFT JOIN " + Tables.serverInfo() + " s ON s.id = p.last_server"
|
||||
+ " WHERE p.online=1")) {
|
||||
ResultSet rs = qr.resultSet();
|
||||
int count = 0;
|
||||
long now = System.currentTimeMillis();
|
||||
int selfId = JdbcConfig.SERVER_ID.get();
|
||||
while (rs.next()) {
|
||||
String uuid = rs.getString("uuid");
|
||||
int ls = rs.getInt("last_server");
|
||||
long lu = rs.getLong("last_update");
|
||||
long age = now - lu;
|
||||
boolean stale = lu == 0 || age > staleMs || ls == 0;
|
||||
if (stale && ls != selfId) {
|
||||
count++;
|
||||
final String u = uuid;
|
||||
final int l = ls;
|
||||
final long a = age;
|
||||
src.sendSuccess(() -> Component.literal("§7- §f" + u + " §7last_server=§f" + l
|
||||
+ " §7heartbeat_age=§f" + (lu == 0 ? "none" : (a / 1000) + "s")), false);
|
||||
}
|
||||
}
|
||||
final int c = count;
|
||||
src.sendSuccess(() -> Component.literal("§a" + c + " §aorphan row(s) found (online=1 on dead peer)"), false);
|
||||
} catch (Exception e) {
|
||||
src.sendFailure(Component.literal("§cOrphans query failed: " + e.getMessage()));
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static int runClearOrphansAll(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx) {
|
||||
// Clear online=1 for rows whose last_server heartbeat is stale OR last_server=0
|
||||
long staleMs = JdbcConfig.PEER_STALE_THRESHOLD_SECONDS.get() * 1000L;
|
||||
long threshold = System.currentTimeMillis() - staleMs;
|
||||
int selfId = JdbcConfig.SERVER_ID.get();
|
||||
try {
|
||||
int n = JDBCsetUp.executePreparedUpdateRet(
|
||||
"UPDATE " + Tables.playerData() + " p SET p.online=0"
|
||||
+ " WHERE p.online=1 AND p.last_server <> ?"
|
||||
+ " AND (p.last_server = 0 OR NOT EXISTS ("
|
||||
+ " SELECT 1 FROM " + Tables.serverInfo() + " s WHERE s.id = p.last_server AND s.last_update >= ?))",
|
||||
selfId, threshold);
|
||||
ctx.getSource().sendSuccess(() -> Component.literal("§aCleared §f" + n + " §aorphan row(s)"), true);
|
||||
SyncLogger.playerEvent("SYSTEM", "ADMIN_CLEAR_ORPHANS",
|
||||
"Cleared " + n + " rows by " + ctx.getSource().getTextName());
|
||||
} catch (Exception e) {
|
||||
ctx.getSource().sendFailure(Component.literal("§cClear failed: " + e.getMessage()));
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static int runClearOrphansId(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx) {
|
||||
int id = IntegerArgumentType.getInteger(ctx, "server_id");
|
||||
try {
|
||||
int n = JDBCsetUp.executePreparedUpdateRet(
|
||||
"UPDATE " + Tables.playerData() + " SET online=0 WHERE last_server=? AND online=1", id);
|
||||
ctx.getSource().sendSuccess(() -> Component.literal("§aCleared §f" + n
|
||||
+ " §aorphan row(s) with last_server=§f" + id), true);
|
||||
SyncLogger.playerEvent("SYSTEM", "ADMIN_CLEAR_ORPHANS_ID",
|
||||
"Cleared " + n + " rows for server_id=" + id + " by " + ctx.getSource().getTextName());
|
||||
} catch (Exception e) {
|
||||
ctx.getSource().sendFailure(Component.literal("§cClear failed: " + e.getMessage()));
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static int runPeers(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx) {
|
||||
CommandSourceStack src = ctx.getSource();
|
||||
long staleMs = JdbcConfig.PEER_STALE_THRESHOLD_SECONDS.get() * 1000L;
|
||||
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
||||
"SELECT id, enable, last_update FROM " + Tables.serverInfo() + " ORDER BY id")) {
|
||||
ResultSet rs = qr.resultSet();
|
||||
int self = JdbcConfig.SERVER_ID.get();
|
||||
long now = System.currentTimeMillis();
|
||||
src.sendSuccess(() -> Component.literal("§a=== Peer servers ==="), false);
|
||||
int shown = 0;
|
||||
while (rs.next()) {
|
||||
int id = rs.getInt("id");
|
||||
int enabled = rs.getInt("enable");
|
||||
long lu = rs.getLong("last_update");
|
||||
long age = now - lu;
|
||||
boolean stale = enabled == 1 && age > staleMs;
|
||||
String tag = id == self ? "§a[SELF]§r "
|
||||
: stale ? "§c[STALE]§r "
|
||||
: enabled == 0 ? "§8[STOPPED]§r "
|
||||
: "§a[ALIVE]§r ";
|
||||
final String line = "§7id=§f" + id + " §7enable=§f" + enabled
|
||||
+ " §7age=§f" + (lu == 0 ? "never" : (age / 1000) + "s") + " " + tag;
|
||||
src.sendSuccess(() -> Component.literal(line), false);
|
||||
shown++;
|
||||
}
|
||||
final int s = shown;
|
||||
src.sendSuccess(() -> Component.literal("§7Total peers: §f" + s), false);
|
||||
} catch (Exception e) {
|
||||
src.sendFailure(Component.literal("§cPeers query failed: " + e.getMessage()));
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static int runPeerKill(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx) {
|
||||
int id = IntegerArgumentType.getInteger(ctx, "server_id");
|
||||
if (id == JdbcConfig.SERVER_ID.get()) {
|
||||
ctx.getSource().sendFailure(Component.literal("§cCannot peer-kill self"));
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
int n = JDBCsetUp.executePreparedUpdateRet(
|
||||
"UPDATE " + Tables.serverInfo() + " SET enable=0 WHERE id=?", id);
|
||||
ctx.getSource().sendSuccess(() -> Component.literal(
|
||||
n > 0 ? "§aMarked peer §f" + id + " §aas stopped (enable=0)"
|
||||
: "§cNo peer found with id=" + id), true);
|
||||
SyncLogger.playerEvent("SYSTEM", "ADMIN_PEER_KILL",
|
||||
"Peer " + id + " marked stopped by " + ctx.getSource().getTextName());
|
||||
} catch (Exception e) {
|
||||
ctx.getSource().sendFailure(Component.literal("§cPeerkill failed: " + e.getMessage()));
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static int runCleanup(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx) {
|
||||
runClearOrphansAll(ctx);
|
||||
long staleMs = JdbcConfig.PEER_STALE_THRESHOLD_SECONDS.get() * 1000L;
|
||||
long threshold = System.currentTimeMillis() - staleMs;
|
||||
try {
|
||||
int n = JDBCsetUp.executePreparedUpdateRet(
|
||||
"UPDATE " + Tables.serverInfo() + " SET enable=0 WHERE enable=1 AND id <> ? AND last_update < ?",
|
||||
JdbcConfig.SERVER_ID.get(), threshold);
|
||||
ctx.getSource().sendSuccess(() -> Component.literal("§aDisabled §f" + n + " §astale peer server(s)"), true);
|
||||
SyncLogger.playerEvent("SYSTEM", "ADMIN_CLEANUP",
|
||||
"Cleanup by " + ctx.getSource().getTextName() + " disabled " + n + " stale peers");
|
||||
} catch (Exception e) {
|
||||
ctx.getSource().sendFailure(Component.literal("§cCleanup stage 2 failed: " + e.getMessage()));
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static int runReload(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx) {
|
||||
// NeoForge's ModConfigSpec is mostly static and not reloadable at runtime.
|
||||
// We expose the command as a marker so admins know to restart after edits,
|
||||
// but also flush in-memory caches that read config lazily (Tables prefix).
|
||||
ctx.getSource().sendSuccess(() -> Component.literal(
|
||||
"§eModConfigSpec is loaded at startup; full reload requires a server restart."), false);
|
||||
ctx.getSource().sendSuccess(() -> Component.literal(
|
||||
"§7Runtime-readable values (thread pool / heartbeat period / toggles) will take effect on next tick."), false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pretty-prints a player's inventory / armor / ender chest / curios from the DB.
|
||||
* Works on offline players too — reads the serialized columns directly instead
|
||||
* of requiring the entity to be online. Output is compact, per-section, with
|
||||
* item ID and count per non-empty slot.
|
||||
*/
|
||||
private static int runInventoryView(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx, String section)
|
||||
throws CommandSyntaxException {
|
||||
Collection<com.mojang.authlib.GameProfile> profiles =
|
||||
GameProfileArgument.getGameProfiles(ctx, "player");
|
||||
if (profiles.isEmpty()) {
|
||||
ctx.getSource().sendFailure(Component.literal("§cNo matching player"));
|
||||
return 0;
|
||||
}
|
||||
com.mojang.authlib.GameProfile profile = profiles.iterator().next();
|
||||
UUID uuid = profile.getId();
|
||||
String name = profile.getName();
|
||||
|
||||
CommandSourceStack src = ctx.getSource();
|
||||
|
||||
String inventoryRaw = null, armorRaw = null, enderRaw = null;
|
||||
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
||||
"SELECT inventory, armor, enderchest FROM " + Tables.playerData() + " WHERE uuid=?",
|
||||
uuid.toString())) {
|
||||
ResultSet rs = qr.resultSet();
|
||||
if (!rs.next()) {
|
||||
src.sendFailure(Component.literal("§cNo DB row for " + name + " (" + uuid + ")"));
|
||||
return 0;
|
||||
}
|
||||
inventoryRaw = rs.getString("inventory");
|
||||
armorRaw = rs.getString("armor");
|
||||
enderRaw = rs.getString("enderchest");
|
||||
} catch (Exception e) {
|
||||
src.sendFailure(Component.literal("§cDB query failed: " + e.getMessage()));
|
||||
return 0;
|
||||
}
|
||||
|
||||
String curiosRaw = null;
|
||||
if ("all".equals(section) || "curios".equals(section)) {
|
||||
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
||||
"SELECT curios_item FROM " + Tables.curios() + " WHERE uuid=?", uuid.toString())) {
|
||||
ResultSet rs = qr.resultSet();
|
||||
if (rs.next()) curiosRaw = rs.getString("curios_item");
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
src.sendSuccess(() -> Component.literal("§a=== Inventory of §f" + name + " §7(" + uuid + ")"), false);
|
||||
|
||||
int totalShown = 0;
|
||||
if ("all".equals(section) || "main".equals(section)) {
|
||||
totalShown += printSection(src, "§6Main inventory", inventoryRaw, 36);
|
||||
}
|
||||
if ("all".equals(section) || "armor".equals(section)) {
|
||||
totalShown += printSection(src, "§6Armor §8(0=boots,1=legs,2=chest,3=helm)", armorRaw, 4);
|
||||
}
|
||||
if ("all".equals(section) || "ender".equals(section)) {
|
||||
totalShown += printSection(src, "§6Ender chest", enderRaw, 27);
|
||||
}
|
||||
if ("all".equals(section) || "curios".equals(section)) {
|
||||
totalShown += printCurios(src, curiosRaw);
|
||||
}
|
||||
|
||||
final int shown = totalShown;
|
||||
src.sendSuccess(() -> Component.literal("§7— §f" + shown + " §7non-empty slot(s) shown"), false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** Prints a vanilla-style slot section (Map<Integer,String>). Returns non-empty count. */
|
||||
private static int printSection(CommandSourceStack src, String header, String raw, int expectedSize) {
|
||||
if (raw == null || raw.length() <= 2) {
|
||||
src.sendSuccess(() -> Component.literal(header + "§7: §8(empty)"), false);
|
||||
return 0;
|
||||
}
|
||||
java.util.Map<Integer, String> map;
|
||||
try {
|
||||
map = vip.fubuki.playersync.util.LocalJsonUtil.StringToEntryMap(raw);
|
||||
} catch (Exception e) {
|
||||
src.sendSuccess(() -> Component.literal(header + "§7: §c<parse error: " + e.getMessage() + ">"), false);
|
||||
return 0;
|
||||
}
|
||||
if (map.isEmpty()) {
|
||||
src.sendSuccess(() -> Component.literal(header + "§7: §8(empty)"), false);
|
||||
return 0;
|
||||
}
|
||||
src.sendSuccess(() -> Component.literal(header + "§7 (" + map.size() + " slot(s) filled of " + expectedSize + "):"), false);
|
||||
int shown = 0;
|
||||
for (java.util.Map.Entry<Integer, String> e : new java.util.TreeMap<>(map).entrySet()) {
|
||||
String line = formatSlotLine(e.getKey().toString(), e.getValue());
|
||||
if (line != null) {
|
||||
src.sendSuccess(() -> Component.literal(line), false);
|
||||
shown++;
|
||||
}
|
||||
}
|
||||
return shown;
|
||||
}
|
||||
|
||||
/** Curios has composite keys ("slotType:index" and "cos:slotType:index"). */
|
||||
private static int printCurios(CommandSourceStack src, String raw) {
|
||||
if (raw == null || raw.length() <= 2) {
|
||||
src.sendSuccess(() -> Component.literal("§6Curios§7: §8(empty)"), false);
|
||||
return 0;
|
||||
}
|
||||
java.util.Map<String, String> map;
|
||||
try {
|
||||
map = vip.fubuki.playersync.util.LocalJsonUtil.StringToMap(raw);
|
||||
} catch (Exception e) {
|
||||
src.sendSuccess(() -> Component.literal("§6Curios§7: §c<parse error>"), false);
|
||||
return 0;
|
||||
}
|
||||
if (map.isEmpty()) {
|
||||
src.sendSuccess(() -> Component.literal("§6Curios§7: §8(empty)"), false);
|
||||
return 0;
|
||||
}
|
||||
src.sendSuccess(() -> Component.literal("§6Curios§7 (" + map.size() + " slot(s) filled):"), false);
|
||||
int shown = 0;
|
||||
for (java.util.Map.Entry<String, String> e : new java.util.TreeMap<>(map).entrySet()) {
|
||||
String line = formatSlotLine(e.getKey(), e.getValue());
|
||||
if (line != null) {
|
||||
src.sendSuccess(() -> Component.literal(line), false);
|
||||
shown++;
|
||||
}
|
||||
}
|
||||
return shown;
|
||||
}
|
||||
|
||||
/** Deserializes a single slot payload into a human-readable line. */
|
||||
private static String formatSlotLine(String slotKey, String payload) {
|
||||
try {
|
||||
net.minecraft.world.item.ItemStack stack =
|
||||
vip.fubuki.playersync.sync.VanillaSync.deserializeAndCreatePlaceholderIfNeeded(payload);
|
||||
if (stack == null || stack.isEmpty()) return null;
|
||||
net.minecraft.resources.ResourceLocation id =
|
||||
net.minecraft.core.registries.BuiltInRegistries.ITEM.getKey(stack.getItem());
|
||||
String idStr = id == null ? "unknown" : id.toString();
|
||||
String display = stack.getHoverName().getString();
|
||||
// Placeholder items (items from a mod not loaded on this server) show up with their
|
||||
// original id preserved inside CustomData — the deserializer already handled that.
|
||||
boolean placeholder = idStr.equals("minecraft:paper")
|
||||
&& stack.getComponents().has(net.minecraft.core.component.DataComponents.CUSTOM_DATA)
|
||||
&& stack.getComponents().get(net.minecraft.core.component.DataComponents.CUSTOM_DATA)
|
||||
.copyTag().contains("playersync:original_item_nbt");
|
||||
String prefix = placeholder ? "§d[placeholder] " : "§f";
|
||||
return "§7 [" + slotKey + "] " + prefix + idStr + "§7 x§f" + stack.getCount()
|
||||
+ (display.equals(stack.getItem().getDescription().getString()) ? "" : " §8(" + display + ")");
|
||||
} catch (Throwable t) {
|
||||
return "§7 [" + slotKey + "] §c<parse error: " + t.getClass().getSimpleName() + ">";
|
||||
}
|
||||
}
|
||||
|
||||
private static int runHelp(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx) {
|
||||
CommandSourceStack src = ctx.getSource();
|
||||
src.sendSuccess(() -> Component.literal("§a=== /playersync command reference ==="), false);
|
||||
String[] lines = {
|
||||
"§e/playersync status §7— server + pool + heartbeat summary",
|
||||
"§e/playersync poolstats §7— log pool stats immediately",
|
||||
"§e/playersync flush [player] §7— force save all / one",
|
||||
"§e/playersync info <player> §7— DB row metadata",
|
||||
"§e/playersync inventory <player> [main|armor|ender|curios|all] §7— pretty-print stored inventory",
|
||||
"§e/playersync dump <player> §7— dump DB row to server log",
|
||||
"§e/playersync resync <player> §7— kick to force re-sync",
|
||||
"§e/playersync wipe <player> confirm §7— DELETE rows (DANGER)",
|
||||
"§e/playersync orphans §7— list stuck online=1",
|
||||
"§e/playersync clearorphans [id] §7— clear orphan rows",
|
||||
"§e/playersync peers §7— list peer servers",
|
||||
"§e/playersync peerkill <id> §7— force-disable a peer",
|
||||
"§e/playersync cleanup §7— orphans + stale peers",
|
||||
"§e/playersync reload §7— status note about config reload",
|
||||
"§e/playersync version §7— mod version",
|
||||
};
|
||||
for (String l : lines) {
|
||||
src.sendSuccess(() -> Component.literal(l), false);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ import net.neoforged.neoforge.event.server.ServerStartingEvent;
|
|||
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
|
||||
import org.slf4j.Logger;
|
||||
import vip.fubuki.playersync.config.JdbcConfig;
|
||||
import vip.fubuki.playersync.sync.ChatSync;
|
||||
import vip.fubuki.playersync.sync.VanillaSync;
|
||||
import vip.fubuki.playersync.util.JDBCsetUp;
|
||||
import vip.fubuki.playersync.util.Tables;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
|
|
@ -35,24 +35,60 @@ public class PlayerSync {
|
|||
|
||||
private void commonSetup(final FMLCommonSetupEvent event) {
|
||||
VanillaSync.register();
|
||||
event.enqueueWork(() -> {
|
||||
// read SYNC_CHAT only within the enqueueWork to reliably get the real
|
||||
// config value and not its default value.
|
||||
if (JdbcConfig.SYNC_CHAT.get()) {
|
||||
LOGGER.info("Chat sync enabled.");
|
||||
ChatSync.register();
|
||||
}
|
||||
});
|
||||
// Chat sync removed. The `sync_chat` / `IsChatServer` / `ChatServerIP` /
|
||||
// `ChatServerPort` keys in existing config files are now silently ignored
|
||||
// (NeoForge's ModConfig loader skips unknown keys, so no crash on upgrade).
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public void onServerStarting(ServerStartingEvent event) throws SQLException {
|
||||
public void onServerStarting(ServerStartingEvent event) {
|
||||
// FIX COMPAT (C2): skip all MySQL init on single-player / integrated servers.
|
||||
// Running PlayerSync in single-player makes no sense (no cross-server sync) and
|
||||
// attempting to open a MySQL connection with default placeholder credentials on a
|
||||
// laptop without a MySQL server produces noisy errors + degraded UX.
|
||||
if (!event.getServer().isDedicatedServer()) {
|
||||
LOGGER.info("PlayerSync: integrated server detected — skipping MySQL init (dedicated-server only).");
|
||||
return;
|
||||
}
|
||||
|
||||
// Full init guarded by a single try/catch so a missing / unreachable MySQL
|
||||
// prints a user-friendly tutorial in the console instead of crashing the
|
||||
// dedicated server or flooding the log with a raw JDBC stack trace.
|
||||
try {
|
||||
onServerStartingUnchecked(event);
|
||||
} catch (Throwable t) {
|
||||
printDatabaseTutorialBanner(t);
|
||||
}
|
||||
}
|
||||
|
||||
private void onServerStartingUnchecked(ServerStartingEvent event) throws SQLException {
|
||||
String dbName = JdbcConfig.DATABASE_NAME.get();
|
||||
|
||||
// Step 1: Create the database using a connection that does not select a database.
|
||||
// FIX: Validate database name to prevent SQL injection via config.
|
||||
// Only alphanumeric chars and underscores are allowed in MySQL identifiers.
|
||||
if (!dbName.matches("[A-Za-z0-9_]+")) {
|
||||
LOGGER.error("Invalid DATABASE_NAME '{}'. Only alphanumeric characters and underscores are allowed. Aborting.", dbName);
|
||||
throw new SQLException("Invalid DATABASE_NAME: " + dbName);
|
||||
}
|
||||
|
||||
// Detect placeholder credentials and surface a tutorial straight away.
|
||||
String pass = JdbcConfig.PASSWORD.get();
|
||||
String host = JdbcConfig.HOST.get();
|
||||
if ("pleaseChangeThisPassword".equals(pass) || "localhost".equals(host)) {
|
||||
LOGGER.warn("[PlayerSync] Using placeholder credentials (host={}, password={}). Attempting anyway; a tutorial will be printed if the connection fails.",
|
||||
host, "pleaseChangeThisPassword".equals(pass) ? "<DEFAULT>" : "<set>");
|
||||
}
|
||||
|
||||
// Step 1: Create the database using a raw DriverManager connection (no pool yet).
|
||||
JDBCsetUp.executeUpdate("CREATE DATABASE IF NOT EXISTS `" + dbName + "`", 1);
|
||||
|
||||
// Step 2: Explicitly select the database on a connection obtained without default database.
|
||||
// Step 2: Initialise HikariCP pool now that the database exists.
|
||||
JDBCsetUp.initPool();
|
||||
|
||||
// Initialize dedicated PlayerSync log file (logs/playersync/sync.log)
|
||||
vip.fubuki.playersync.util.SyncLogger.init();
|
||||
|
||||
// Step 3: Explicitly select the database on a raw connection (DDL only).
|
||||
try (Connection conn = JDBCsetUp.getConnection(false);
|
||||
Statement st = conn.createStatement()) {
|
||||
st.execute("USE `" + dbName + "`");
|
||||
|
|
@ -61,10 +97,10 @@ public class PlayerSync {
|
|||
throw e;
|
||||
}
|
||||
|
||||
// Step 3: Create and alter tables using fully qualified names.
|
||||
// Step 4: Create and alter tables using fully qualified names.
|
||||
// Create player_data table
|
||||
JDBCsetUp.executeUpdate(
|
||||
"CREATE TABLE IF NOT EXISTS `" + dbName + "`.`player_data` (" +
|
||||
"CREATE TABLE IF NOT EXISTS `" + dbName + "`.`" + Tables.playerData() + "` (" +
|
||||
"`uuid` char(36) NOT NULL," +
|
||||
"`inventory` mediumblob," +
|
||||
"`armor` blob," +
|
||||
|
|
@ -84,131 +120,257 @@ public class PlayerSync {
|
|||
);
|
||||
|
||||
// Check and alter player_data table if columns are missing
|
||||
JDBCsetUp.QueryResult queryResult = JDBCsetUp.executeQuery(
|
||||
"SELECT COUNT(*) AS column_count " +
|
||||
"FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_SCHEMA = '" + dbName + "' " +
|
||||
"AND TABLE_NAME = 'player_data';"
|
||||
);
|
||||
ResultSet resultSet = queryResult.resultSet();
|
||||
int columnCount = 0;
|
||||
try (JDBCsetUp.QueryResult queryResult = JDBCsetUp.executePreparedQuery(
|
||||
"SELECT COUNT(*) AS column_count FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?",
|
||||
dbName, Tables.playerData())) {
|
||||
ResultSet resultSet = queryResult.resultSet();
|
||||
if (resultSet.next()) {
|
||||
columnCount = resultSet.getInt("column_count");
|
||||
}
|
||||
}
|
||||
if (columnCount < 14) {
|
||||
JDBCsetUp.executeUpdate(
|
||||
"ALTER TABLE `" + dbName + "`.`player_data` " +
|
||||
"ALTER TABLE `" + dbName + "`.`" + Tables.playerData() + "` " +
|
||||
"ADD COLUMN left_hand blob, " +
|
||||
"ADD COLUMN cursors blob;"
|
||||
);
|
||||
}
|
||||
|
||||
// PHASE 15: 2-phase commit protocol column. Set when a peer starts its async
|
||||
// logout save; cleared when the save atomically commits. Lets joining servers
|
||||
// distinguish 'peer saving' from 'ghost session' from 'active dup' deterministically.
|
||||
try (JDBCsetUp.QueryResult check = JDBCsetUp.executePreparedQuery(
|
||||
"SELECT COUNT(*) AS c FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA=? AND TABLE_NAME=? AND COLUMN_NAME='logout_started_at'",
|
||||
dbName, Tables.playerData())) {
|
||||
ResultSet rs = check.resultSet();
|
||||
if (rs.next() && rs.getInt("c") == 0) {
|
||||
JDBCsetUp.executeUpdate(
|
||||
"ALTER TABLE `" + dbName + "`.`" + Tables.playerData()
|
||||
+ "` ADD COLUMN logout_started_at BIGINT NULL"
|
||||
);
|
||||
LOGGER.info("[migration] added player_data.logout_started_at column (2-phase commit)");
|
||||
}
|
||||
}
|
||||
|
||||
// Create server_info table
|
||||
JDBCsetUp.executeUpdate(
|
||||
"CREATE TABLE IF NOT EXISTS `" + dbName + "`.`server_info` (" +
|
||||
"CREATE TABLE IF NOT EXISTS `" + dbName + "`.`" + Tables.serverInfo() + "` (" +
|
||||
"`id` INT NOT NULL," +
|
||||
"`enable` boolean NOT NULL," +
|
||||
"`last_update` BIGINT NOT NULL," +
|
||||
"PRIMARY KEY (`id`)" +
|
||||
");"
|
||||
);
|
||||
// FIX H-8: Use prepared statements for server_id to prevent SQL injection from config
|
||||
long current = System.currentTimeMillis();
|
||||
JDBCsetUp.executeUpdate(
|
||||
"INSERT INTO `" + dbName + "`.`server_info`(id,enable,last_update) " +
|
||||
"VALUES(" + JdbcConfig.SERVER_ID.get() + ",true," + current + ") " +
|
||||
"ON DUPLICATE KEY UPDATE id= " + JdbcConfig.SERVER_ID.get() + ",enable = 1," +
|
||||
"last_update=" + current + ";"
|
||||
JDBCsetUp.executePreparedUpdate(
|
||||
"INSERT INTO `" + dbName + "`.`" + Tables.serverInfo() + "`(id,enable,last_update) VALUES(?,true,?) ON DUPLICATE KEY UPDATE id=VALUES(id),enable=1,last_update=VALUES(last_update)",
|
||||
JdbcConfig.SERVER_ID.get(), current
|
||||
);
|
||||
JDBCsetUp.executeUpdate(
|
||||
"UPDATE `" + dbName + "`.`server_info` SET last_update=" + System.currentTimeMillis() +
|
||||
" WHERE id='" + JdbcConfig.SERVER_ID.get() + "'"
|
||||
JDBCsetUp.executePreparedUpdate(
|
||||
"UPDATE `" + dbName + "`.`" + Tables.serverInfo() + "` SET last_update=? WHERE id=?",
|
||||
System.currentTimeMillis(), JdbcConfig.SERVER_ID.get()
|
||||
);
|
||||
|
||||
// Create curios table if the Curios mod is loaded
|
||||
if (ModList.get().isLoaded("curios")) {
|
||||
JDBCsetUp.executeUpdate(
|
||||
"CREATE TABLE IF NOT EXISTS `" + dbName + "`.`curios` (" +
|
||||
"CREATE TABLE IF NOT EXISTS `" + dbName + "`.`" + Tables.curios() + "` (" +
|
||||
"uuid CHAR(36) NOT NULL, curios_item BLOB, PRIMARY KEY (uuid)" +
|
||||
")"
|
||||
);
|
||||
}
|
||||
|
||||
// Create Cobblemon table
|
||||
if(ModList.get().isLoaded("cobblemon")){
|
||||
JDBCsetUp.executeUpdate(
|
||||
"CREATE TABLE IF NOT EXISTS `" + dbName + "`.`cobblemon`(" +
|
||||
"uuid CHAR(36) NOT NULL," +
|
||||
"inv BLOB," +
|
||||
"pokedex MEDIUMBLOB," +
|
||||
"pc MEDIUMBLOB," +
|
||||
"general BLOB," +
|
||||
"PRIMARY KEY (uuid)" +
|
||||
")"
|
||||
);
|
||||
|
||||
JDBCsetUp.executeUpdate(
|
||||
"ALTER TABLE `" + dbName + "`.`cobblemon` MODIFY COLUMN pc MEDIUMBLOB"
|
||||
);
|
||||
JDBCsetUp.executeUpdate(
|
||||
"ALTER TABLE `" + dbName + "`.`cobblemon` MODIFY COLUMN pokedex MEDIUMBLOB"
|
||||
);
|
||||
}
|
||||
// Cobblemon support removed in this build (sync was main-thread blocking + SQL
|
||||
// injection in the mixins). Existing `cobblemon` tables in the DB are kept intact
|
||||
// for backward compat — they are simply no longer read or written.
|
||||
|
||||
// Create backpack_data table
|
||||
if (ModList.get().isLoaded("sophisticatedbackpacks")) {
|
||||
JDBCsetUp.executeUpdate(
|
||||
"CREATE TABLE IF NOT EXISTS `" + dbName + "`.`backpack_data` (" +
|
||||
"CREATE TABLE IF NOT EXISTS `" + dbName + "`.`" + Tables.backpackData() + "` (" +
|
||||
"uuid CHAR(36) NOT NULL, backpack_nbt MEDIUMBLOB, PRIMARY KEY (uuid)" +
|
||||
");", 1
|
||||
);
|
||||
|
||||
// Check if backpack_data table has the 'uuid' column
|
||||
JDBCsetUp.QueryResult backpackColCheck = JDBCsetUp.executeQuery(
|
||||
"SELECT COUNT(*) AS colCount FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_SCHEMA = '" + dbName + "' " +
|
||||
"AND TABLE_NAME = 'backpack_data' " +
|
||||
"AND COLUMN_NAME = 'uuid';"
|
||||
);
|
||||
try (JDBCsetUp.QueryResult backpackColCheck = JDBCsetUp.executePreparedQuery(
|
||||
"SELECT COUNT(*) AS colCount FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = 'uuid'",
|
||||
dbName, Tables.backpackData())) {
|
||||
ResultSet rsBackpackCol = backpackColCheck.resultSet();
|
||||
if (rsBackpackCol.next() && rsBackpackCol.getInt("colCount") == 0) {
|
||||
LOGGER.info("Altering backpack_data table to add missing 'uuid' column.");
|
||||
// Add the missing column and set it as primary key.
|
||||
JDBCsetUp.executeUpdate("ALTER TABLE `" + dbName + "`.`backpack_data` ADD COLUMN uuid CHAR(36) NOT NULL", 1);
|
||||
JDBCsetUp.executeUpdate("ALTER TABLE `" + dbName + "`.`backpack_data` ADD PRIMARY KEY (uuid)", 1);
|
||||
JDBCsetUp.executeUpdate("ALTER TABLE `" + dbName + "`.`" + Tables.backpackData() + "` ADD COLUMN uuid CHAR(36) NOT NULL", 1);
|
||||
JDBCsetUp.executeUpdate("ALTER TABLE `" + dbName + "`.`" + Tables.backpackData() + "` ADD PRIMARY KEY (uuid)", 1);
|
||||
}
|
||||
}
|
||||
rsBackpackCol.close();
|
||||
backpackColCheck.connection().close();
|
||||
}
|
||||
|
||||
// Check and alter the 'advancements' column in player_data if necessary
|
||||
JDBCsetUp.QueryResult advColCheck = JDBCsetUp.executeQuery(
|
||||
"SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_SCHEMA = '" + dbName + "' " +
|
||||
"AND TABLE_NAME = 'player_data' " +
|
||||
"AND COLUMN_NAME = 'advancements';"
|
||||
);
|
||||
try (JDBCsetUp.QueryResult advColCheck = JDBCsetUp.executePreparedQuery(
|
||||
"SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = 'advancements'",
|
||||
dbName, Tables.playerData())) {
|
||||
ResultSet rsAdvCol = advColCheck.resultSet();
|
||||
if (rsAdvCol.next()) {
|
||||
String dataType = rsAdvCol.getString("DATA_TYPE");
|
||||
if (!"mediumblob".equalsIgnoreCase(dataType)) {
|
||||
LOGGER.info("Altering player_data table to modify 'advancements' column to MEDIUMBLOB.");
|
||||
JDBCsetUp.executeUpdate("ALTER TABLE `" + dbName + "`.`player_data` MODIFY COLUMN advancements MEDIUMBLOB", 1);
|
||||
JDBCsetUp.executeUpdate("ALTER TABLE `" + dbName + "`.`" + Tables.playerData() + "` MODIFY COLUMN advancements MEDIUMBLOB", 1);
|
||||
}
|
||||
}
|
||||
rsAdvCol.close();
|
||||
// ----- END NEW BLOCK -----
|
||||
}
|
||||
|
||||
// Create generic mod_player_data table for mod compatibility (Accessories, CosmeticArmor, Aether, etc.)
|
||||
JDBCsetUp.executeUpdate(
|
||||
"CREATE TABLE IF NOT EXISTS `" + dbName + "`.`" + Tables.modPlayerData() + "` (" +
|
||||
"`uuid` CHAR(36) NOT NULL," +
|
||||
"`mod_id` VARCHAR(64) NOT NULL," +
|
||||
"`data_value` MEDIUMBLOB," +
|
||||
"PRIMARY KEY (`uuid`, `mod_id`)" +
|
||||
");"
|
||||
);
|
||||
|
||||
try {
|
||||
JDBCsetUp.executeUpdate("UPDATE player_data SET online=0 WHERE last_server=" + JdbcConfig.SERVER_ID.get() +" AND online=1 LIMIT 1000");
|
||||
JDBCsetUp.executePreparedUpdate("UPDATE " + Tables.playerData() + " SET online=0 WHERE last_server=? AND online=1", JdbcConfig.SERVER_ID.get());
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("An exception occurred while trying change wrong player-status\n" + e.getMessage());
|
||||
}
|
||||
|
||||
// Phase 3: anti-loss infrastructure.
|
||||
// 1. Clear orphaned online=1 flags from previous unclean shutdown.
|
||||
// 2. Report zombie peer servers so admins see them in logs.
|
||||
// 3. Install JVM shutdown hook — covers kill -9 / OOM / host reboot.
|
||||
// 4. Start periodic heartbeat so peers can detect us as alive.
|
||||
vip.fubuki.playersync.util.CrashRecovery.clearOrphanedOnlineFlags();
|
||||
vip.fubuki.playersync.util.CrashRecovery.reportZombiePeers(60_000L);
|
||||
vip.fubuki.playersync.util.CrashRecovery.installShutdownHook(() ->
|
||||
vip.fubuki.playersync.sync.VanillaSync.emergencyFlushAll());
|
||||
vip.fubuki.playersync.util.HeartbeatService.start();
|
||||
|
||||
// Phase 4: periodic full-flush scheduler (default 10 min).
|
||||
vip.fubuki.playersync.util.PeriodicSaveService.start();
|
||||
|
||||
// Phase 5: pool / executor stats reporter (every 5 min into sync.log).
|
||||
vip.fubuki.playersync.util.PoolStatsReporter.start();
|
||||
|
||||
LOGGER.info("PlayerSync is ready!");
|
||||
}
|
||||
|
||||
/**
|
||||
* Alters a column to {@code targetType} only if its current {@code DATA_TYPE}
|
||||
* differs. Skips expensive MDL + rebuild on every server start.
|
||||
*/
|
||||
private static void alterColumnIfNeeded(String dbName, String table, String column, String targetTypeLower) throws SQLException {
|
||||
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
||||
"SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA=? AND TABLE_NAME=? AND COLUMN_NAME=?",
|
||||
dbName, table, column)) {
|
||||
ResultSet rs = qr.resultSet();
|
||||
if (rs.next()) {
|
||||
String current = rs.getString("DATA_TYPE");
|
||||
if (current != null && targetTypeLower.equalsIgnoreCase(current)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
LOGGER.info("Altering {}.{} column {} to {}", dbName, table, column, targetTypeLower.toUpperCase());
|
||||
JDBCsetUp.executeUpdate("ALTER TABLE `" + dbName + "`.`" + table + "` MODIFY COLUMN `" + column + "` " + targetTypeLower.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints a big, friendly banner to the console explaining why PlayerSync could
|
||||
* not initialise its database. Invoked from the top-level try/catch in
|
||||
* {@link #onServerStarting(ServerStartingEvent)} so the dedicated server boots
|
||||
* anyway — admins running the mod for the first time get a tutorial instead
|
||||
* of a cryptic SQLException.
|
||||
*/
|
||||
private static void printDatabaseTutorialBanner(Throwable failure) {
|
||||
String configPath = "config/playersync-common.toml";
|
||||
String host = safe(JdbcConfig.HOST);
|
||||
int port = safeInt(JdbcConfig.PORT, 3306);
|
||||
String user = safe(JdbcConfig.USERNAME);
|
||||
String db = safe(JdbcConfig.DATABASE_NAME);
|
||||
boolean defaultPass = "pleaseChangeThisPassword".equals(safe(JdbcConfig.PASSWORD));
|
||||
String rootCause = rootCauseSummary(failure);
|
||||
|
||||
String[] banner = {
|
||||
"",
|
||||
"######################################################################",
|
||||
"# #",
|
||||
"# PlayerSync — DATABASE NOT AVAILABLE — SERVER STILL STARTED #",
|
||||
"# #",
|
||||
"# PlayerSync requires a MySQL / MariaDB database to sync player #",
|
||||
"# data across servers. Your server will BOOT without sync until #",
|
||||
"# the connection is fixed. #",
|
||||
"# #",
|
||||
"######################################################################",
|
||||
"",
|
||||
"What failed: " + rootCause,
|
||||
"",
|
||||
"Current config (from " + configPath + "):",
|
||||
" host = " + host,
|
||||
" db_port = " + port,
|
||||
" user_name = " + user,
|
||||
" db_name = " + db,
|
||||
" password = " + (defaultPass ? "<PLACEHOLDER — NOT CHANGED>" : "<set>"),
|
||||
"",
|
||||
"=== Quick-fix checklist ===",
|
||||
" 1. Is the database reachable from this host?",
|
||||
" telnet " + host + " " + port + " (should connect)",
|
||||
" mysql -h " + host + " -P " + port + " -u " + user + " -p",
|
||||
"",
|
||||
" 2. Did you change the password in " + configPath + " ?",
|
||||
(defaultPass
|
||||
? " >> NO — you're using the default 'pleaseChangeThisPassword'. <<"
|
||||
: " OK — password is set."),
|
||||
"",
|
||||
" 3. Running on localhost for dev? Use the bundled Docker compose:",
|
||||
" docker compose up -d # project root",
|
||||
" (starts MariaDB + Adminer on :3306 / :8080)",
|
||||
"",
|
||||
" 4. Firewall / bind-address? MySQL config 'bind-address = 0.0.0.0'",
|
||||
" and the user must have remote-login grants:",
|
||||
" GRANT ALL ON " + db + ".* TO '" + user + "'@'%' IDENTIFIED BY '<your-pw>';",
|
||||
" FLUSH PRIVILEGES;",
|
||||
"",
|
||||
" 5. Completely disable PlayerSync for this session — remove the jar",
|
||||
" or start with -Dplayersync.disabled=true (not enforced by the mod",
|
||||
" itself, but skips noisy errors if you don't intend to use it).",
|
||||
"",
|
||||
"Full exception trace follows for support / bug reports:",
|
||||
"######################################################################",
|
||||
"",
|
||||
};
|
||||
for (String line : banner) {
|
||||
LOGGER.error(line);
|
||||
}
|
||||
LOGGER.error("PlayerSync initialisation failed — root cause:", failure);
|
||||
LOGGER.error("######################################################################");
|
||||
}
|
||||
|
||||
private static String safe(net.neoforged.neoforge.common.ModConfigSpec.ConfigValue<?> v) {
|
||||
try { Object o = v.get(); return o == null ? "<null>" : o.toString(); } catch (Throwable t) { return "<unreadable>"; }
|
||||
}
|
||||
private static int safeInt(net.neoforged.neoforge.common.ModConfigSpec.IntValue v, int def) {
|
||||
try { return v.get(); } catch (Throwable t) { return def; }
|
||||
}
|
||||
private static String rootCauseSummary(Throwable t) {
|
||||
Throwable cur = t;
|
||||
while (cur.getCause() != null && cur.getCause() != cur) cur = cur.getCause();
|
||||
String cls = cur.getClass().getSimpleName();
|
||||
String msg = cur.getMessage() == null ? "(no message)" : cur.getMessage().replaceAll("\\s+", " ").trim();
|
||||
if (msg.length() > 180) msg = msg.substring(0, 177) + "...";
|
||||
return cls + ": " + msg;
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public void onServerStopping(ServerStoppingEvent event) {
|
||||
ChatSync.shutdown();
|
||||
// DO NOT call JDBCsetUp.shutdownPool() or SyncLogger.shutdown() here!
|
||||
// VanillaSync.onServerShutdown also subscribes to ServerStoppingEvent and
|
||||
// needs the pool to save all player data AND the logger to trace those saves.
|
||||
// NeoForge does not guarantee handler ordering across @SubscribeEvent instances,
|
||||
// so both the pool and the logger are shut down at the very end of
|
||||
// VanillaSync.onServerShutdown — after parallel saves finish.
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,59 +10,229 @@ import java.util.Random;
|
|||
|
||||
public class JdbcConfig {
|
||||
public static ModConfigSpec COMMON_CONFIG;
|
||||
|
||||
// ----- Connection (kept under [general] for backward compat with existing config files) -----
|
||||
public static ModConfigSpec.ConfigValue<String> HOST;
|
||||
public static ModConfigSpec.IntValue PORT;
|
||||
public static ModConfigSpec.ConfigValue<String> USERNAME;
|
||||
public static ModConfigSpec.ConfigValue<String> PASSWORD;
|
||||
public static ModConfigSpec.ConfigValue<String> DATABASE_NAME;
|
||||
public static ModConfigSpec.BooleanValue USE_SSL;
|
||||
|
||||
// ----- Core sync behaviour (kept under [general]) -----
|
||||
public static ModConfigSpec.ConfigValue<List<String>> SYNC_WORLD;
|
||||
public static ModConfigSpec.BooleanValue SYNC_ADVANCEMENTS;
|
||||
public static ModConfigSpec.BooleanValue USE_SSL;
|
||||
public static ModConfigSpec.BooleanValue SYNC_CHAT;
|
||||
public static ModConfigSpec.BooleanValue IS_CHAT_SERVER;
|
||||
public static ModConfigSpec.BooleanValue KICK_WHEN_ALREADY_ONLINE;
|
||||
public static ModConfigSpec.ConfigValue<String> KICK_MESSAGE;
|
||||
public static ModConfigSpec.IntValue KICK_GRACE_PERIOD_MS;
|
||||
public static ModConfigSpec.BooleanValue USE_LEGACY_SERIALIZATION;
|
||||
public static final ModConfigSpec.ConfigValue<String> ITEM_PLACEHOLDER_TITLE_OVERRIDE;
|
||||
public static final ModConfigSpec.ConfigValue<String> ITEM_PLACEHOLDER_DESCRIPTION_OVERRIDE;
|
||||
public static ModConfigSpec.ConfigValue<String> CHAT_SERVER_IP;
|
||||
public static ModConfigSpec.IntValue CHAT_SERVER_PORT;
|
||||
public static ModConfigSpec.BooleanValue USE_LEGACY_SERIALIZATION;
|
||||
|
||||
public static ModConfigSpec.ConfigValue<Integer> SERVER_ID;
|
||||
|
||||
/** Table-name prefix; see {@link vip.fubuki.playersync.util.Tables}. */
|
||||
public static ModConfigSpec.ConfigValue<String> TABLE_PREFIX;
|
||||
|
||||
// ----- Save triggers (new section) -----
|
||||
public static ModConfigSpec.IntValue AUTO_SAVE_INTERVAL_MINUTES;
|
||||
public static ModConfigSpec.BooleanValue SAVE_ON_DIMENSION_CHANGE;
|
||||
public static ModConfigSpec.BooleanValue SAVE_ON_DEATH;
|
||||
public static ModConfigSpec.BooleanValue SAVE_ON_RESPAWN;
|
||||
|
||||
// ----- Sync toggles (new section) -----
|
||||
public static ModConfigSpec.BooleanValue SYNC_INVENTORY;
|
||||
public static ModConfigSpec.BooleanValue SYNC_ENDER_CHEST;
|
||||
public static ModConfigSpec.BooleanValue SYNC_XP;
|
||||
public static ModConfigSpec.BooleanValue SYNC_EFFECTS;
|
||||
public static ModConfigSpec.BooleanValue SYNC_HEALTH_FOOD;
|
||||
public static ModConfigSpec.BooleanValue SYNC_CURIOS;
|
||||
public static ModConfigSpec.BooleanValue SYNC_ACCESSORIES;
|
||||
public static ModConfigSpec.BooleanValue SYNC_BACKPACKS;
|
||||
public static ModConfigSpec.BooleanValue SYNC_COSMETIC_ARMOR;
|
||||
public static ModConfigSpec.BooleanValue SYNC_REFINED_STORAGE;
|
||||
|
||||
// ----- Performance tuning (new section) -----
|
||||
public static ModConfigSpec.IntValue HEARTBEAT_INTERVAL_SECONDS;
|
||||
public static ModConfigSpec.IntValue PEER_STALE_THRESHOLD_SECONDS;
|
||||
public static ModConfigSpec.IntValue JOIN_POLL_MAX_ATTEMPTS;
|
||||
public static ModConfigSpec.IntValue JOIN_POLL_INTERVAL_MS;
|
||||
public static ModConfigSpec.IntValue JOIN_PEER_ALIVE_MAX_WAIT_SECONDS;
|
||||
public static ModConfigSpec.IntValue POOL_STATS_INTERVAL_MINUTES;
|
||||
public static ModConfigSpec.IntValue HIKARI_POOL_MAX_SIZE;
|
||||
public static ModConfigSpec.IntValue HIKARI_LEAK_THRESHOLD_MS;
|
||||
|
||||
// ----- Safety / integrity (new section) -----
|
||||
public static ModConfigSpec.BooleanValue REFUSE_EMPTY_INVENTORY_WRITE;
|
||||
public static ModConfigSpec.IntValue MAX_INVENTORY_SIZE_BYTES;
|
||||
public static ModConfigSpec.IntValue SKIP_SAVES_WHEN_TPS_BELOW;
|
||||
|
||||
// ----- Observability (new section) -----
|
||||
public static ModConfigSpec.BooleanValue LOG_STRUCTURED_JSON;
|
||||
public static ModConfigSpec.IntValue LOG_ROTATION_SIZE_MB;
|
||||
public static ModConfigSpec.IntValue LOG_ROTATION_MAX_FILES;
|
||||
|
||||
|
||||
static {
|
||||
ModConfigSpec.Builder COMMON_BUILDER = new ModConfigSpec.Builder();
|
||||
COMMON_BUILDER.comment("General settings").push("general");
|
||||
HOST=COMMON_BUILDER.comment("The host of the database").define("host", "localhost");
|
||||
PORT = COMMON_BUILDER.comment("database port").defineInRange("db_port", 3306, 0, 65535);
|
||||
USE_SSL = COMMON_BUILDER.comment("whether use SSL").define("use_ssl", false);
|
||||
USERNAME = COMMON_BUILDER.comment("username").define("user_name", "playersync");
|
||||
PASSWORD = COMMON_BUILDER.comment("password").define("password", "pleaseChangeThisPassword");
|
||||
DATABASE_NAME = COMMON_BUILDER.comment("database name").define("db_name","playersync");
|
||||
SERVER_ID = COMMON_BUILDER.comment("the server id should be unique").define("Server_id", new Random().nextInt(1,Integer.MAX_VALUE-1));
|
||||
SYNC_WORLD = COMMON_BUILDER.comment("The worlds that will be synchronized. If running on a server, leave array empty.").define("sync_world", new ArrayList<>());
|
||||
SYNC_ADVANCEMENTS = COMMON_BUILDER.comment("Whether to sync advancements between servers")
|
||||
ModConfigSpec.Builder B = new ModConfigSpec.Builder();
|
||||
|
||||
// ==========================================================================
|
||||
// [general] — Every key that already existed in pre-2.1.5 configs MUST stay
|
||||
// here so existing playersync-common.toml files keep working after an upgrade.
|
||||
// New settings go into dedicated sections below.
|
||||
// ==========================================================================
|
||||
B.comment("General settings").push("general");
|
||||
|
||||
HOST = B.comment("The host of the database").define("host", "localhost");
|
||||
PORT = B.comment("database port").defineInRange("db_port", 3306, 0, 65535);
|
||||
USE_SSL = B.comment("whether use SSL").define("use_ssl", false);
|
||||
USERNAME = B.comment("username").define("user_name", "playersync");
|
||||
PASSWORD = B.comment("password").define("password", "pleaseChangeThisPassword");
|
||||
DATABASE_NAME = B.comment("database name").define("db_name", "playersync");
|
||||
TABLE_PREFIX = B.comment(
|
||||
"Optional prefix prepended to every PlayerSync table (player_data, curios, backpack_data, ...).",
|
||||
"Use to share a single MySQL database with other mods or legacy schemas.",
|
||||
"Leave empty to keep the historical unprefixed names. Example: 'playersync_'.",
|
||||
"Only alphanumeric characters and underscores are allowed."
|
||||
).define("table_prefix", "");
|
||||
SERVER_ID = B.comment("the server id should be unique")
|
||||
.define("Server_id", new Random().nextInt(1, Integer.MAX_VALUE - 1));
|
||||
SYNC_WORLD = B.comment("The worlds that will be synchronized. If running on a server, leave array empty.")
|
||||
.define("sync_world", new ArrayList<>());
|
||||
SYNC_ADVANCEMENTS = B.comment("Whether to sync advancements between servers")
|
||||
.define("sync_advancements", true);
|
||||
SYNC_CHAT = COMMON_BUILDER.comment("Whether synchronize chat").define("sync_chat", false);
|
||||
IS_CHAT_SERVER = COMMON_BUILDER.comment("Whether recieve messages from other servers as host").define("IsChatServer",false);
|
||||
KICK_WHEN_ALREADY_ONLINE = COMMON_BUILDER.comment("Whether to kick player when already online on another server")
|
||||
KICK_WHEN_ALREADY_ONLINE = B.comment("Whether to kick player when already online on another server")
|
||||
.define("kick_when_already_online", true);
|
||||
CHAT_SERVER_IP = COMMON_BUILDER.define("ChatServerIP","127.0.0.1");
|
||||
CHAT_SERVER_PORT = COMMON_BUILDER.defineInRange("ChatServerPort",7900,0,65535);
|
||||
USE_LEGACY_SERIALIZATION = COMMON_BUILDER.comment(
|
||||
// NEW in 2.1.5 — safe to add to [general], unknown keys on old rollbacks just get ignored.
|
||||
KICK_MESSAGE = B.comment(
|
||||
"Custom kick message when a duplicate login is detected. Empty = default message.")
|
||||
.define("kick_message", "");
|
||||
KICK_GRACE_PERIOD_MS = B.comment(
|
||||
"Milliseconds to wait before kicking a duplicate-login player. Short grace period lets",
|
||||
"the legitimate session re-establish on this server. Range 0-10000.")
|
||||
.defineInRange("kick_grace_period_ms", 500, 0, 10000);
|
||||
USE_LEGACY_SERIALIZATION = B.comment(
|
||||
"Use the old (pre-Base64) serialization format for writing data to the database.",
|
||||
"Set to true ONLY if you have older mod versions reading the same database.",
|
||||
"This only affects writing data, the mod can read both Base64 and pre-Base64 serialization.",
|
||||
"New installations should leave this as 'false'."
|
||||
).define("use_legacy_serialization", false);
|
||||
ITEM_PLACEHOLDER_TITLE_OVERRIDE = COMMON_BUILDER
|
||||
ITEM_PLACEHOLDER_TITLE_OVERRIDE = B
|
||||
.comment("Override the title of placeholder items which are unavailable on the current server.")
|
||||
.define("item_placeholder_title_override", "");
|
||||
ITEM_PLACEHOLDER_DESCRIPTION_OVERRIDE = COMMON_BUILDER
|
||||
ITEM_PLACEHOLDER_DESCRIPTION_OVERRIDE = B
|
||||
.comment("Override the description of placeholder items which are unavailable on the current server.")
|
||||
.define("item_placeholder_description_override", "");
|
||||
|
||||
COMMON_BUILDER.pop();
|
||||
COMMON_CONFIG = COMMON_BUILDER.build();
|
||||
B.pop(); // end [general]
|
||||
|
||||
// ===== [save_triggers] =====
|
||||
B.comment("When to trigger a save (new in 2.1.5)").push("save_triggers");
|
||||
AUTO_SAVE_INTERVAL_MINUTES = B.comment(
|
||||
"Periodic full-flush interval (minutes). Triggers a complete save (player data +",
|
||||
"backpacks + SS + RS2) for every online player. Set to 0 to disable. Default 10."
|
||||
).defineInRange("auto_save_interval_minutes", 10, 0, 1440);
|
||||
SAVE_ON_DIMENSION_CHANGE = B.comment(
|
||||
"Trigger a full save when a player changes dimension. Protects against mid-teleport",
|
||||
"crashes. Adds DB load proportional to travel frequency."
|
||||
).define("save_on_dimension_change", false);
|
||||
SAVE_ON_DEATH = B.comment(
|
||||
"Trigger a pre-death snapshot on LivingDeathEvent (before items drop).",
|
||||
"Recovery insurance if the normal logout handler is skipped after death."
|
||||
).define("save_on_death", true);
|
||||
SAVE_ON_RESPAWN = B.comment(
|
||||
"Trigger a save after player respawn to capture the post-death state immediately.")
|
||||
.define("save_on_respawn", true);
|
||||
B.pop();
|
||||
|
||||
// ===== [sync_toggles] =====
|
||||
B.comment("Per-category sync toggles — disable individual data kinds if your server doesn't need them (new in 2.1.5)").push("sync_toggles");
|
||||
SYNC_INVENTORY = B.comment("Sync main inventory + armor + offhand").define("sync_inventory", true);
|
||||
SYNC_ENDER_CHEST = B.comment("Sync ender chest contents").define("sync_ender_chest", true);
|
||||
SYNC_XP = B.comment("Sync total XP / experience levels").define("sync_xp", true);
|
||||
SYNC_EFFECTS = B.comment("Sync active potion effects").define("sync_effects", true);
|
||||
SYNC_HEALTH_FOOD = B.comment("Sync current health and food level").define("sync_health_food", true);
|
||||
SYNC_CURIOS = B.comment("Sync Curios API slots (if the Curios mod is installed)").define("sync_curios", true);
|
||||
SYNC_ACCESSORIES = B.comment("Sync Accessories API slots (if installed)").define("sync_accessories", true);
|
||||
SYNC_BACKPACKS = B.comment("Sync Sophisticated Backpacks + Storage contents").define("sync_backpacks", true);
|
||||
SYNC_COSMETIC_ARMOR = B.comment("Sync Cosmetic Armor Reworked slots").define("sync_cosmetic_armor", true);
|
||||
SYNC_REFINED_STORAGE = B.comment("Sync Refined Storage 2 disk contents").define("sync_refined_storage", true);
|
||||
B.pop();
|
||||
|
||||
// ===== [performance] =====
|
||||
B.comment("Performance tuning — touch only if you know what you're doing (new in 2.1.5)").push("performance");
|
||||
HEARTBEAT_INTERVAL_SECONDS = B.comment(
|
||||
"How often this server writes its heartbeat to server_info (seconds). Pair with",
|
||||
"peer_stale_threshold_seconds: peers older than threshold are treated as dead.")
|
||||
.defineInRange("heartbeat_interval_seconds", 30, 5, 600);
|
||||
PEER_STALE_THRESHOLD_SECONDS = B.comment(
|
||||
"How old a peer heartbeat must be before we treat it as a dead (zombie) server.",
|
||||
"doPlayerJoin short-circuits the last_server poll when the peer is stale.")
|
||||
.defineInRange("peer_stale_threshold_seconds", 60, 10, 3600);
|
||||
JOIN_POLL_MAX_ATTEMPTS = B.comment(
|
||||
"Max attempts for doPlayerJoin's last_server poll before giving up.")
|
||||
.defineInRange("join_poll_max_attempts", 120, 10, 600);
|
||||
JOIN_POLL_INTERVAL_MS = B.comment(
|
||||
"Wait interval between last_server poll attempts (milliseconds).")
|
||||
.defineInRange("join_poll_interval_ms", 500, 100, 5000);
|
||||
JOIN_PEER_ALIVE_MAX_WAIT_SECONDS = B.comment(
|
||||
"How long to wait before force-claiming ownership when the previous server is",
|
||||
"ALIVE (heartbeat fresh) but the player row still shows online=1. A force-claim",
|
||||
"reads whatever is currently in the DB — if the peer's async save is still",
|
||||
"in flight and commits AFTER we claim, any state change the peer recorded (item",
|
||||
"pickup, drop, deposit) is lost on our side and may look like duplication against",
|
||||
"an ItemEntity the peer had spawned. Real saves complete in <1s, but a slow DB",
|
||||
"or heavy batch can push this to many seconds.",
|
||||
"",
|
||||
"Default 600s = wait the full poll — never force-claim on an alive peer. SAFE.",
|
||||
"Lower to 30/15s if you accept the edge-case risk in exchange for faster handling",
|
||||
"of ghost sessions (player dropped off A's network without clean logout).",
|
||||
"Set to 0 to force-claim immediately (very aggressive, highest risk).",
|
||||
"Stale-heartbeat peers are always force-claimed instantly regardless of this value.")
|
||||
.defineInRange("join_peer_alive_max_wait_seconds", 600, 0, 3600);
|
||||
POOL_STATS_INTERVAL_MINUTES = B.comment(
|
||||
"How often PoolStatsReporter logs executor + Hikari stats. 0 to disable.")
|
||||
.defineInRange("pool_stats_interval_minutes", 5, 0, 1440);
|
||||
HIKARI_POOL_MAX_SIZE = B.comment(
|
||||
"Max HikariCP connections. Empirical rule: cores*2 + spindles. Default 15 is good",
|
||||
"for typical 35-player servers on modest hardware.")
|
||||
.defineInRange("hikari_pool_max_size", 15, 1, 200);
|
||||
HIKARI_LEAK_THRESHOLD_MS = B.comment(
|
||||
"Hikari leak-detection threshold (ms). Lower = more sensitive, but false positives on",
|
||||
"slow polls. 25000 covers legitimate 15-30s poll bursts.")
|
||||
.defineInRange("hikari_leak_threshold_ms", 25000, 2000, 600000);
|
||||
B.pop();
|
||||
|
||||
// ===== [safety] =====
|
||||
B.comment("Safety guards — prevent silent data loss (new in 2.1.5)").push("safety");
|
||||
REFUSE_EMPTY_INVENTORY_WRITE = B.comment(
|
||||
"Refuse to UPDATE player_data with an empty inventory if the DB currently has non-empty",
|
||||
"data. Last-resort guard against on-disconnect wipes. Set to false only for debugging.")
|
||||
.define("refuse_empty_inventory_write", true);
|
||||
MAX_INVENTORY_SIZE_BYTES = B.comment(
|
||||
"Max serialized inventory size (bytes). Snapshots larger than this are rejected with",
|
||||
"a log entry. Protects against infinite-NBT exploits. Default 10 MB.")
|
||||
.defineInRange("max_inventory_size_bytes", 10 * 1024 * 1024, 1024, 512 * 1024 * 1024);
|
||||
SKIP_SAVES_WHEN_TPS_BELOW = B.comment(
|
||||
"Skip periodic auto-saves when the server MSPT average exceeds the value implied by this",
|
||||
"TPS threshold. 0 = never skip. Example: 15 skips periodic saves when TPS < 15.")
|
||||
.defineInRange("skip_saves_when_tps_below", 0, 0, 20);
|
||||
B.pop();
|
||||
|
||||
// ===== [observability] =====
|
||||
B.comment("Log file & diagnostics (new in 2.1.5)").push("observability");
|
||||
LOG_STRUCTURED_JSON = B.comment(
|
||||
"Emit sync.log entries as JSON objects instead of text. Enables ingestion in",
|
||||
"Loki / ELK / Splunk pipelines.")
|
||||
.define("log_structured_json", false);
|
||||
LOG_ROTATION_SIZE_MB = B.comment(
|
||||
"Max sync.log size before rotation (megabytes).")
|
||||
.defineInRange("log_rotation_size_mb", 10, 1, 1024);
|
||||
LOG_ROTATION_MAX_FILES = B.comment(
|
||||
"Keep at most N rotated sync.log files (oldest deleted).")
|
||||
.defineInRange("log_rotation_max_files", 5, 1, 100);
|
||||
B.pop();
|
||||
|
||||
COMMON_CONFIG = B.build();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,62 +0,0 @@
|
|||
package vip.fubuki.playersync.mixin.cobblemon;
|
||||
|
||||
import com.cobblemon.mod.common.api.storage.PokemonStore;
|
||||
import com.cobblemon.mod.common.api.storage.factory.FileBackedPokemonStoreFactory;
|
||||
import com.cobblemon.mod.common.api.storage.party.PartyStore;
|
||||
import com.cobblemon.mod.common.api.storage.pc.PCStore;
|
||||
import kotlin.jvm.functions.Function1;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.Unique;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.Redirect;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
import vip.fubuki.playersync.util.JDBCsetUp;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.util.UUID;
|
||||
|
||||
@Mixin(FileBackedPokemonStoreFactory.class)
|
||||
public class MixinFileBackedPokemonStoreFactory {
|
||||
@Unique
|
||||
RegistryAccess playerSync$registryAccess;
|
||||
|
||||
@Inject(method = "getStore", at = @At("HEAD"))
|
||||
private <T extends PokemonStore<?>> void getStore$playerSync(Class<T> storeClass, UUID uuid, RegistryAccess registryAccess, Function1<? super UUID, ? extends T> constructor, CallbackInfoReturnable<T> cir){
|
||||
this.playerSync$registryAccess = registryAccess;
|
||||
}
|
||||
|
||||
@Redirect(method = "getStore", at = @At(value = "INVOKE", target = "Lcom/cobblemon/mod/common/api/storage/PokemonStore;initialize()V"))
|
||||
private void getStore$playerSync(PokemonStore<?> instance) {
|
||||
|
||||
String column;
|
||||
if(instance instanceof PCStore){
|
||||
column = "pc";
|
||||
} else if(instance instanceof PartyStore){
|
||||
column = "inv";
|
||||
}else {
|
||||
instance.initialize();
|
||||
return;
|
||||
}
|
||||
|
||||
String sql = "SELECT " + column + " FROM cobblemon WHERE uuid = '" + instance.getUuid() + "'";
|
||||
|
||||
try {
|
||||
JDBCsetUp.QueryResult qr = vip.fubuki.playersync.util.JDBCsetUp.executeQuery(sql);
|
||||
ResultSet rs = qr.resultSet();
|
||||
if (rs.next() && rs.getString(column) != null) {
|
||||
CompoundTag compoundTag = new CompoundTag();
|
||||
instance.loadFromNBT(compoundTag, playerSync$registryAccess);
|
||||
}
|
||||
|
||||
rs.close();
|
||||
qr.close();
|
||||
} catch (java.sql.SQLException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
instance.initialize();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
package vip.fubuki.playersync.mixin.cobblemon;
|
||||
|
||||
import com.cobblemon.mod.common.api.pokedex.PokedexManager;
|
||||
import com.cobblemon.mod.common.api.storage.player.InstancedPlayerData;
|
||||
import com.cobblemon.mod.common.api.storage.player.adapter.NbtBackedPlayerData;
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.DataResult;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.nbt.NbtOps;
|
||||
import net.minecraft.nbt.NbtUtils;
|
||||
import net.minecraft.nbt.Tag;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
import vip.fubuki.playersync.mixin.cobblemon.accessor.FileBasedPlayerDataStoreBackendAccessor;
|
||||
import vip.fubuki.playersync.mixin.cobblemon.accessor.NbtBackedPlayerDataAccessor;
|
||||
import vip.fubuki.playersync.util.JDBCsetUp;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.UUID;
|
||||
|
||||
@Mixin(NbtBackedPlayerData.class)
|
||||
public class MixinNbtBackedPlayerData {
|
||||
|
||||
@Inject(method = "save", at = @org.spongepowered.asm.mixin.injection.At("HEAD"))
|
||||
private void save$playerSync(InstancedPlayerData playerData, CallbackInfo ci) {
|
||||
if(playerData instanceof PokedexManager){
|
||||
Codec<InstancedPlayerData> codec = ((NbtBackedPlayerDataAccessor)this).getCodec();
|
||||
DataResult<Tag> encodeResult = codec.encodeStart(
|
||||
NbtOps.INSTANCE,
|
||||
playerData
|
||||
);
|
||||
|
||||
CompoundTag nbt = (CompoundTag) encodeResult.result().orElseThrow();
|
||||
|
||||
String serializedData = nbt.toString();
|
||||
String sql = "INSERT INTO cobblemon (uuid, pokedex) VALUES ('" + playerData.getUuid() + "', '" + serializedData + "') " +
|
||||
"ON DUPLICATE KEY UPDATE pokedex = '" + serializedData + "'";
|
||||
try {
|
||||
JDBCsetUp.executeUpdate(sql);
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Inject(method = "load", at = @org.spongepowered.asm.mixin.injection.At("HEAD"), cancellable = true)
|
||||
private void load$playerSync(UUID uuid, CallbackInfoReturnable<InstancedPlayerData> cir){
|
||||
if(!((FileBasedPlayerDataStoreBackendAccessor) this).getType().getId().equals(ResourceLocation.fromNamespaceAndPath("cobblemon", "pokedex"))){
|
||||
return;
|
||||
}
|
||||
|
||||
String sql = "SELECT pokedex FROM cobblemon WHERE uuid = '" + uuid + "'";
|
||||
CompoundTag loadedNbt;
|
||||
try {
|
||||
JDBCsetUp.QueryResult qr = JDBCsetUp.executeQuery(sql);
|
||||
ResultSet rs = qr.resultSet();
|
||||
if (rs.next()) {
|
||||
String serializedData = rs.getString("pokedex");
|
||||
|
||||
if(serializedData == null){
|
||||
rs.close();
|
||||
qr.close();
|
||||
return;
|
||||
}
|
||||
|
||||
loadedNbt = NbtUtils.snbtToStructure(serializedData);
|
||||
|
||||
if(!loadedNbt.isEmpty()){
|
||||
Codec<InstancedPlayerData> codec = ((NbtBackedPlayerDataAccessor)this).getCodec();
|
||||
DataResult<InstancedPlayerData> decodeResult = codec.parse(
|
||||
NbtOps.INSTANCE,
|
||||
loadedNbt
|
||||
);
|
||||
InstancedPlayerData playerData = decodeResult.result().orElseThrow();
|
||||
cir.setReturnValue(playerData);
|
||||
}
|
||||
}
|
||||
|
||||
rs.close();
|
||||
qr.close();
|
||||
} catch (SQLException | CommandSyntaxException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
package vip.fubuki.playersync.mixin.cobblemon;
|
||||
|
||||
import com.cobblemon.mod.common.api.storage.pc.PCStore;
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.nbt.TagParser;
|
||||
import org.spongepowered.asm.mixin.Final;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.Shadow;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.ModifyVariable;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
import vip.fubuki.playersync.util.JDBCsetUp;
|
||||
import vip.fubuki.playersync.util.LocalJsonUtil;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.UUID;
|
||||
|
||||
@Mixin(PCStore.class)
|
||||
public class MixinPCStore {
|
||||
@Final
|
||||
@Shadow
|
||||
private UUID uuid;
|
||||
|
||||
@Inject(method = "saveToNBT",at = @At("TAIL"))
|
||||
private void saveToNBT$playerSync(CompoundTag nbt, RegistryAccess registryAccess, CallbackInfoReturnable<CompoundTag> cir) {
|
||||
String serializedData = nbt.toString();
|
||||
String sql = "INSERT INTO cobblemon (uuid, pc) VALUES ('" + this.uuid.toString() + "', '" + serializedData + "') " +
|
||||
"ON DUPLICATE KEY UPDATE pc = '" + serializedData + "'";
|
||||
try {
|
||||
JDBCsetUp.executeUpdate(sql);
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@ModifyVariable(method = "loadFromNBT", at = @At("HEAD"), argsOnly = true, name = "arg1")
|
||||
private CompoundTag loadFromNBT$playerSync(CompoundTag value) {
|
||||
String sql = "SELECT pc FROM cobblemon WHERE uuid = '" + this.uuid.toString() + "'";
|
||||
CompoundTag loadedNbt = value;
|
||||
try {
|
||||
JDBCsetUp.QueryResult qr = JDBCsetUp.executeQuery(sql);
|
||||
ResultSet rs = qr.resultSet();
|
||||
if (rs.next()) {
|
||||
String serializedData = rs.getString("pc");
|
||||
loadedNbt = TagParser.parseTag(LocalJsonUtil.cleanSnbt(serializedData));
|
||||
}
|
||||
|
||||
rs.close();
|
||||
qr.close();
|
||||
} catch (SQLException | CommandSyntaxException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
return loadedNbt;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
package vip.fubuki.playersync.mixin.cobblemon;
|
||||
|
||||
import com.cobblemon.mod.common.api.storage.party.PartyStore;
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.nbt.TagParser;
|
||||
import org.spongepowered.asm.mixin.Final;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.Shadow;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.ModifyVariable;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
import vip.fubuki.playersync.util.JDBCsetUp;
|
||||
import vip.fubuki.playersync.util.LocalJsonUtil;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.UUID;
|
||||
|
||||
@Mixin(PartyStore.class)
|
||||
public class MixinPartyStore {
|
||||
@Final
|
||||
@Shadow
|
||||
private UUID uuid;
|
||||
|
||||
@Inject(method = "saveToNBT",at = @At("TAIL"))
|
||||
private void saveToNBT$playerSync(CompoundTag nbt, RegistryAccess registryAccess, CallbackInfoReturnable<CompoundTag> cir) {
|
||||
String serializedData = nbt.toString();
|
||||
String sql = "INSERT INTO cobblemon (uuid, inv) VALUES ('" + this.uuid.toString() + "', '" + serializedData + "') " +
|
||||
"ON DUPLICATE KEY UPDATE inv = '" + serializedData + "'";
|
||||
try {
|
||||
JDBCsetUp.executeUpdate(sql);
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@ModifyVariable(method = "loadFromNBT*", at = @At("HEAD"), argsOnly = true, name = "arg1")
|
||||
private CompoundTag loadFromNBT$playerSync(CompoundTag value) {
|
||||
String sql = "SELECT inv FROM cobblemon WHERE uuid = '" + this.uuid.toString() + "'";
|
||||
CompoundTag loadedNbt = value;
|
||||
try {
|
||||
JDBCsetUp.QueryResult qr = JDBCsetUp.executeQuery(sql);
|
||||
ResultSet rs = qr.resultSet();
|
||||
if (rs.next()) {
|
||||
String serializedData = rs.getString("inv");
|
||||
loadedNbt = TagParser.parseTag(LocalJsonUtil.cleanSnbt(serializedData));
|
||||
}
|
||||
|
||||
rs.close();
|
||||
qr.close();
|
||||
} catch (SQLException | CommandSyntaxException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
return loadedNbt;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
package vip.fubuki.playersync.mixin.cobblemon.accessor;
|
||||
|
||||
import com.cobblemon.mod.common.api.storage.player.PlayerInstancedDataStoreType;
|
||||
import com.cobblemon.mod.common.api.storage.player.adapter.FileBasedPlayerDataStoreBackend;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.gen.Accessor;
|
||||
|
||||
@Mixin(FileBasedPlayerDataStoreBackend.class)
|
||||
public interface FileBasedPlayerDataStoreBackendAccessor {
|
||||
@Accessor
|
||||
PlayerInstancedDataStoreType getType();
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
package vip.fubuki.playersync.mixin.cobblemon.accessor;
|
||||
|
||||
import com.cobblemon.mod.common.api.storage.player.InstancedPlayerData;
|
||||
import com.cobblemon.mod.common.api.storage.player.adapter.DexDataNbtBackend;
|
||||
import com.mojang.serialization.Codec;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.gen.Accessor;
|
||||
|
||||
@Mixin(DexDataNbtBackend.class)
|
||||
public interface NbtBackedPlayerDataAccessor {
|
||||
@Accessor("codec")
|
||||
Codec<InstancedPlayerData> getCodec();
|
||||
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
package vip.fubuki.playersync.sync;
|
||||
|
||||
import com.mojang.logging.LogUtils;
|
||||
import net.neoforged.neoforge.common.NeoForge;
|
||||
import org.slf4j.Logger;
|
||||
import vip.fubuki.playersync.config.JdbcConfig;
|
||||
import vip.fubuki.playersync.sync.chat.ChatSyncClient;
|
||||
import vip.fubuki.playersync.sync.chat.ChatSyncServer;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class ChatSync {
|
||||
public static final Logger LOGGER = LogUtils.getLogger();
|
||||
private static ChatSyncServer chatSyncServer;
|
||||
private static ChatSyncClient chatSyncClient;
|
||||
|
||||
public static void register(){
|
||||
if(JdbcConfig.IS_CHAT_SERVER.get()) {
|
||||
LOGGER.info("Trying to setup chat server at port " + JdbcConfig.CHAT_SERVER_PORT.get());
|
||||
new Thread(()->{
|
||||
chatSyncServer = new ChatSyncServer();
|
||||
try {
|
||||
chatSyncServer.run();
|
||||
} catch (IOException e) {
|
||||
LOGGER.error("Unable to start chat server", e);
|
||||
}
|
||||
}, "ChatSync-Server").start();
|
||||
}
|
||||
|
||||
new Thread(()->{
|
||||
try {
|
||||
Thread.sleep(2000);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
|
||||
LOGGER.info("Trying to connect to chat server "
|
||||
+ JdbcConfig.CHAT_SERVER_IP.get()
|
||||
+ ":"
|
||||
+ JdbcConfig.CHAT_SERVER_PORT.get());
|
||||
chatSyncClient = new ChatSyncClient();
|
||||
chatSyncClient.run();
|
||||
}, "ChatSync-Client").start();
|
||||
NeoForge.EVENT_BUS.register(ChatSyncClient.class);
|
||||
}
|
||||
|
||||
public static void shutdown() {
|
||||
if (chatSyncServer != null) {
|
||||
chatSyncServer.shutdown();
|
||||
}
|
||||
if (chatSyncClient != null) {
|
||||
chatSyncClient.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -36,8 +36,11 @@ public class CuriosCache {
|
|||
//Create a method to store temporary curios data when player is dead.
|
||||
//Then check player status in the logged out event,and take a normal sync if player is alive.
|
||||
//If player is dead or dying,the cache will be used to prevent the empty data from the failure of getting handlerOpt.
|
||||
// FIX H-5: Cache curios on death regardless of keepInventory. Without this,
|
||||
// players on servers WITHOUT keepInventory who die then disconnect before respawning
|
||||
// would have their curios data overwritten with empty data (Curios API returns empty for dead players).
|
||||
public static void tryStoreCuriosToCache(net.minecraft.world.entity.player.Player player) {
|
||||
if (!ModList.get().isLoaded("curios") || !CuriosCache.isKeepInventoryActive(player)) {
|
||||
if (!ModList.get().isLoaded("curios")) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -71,8 +74,18 @@ public class CuriosCache {
|
|||
for (int i = 0; i < dynStacks.getSlots(); i++) {
|
||||
ItemStack stack = dynStacks.getStackInSlot(i);
|
||||
if (!stack.isEmpty()) {
|
||||
String serialized = VanillaSync.getNbtForStorage(stack);
|
||||
flatMap.put(slotType + ":" + i, serialized);
|
||||
flatMap.put(slotType + ":" + i, VanillaSync.getNbtForStorage(stack));
|
||||
}
|
||||
}
|
||||
// FIX A2: capture cosmetic stacks in the death cache, matching the
|
||||
// snapshot/apply format ("cos:slotType:index"). Without this, a player
|
||||
// who died with a cosmetic curio would lose it on rejoin because the
|
||||
// apply path clears cosmetic slots unconditionally.
|
||||
IDynamicStackHandler cosStacks = stacksHandler.getCosmeticStacks();
|
||||
for (int i = 0; i < cosStacks.getSlots(); i++) {
|
||||
ItemStack stack = cosStacks.getStackInSlot(i);
|
||||
if (!stack.isEmpty()) {
|
||||
flatMap.put("cos:" + slotType + ":" + i, VanillaSync.getNbtForStorage(stack));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,644 @@
|
|||
package vip.fubuki.playersync.sync.addons;
|
||||
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.neoforged.fml.ModList;
|
||||
import vip.fubuki.playersync.PlayerSync;
|
||||
import vip.fubuki.playersync.sync.VanillaSync;
|
||||
import vip.fubuki.playersync.util.JDBCsetUp;
|
||||
import vip.fubuki.playersync.util.LocalJsonUtil;
|
||||
import vip.fubuki.playersync.util.Tables;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Mod compatibility handlers for syncing player data from:
|
||||
* - Accessories API (used by The Aether for pendant, cape, gloves, rings, etc.)
|
||||
* - Cosmetic Armor Reworked (4 cosmetic armor slots)
|
||||
* - Apotheosis (item DataComponents travel with inventory - automatic)
|
||||
*/
|
||||
public class ModCompatSync {
|
||||
|
||||
// FIX PERF (C4): Cache reflection Method lookups for NeoForge AttachmentHolder.
|
||||
// Previously resolved on every snapshot/apply (35 players × auto-save = thousands of
|
||||
// reflective lookups / hour). Static-init once, reuse forever.
|
||||
private static final java.lang.reflect.Method SERIALIZE_ATTACHMENTS;
|
||||
private static final java.lang.reflect.Method DESERIALIZE_ATTACHMENTS;
|
||||
static {
|
||||
java.lang.reflect.Method ser = null, des = null;
|
||||
try {
|
||||
ser = net.neoforged.neoforge.attachment.AttachmentHolder.class
|
||||
.getDeclaredMethod("serializeAttachments", net.minecraft.core.HolderLookup.Provider.class);
|
||||
ser.setAccessible(true);
|
||||
des = net.neoforged.neoforge.attachment.AttachmentHolder.class
|
||||
.getDeclaredMethod("deserializeAttachments",
|
||||
net.minecraft.core.HolderLookup.Provider.class,
|
||||
net.minecraft.nbt.CompoundTag.class);
|
||||
des.setAccessible(true);
|
||||
} catch (NoSuchMethodException e) {
|
||||
PlayerSync.LOGGER.error("[PlayerSync] Could not cache AttachmentHolder reflection methods; NeoForge attachment sync will be disabled.", e);
|
||||
}
|
||||
SERIALIZE_ATTACHMENTS = ser;
|
||||
DESERIALIZE_ATTACHMENTS = des;
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Accessories API (Aether slots)
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Saves Accessories inventory (used by The Aether and other mods).
|
||||
* Works identically to Curios sync but uses the Accessories API.
|
||||
*/
|
||||
public static void storeAccessories(Player player) {
|
||||
if (!ModList.get().isLoaded("accessories")) return;
|
||||
|
||||
try {
|
||||
Map<String, String> flatMap = new HashMap<>();
|
||||
|
||||
io.wispforest.accessories.api.AccessoriesCapability cap =
|
||||
io.wispforest.accessories.api.AccessoriesCapability.get(player);
|
||||
if (cap == null) {
|
||||
PlayerSync.LOGGER.debug("No Accessories capability for player {}", player.getUUID());
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, io.wispforest.accessories.api.AccessoriesContainer> containers = cap.getContainers();
|
||||
for (Map.Entry<String, io.wispforest.accessories.api.AccessoriesContainer> entry : containers.entrySet()) {
|
||||
String slotType = entry.getKey();
|
||||
io.wispforest.accessories.api.AccessoriesContainer container = entry.getValue();
|
||||
var accessories = container.getAccessories();
|
||||
for (int i = 0; i < accessories.getContainerSize(); i++) {
|
||||
ItemStack stack = accessories.getItem(i);
|
||||
if (!stack.isEmpty()) {
|
||||
flatMap.put(slotType + ":" + i, VanillaSync.getNbtForStorage(stack));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String serializedData = flatMap.toString();
|
||||
JDBCsetUp.executePreparedUpdate(
|
||||
"REPLACE INTO " + Tables.modPlayerData() + " (uuid, mod_id, data_value) VALUES (?, ?, ?)",
|
||||
player.getUUID().toString(), "accessories", serializedData);
|
||||
PlayerSync.LOGGER.debug("Saved Accessories data for player {}", player.getUUID());
|
||||
|
||||
} catch (Exception e) {
|
||||
PlayerSync.LOGGER.error("Error saving Accessories data for player {}", player.getUUID(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores Accessories inventory for a player.
|
||||
* Same logic as Curios restore: validate data before clearing, then restore items.
|
||||
*/
|
||||
public static void restoreAccessories(Player player) {
|
||||
if (!ModList.get().isLoaded("accessories")) return;
|
||||
|
||||
try {
|
||||
io.wispforest.accessories.api.AccessoriesCapability cap =
|
||||
io.wispforest.accessories.api.AccessoriesCapability.get(player);
|
||||
if (cap == null) {
|
||||
PlayerSync.LOGGER.debug("No Accessories capability for player {}", player.getUUID());
|
||||
return;
|
||||
}
|
||||
|
||||
String accessoriesData;
|
||||
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
||||
"SELECT data_value FROM " + Tables.modPlayerData() + " WHERE uuid=? AND mod_id=?",
|
||||
player.getUUID().toString(), "accessories")) {
|
||||
ResultSet rs = qr.resultSet();
|
||||
if (!rs.next()) {
|
||||
// No data yet, perform initial save
|
||||
storeAccessories(player);
|
||||
return;
|
||||
}
|
||||
accessoriesData = rs.getString("data_value");
|
||||
}
|
||||
|
||||
// Validate data before clearing
|
||||
if (accessoriesData == null || accessoriesData.length() <= 2) {
|
||||
PlayerSync.LOGGER.debug("Empty Accessories data for player {}, skipping restore", player.getUUID());
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, String> storedMap = LocalJsonUtil.StringToMap(accessoriesData);
|
||||
if (storedMap.isEmpty()) return;
|
||||
|
||||
Map<String, io.wispforest.accessories.api.AccessoriesContainer> containers = cap.getContainers();
|
||||
|
||||
// Clear all Accessories slots ONLY after confirming valid data
|
||||
for (io.wispforest.accessories.api.AccessoriesContainer container : containers.values()) {
|
||||
var accessories = container.getAccessories();
|
||||
for (int i = 0; i < accessories.getContainerSize(); i++) {
|
||||
accessories.setItem(i, ItemStack.EMPTY);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore items
|
||||
for (Map.Entry<String, String> entry : storedMap.entrySet()) {
|
||||
String compositeKey = entry.getKey();
|
||||
int lastColon = compositeKey.lastIndexOf(':');
|
||||
if (lastColon < 0) continue;
|
||||
|
||||
String slotType = compositeKey.substring(0, lastColon);
|
||||
int slotIndex;
|
||||
try {
|
||||
slotIndex = Integer.parseInt(compositeKey.substring(lastColon + 1));
|
||||
} catch (NumberFormatException ex) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
ItemStack stack = VanillaSync.deserializeAndCreatePlaceholderIfNeeded(entry.getValue());
|
||||
if (containers.containsKey(slotType)) {
|
||||
var accessories = containers.get(slotType).getAccessories();
|
||||
if (slotIndex < accessories.getContainerSize()) {
|
||||
accessories.setItem(slotIndex, stack);
|
||||
}
|
||||
}
|
||||
} catch (CommandSyntaxException e) {
|
||||
PlayerSync.LOGGER.error("Error deserializing Accessories data for key {}. Skipping.", compositeKey, e);
|
||||
} catch (Exception e) {
|
||||
PlayerSync.LOGGER.error("Unexpected error restoring Accessories data for key {}. Skipping.", compositeKey, e);
|
||||
}
|
||||
}
|
||||
|
||||
PlayerSync.LOGGER.debug("Restored Accessories data for player {}", player.getUUID());
|
||||
} catch (Exception e) {
|
||||
PlayerSync.LOGGER.error("Error restoring Accessories data for player {}", player.getUUID(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies pre-read Accessories data to the player entity (NO DB access).
|
||||
* Used by doPlayerJoin to avoid DB reads on the main thread.
|
||||
*/
|
||||
public static void applyAccessoriesFromData(Player player, String accessoriesData) {
|
||||
if (!ModList.get().isLoaded("accessories")) return;
|
||||
if (!vip.fubuki.playersync.config.JdbcConfig.SYNC_ACCESSORIES.get()) return; // PHASE 8: toggle
|
||||
try {
|
||||
io.wispforest.accessories.api.AccessoriesCapability cap =
|
||||
io.wispforest.accessories.api.AccessoriesCapability.get(player);
|
||||
if (cap == null) return;
|
||||
|
||||
Map<String, io.wispforest.accessories.api.AccessoriesContainer> containers = cap.getContainers();
|
||||
|
||||
// FIX ANTI-DUPLICATION: ALWAYS clear accessories slots first to wipe stale
|
||||
// data from Minecraft's .dat file, then only restore if DB has valid data.
|
||||
for (io.wispforest.accessories.api.AccessoriesContainer container : containers.values()) {
|
||||
var accessories = container.getAccessories();
|
||||
for (int i = 0; i < accessories.getContainerSize(); i++) {
|
||||
accessories.setItem(i, ItemStack.EMPTY);
|
||||
}
|
||||
}
|
||||
|
||||
if (accessoriesData == null || accessoriesData.length() <= 2) return;
|
||||
|
||||
Map<String, String> storedMap = LocalJsonUtil.StringToMap(accessoriesData);
|
||||
if (storedMap.isEmpty()) return;
|
||||
|
||||
for (Map.Entry<String, String> entry : storedMap.entrySet()) {
|
||||
String compositeKey = entry.getKey();
|
||||
int lastColon = compositeKey.lastIndexOf(':');
|
||||
if (lastColon < 0) continue;
|
||||
String slotType = compositeKey.substring(0, lastColon);
|
||||
int slotIndex;
|
||||
try { slotIndex = Integer.parseInt(compositeKey.substring(lastColon + 1)); }
|
||||
catch (NumberFormatException ex) { continue; }
|
||||
|
||||
try {
|
||||
ItemStack stack = VanillaSync.deserializeAndCreatePlaceholderIfNeeded(entry.getValue());
|
||||
if (containers.containsKey(slotType)) {
|
||||
var acc = containers.get(slotType).getAccessories();
|
||||
if (slotIndex < acc.getContainerSize()) {
|
||||
acc.setItem(slotIndex, stack);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
PlayerSync.LOGGER.error("Error applying Accessories data for key {}", compositeKey, e);
|
||||
}
|
||||
}
|
||||
PlayerSync.LOGGER.info("Applied Accessories data for player {}", player.getUUID());
|
||||
} catch (Exception e) {
|
||||
PlayerSync.LOGGER.error("Error applying Accessories data for player {}", player.getUUID(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Cosmetic Armor Reworked
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Saves Cosmetic Armor slots (4 cosmetic equipment slots: head, chest, legs, feet).
|
||||
*/
|
||||
public static void storeCosmeticArmor(Player player) {
|
||||
if (!ModList.get().isLoaded("cosmeticarmorreworked")) return;
|
||||
|
||||
try {
|
||||
Map<Integer, String> flatMap = new HashMap<>();
|
||||
|
||||
lain.mods.cos.impl.inventory.InventoryCosArmor cosInv =
|
||||
lain.mods.cos.impl.ModObjects.invMan.getCosArmorInventory(player.getUUID());
|
||||
if (cosInv == null) {
|
||||
PlayerSync.LOGGER.debug("No CosmeticArmor inventory for player {}", player.getUUID());
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < cosInv.getContainerSize(); i++) {
|
||||
ItemStack stack = cosInv.getItem(i);
|
||||
if (!stack.isEmpty()) {
|
||||
flatMap.put(i, VanillaSync.getNbtForStorage(stack));
|
||||
}
|
||||
}
|
||||
|
||||
String serializedData = flatMap.toString();
|
||||
JDBCsetUp.executePreparedUpdate(
|
||||
"REPLACE INTO " + Tables.modPlayerData() + " (uuid, mod_id, data_value) VALUES (?, ?, ?)",
|
||||
player.getUUID().toString(), "cosmeticarmor", serializedData);
|
||||
PlayerSync.LOGGER.debug("Saved CosmeticArmor data for player {}", player.getUUID());
|
||||
|
||||
} catch (Exception e) {
|
||||
PlayerSync.LOGGER.error("Error saving CosmeticArmor data for player {}", player.getUUID(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores Cosmetic Armor slots for a player.
|
||||
*/
|
||||
public static void restoreCosmeticArmor(Player player) {
|
||||
if (!ModList.get().isLoaded("cosmeticarmorreworked")) return;
|
||||
|
||||
try {
|
||||
lain.mods.cos.impl.inventory.InventoryCosArmor cosInv =
|
||||
lain.mods.cos.impl.ModObjects.invMan.getCosArmorInventory(player.getUUID());
|
||||
if (cosInv == null) {
|
||||
PlayerSync.LOGGER.debug("No CosmeticArmor inventory for player {}", player.getUUID());
|
||||
return;
|
||||
}
|
||||
|
||||
String cosmeticData;
|
||||
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
||||
"SELECT data_value FROM " + Tables.modPlayerData() + " WHERE uuid=? AND mod_id=?",
|
||||
player.getUUID().toString(), "cosmeticarmor")) {
|
||||
ResultSet rs = qr.resultSet();
|
||||
if (!rs.next()) {
|
||||
// No data yet, perform initial save
|
||||
storeCosmeticArmor(player);
|
||||
return;
|
||||
}
|
||||
cosmeticData = rs.getString("data_value");
|
||||
}
|
||||
|
||||
// Validate before clearing
|
||||
if (cosmeticData == null || cosmeticData.length() <= 2) {
|
||||
PlayerSync.LOGGER.debug("Empty CosmeticArmor data for player {}, skipping restore", player.getUUID());
|
||||
return;
|
||||
}
|
||||
|
||||
Map<Integer, String> storedMap = LocalJsonUtil.StringToEntryMap(cosmeticData);
|
||||
if (storedMap.isEmpty()) return;
|
||||
|
||||
// Clear cosmetic armor slots
|
||||
for (int i = 0; i < cosInv.getContainerSize(); i++) {
|
||||
cosInv.setItem(i, ItemStack.EMPTY);
|
||||
}
|
||||
|
||||
// Restore items
|
||||
for (Map.Entry<Integer, String> entry : storedMap.entrySet()) {
|
||||
int slot = entry.getKey();
|
||||
try {
|
||||
ItemStack stack = VanillaSync.deserializeAndCreatePlaceholderIfNeeded(entry.getValue());
|
||||
if (slot < cosInv.getContainerSize()) {
|
||||
cosInv.setItem(slot, stack);
|
||||
}
|
||||
} catch (CommandSyntaxException e) {
|
||||
PlayerSync.LOGGER.error("Error deserializing CosmeticArmor slot {}. Skipping.", slot, e);
|
||||
} catch (Exception e) {
|
||||
PlayerSync.LOGGER.error("Unexpected error restoring CosmeticArmor slot {}. Skipping.", slot, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark the inventory as changed so the mod syncs to the client
|
||||
cosInv.setChanged();
|
||||
PlayerSync.LOGGER.debug("Restored CosmeticArmor data for player {}", player.getUUID());
|
||||
|
||||
} catch (Exception e) {
|
||||
PlayerSync.LOGGER.error("Error restoring CosmeticArmor data for player {}", player.getUUID(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies pre-read CosmeticArmor data to the player entity (NO DB access).
|
||||
*/
|
||||
public static void applyCosmeticArmorFromData(Player player, String cosmeticArmorData) {
|
||||
if (!ModList.get().isLoaded("cosmeticarmorreworked")) return;
|
||||
if (!vip.fubuki.playersync.config.JdbcConfig.SYNC_COSMETIC_ARMOR.get()) return; // PHASE 8: toggle
|
||||
try {
|
||||
lain.mods.cos.impl.inventory.InventoryCosArmor cosInv =
|
||||
lain.mods.cos.impl.ModObjects.invMan.getCosArmorInventory(player.getUUID());
|
||||
if (cosInv == null) return;
|
||||
|
||||
// FIX ANTI-DUPLICATION: ALWAYS clear cosmetic armor slots first to wipe stale
|
||||
// data from Minecraft's .dat file, then only restore if DB has valid data.
|
||||
for (int i = 0; i < cosInv.getContainerSize(); i++) {
|
||||
cosInv.setItem(i, ItemStack.EMPTY);
|
||||
}
|
||||
|
||||
if (cosmeticArmorData == null || cosmeticArmorData.length() <= 2) return;
|
||||
|
||||
Map<Integer, String> storedMap = LocalJsonUtil.StringToEntryMap(cosmeticArmorData);
|
||||
if (storedMap.isEmpty()) return;
|
||||
|
||||
for (Map.Entry<Integer, String> entry : storedMap.entrySet()) {
|
||||
int slot = entry.getKey();
|
||||
try {
|
||||
ItemStack stack = VanillaSync.deserializeAndCreatePlaceholderIfNeeded(entry.getValue());
|
||||
if (slot < cosInv.getContainerSize()) {
|
||||
cosInv.setItem(slot, stack);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
PlayerSync.LOGGER.error("Error applying CosmeticArmor slot {}", slot, e);
|
||||
}
|
||||
}
|
||||
cosInv.setChanged();
|
||||
PlayerSync.LOGGER.info("Applied CosmeticArmor data for player {}", player.getUUID());
|
||||
} catch (Exception e) {
|
||||
PlayerSync.LOGGER.error("Error applying CosmeticArmor data for player {}", player.getUUID(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Generic NeoForge Attachment Sync
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Saves ALL NeoForge player attachments to the database.
|
||||
* This covers per-player data from ALL mods, including:
|
||||
* - Ars Nouveau (mana, glyph knowledge)
|
||||
* - Iron's Spellbooks (mana, learned spells)
|
||||
* - Pehkui (player scale)
|
||||
* - Spice of Life: Onion (food diversity)
|
||||
* - Any other mod using NeoForge's attachment system
|
||||
*
|
||||
* Uses player.saveWithoutId() to extract the attachments tag from the
|
||||
* player's full serialized NBT, ensuring we capture ALL mod data.
|
||||
*/
|
||||
public static void storeNeoForgeAttachments(Player player) {
|
||||
try {
|
||||
if (!(player instanceof net.minecraft.server.level.ServerPlayer serverPlayer)) return;
|
||||
if (SERIALIZE_ATTACHMENTS == null) return;
|
||||
|
||||
net.minecraft.nbt.CompoundTag attachments = (net.minecraft.nbt.CompoundTag)
|
||||
SERIALIZE_ATTACHMENTS.invoke(player, serverPlayer.getServer().registryAccess());
|
||||
|
||||
if (attachments != null && !attachments.isEmpty()) {
|
||||
String serialized = VanillaSync.serializeTagToBinaryBase64(attachments);
|
||||
JDBCsetUp.executePreparedUpdate(
|
||||
"REPLACE INTO " + Tables.modPlayerData() + " (uuid, mod_id, data_value) VALUES (?, ?, ?)",
|
||||
player.getUUID().toString(), "neoforge_attachments", serialized);
|
||||
PlayerSync.LOGGER.debug("Saved NeoForge attachments for player {} ({} keys)",
|
||||
player.getUUID(), attachments.getAllKeys().size());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
PlayerSync.LOGGER.error("Error saving NeoForge attachments for player {}", player.getUUID(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores NeoForge player attachments from the database.
|
||||
* Uses reflection to call NeoForge's internal deserializeAttachments method,
|
||||
* which ensures the exact same deserialization path as a normal player load.
|
||||
*
|
||||
* FIX: The method signature is deserializeAttachments(HolderLookup.Provider, CompoundTag),
|
||||
* NOT deserializeAttachments(CompoundTag). The old code passed wrong parameters causing
|
||||
* silent failure - no NeoForge attachment data (SOL Onion, Ars Nouveau, etc.) was restored.
|
||||
*/
|
||||
public static void restoreNeoForgeAttachments(Player player) {
|
||||
try {
|
||||
if (!(player instanceof net.minecraft.server.level.ServerPlayer serverPlayer)) return;
|
||||
if (DESERIALIZE_ATTACHMENTS == null) return;
|
||||
|
||||
String serialized;
|
||||
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
||||
"SELECT data_value FROM " + Tables.modPlayerData() + " WHERE uuid=? AND mod_id=?",
|
||||
player.getUUID().toString(), "neoforge_attachments")) {
|
||||
ResultSet rs = qr.resultSet();
|
||||
if (!rs.next()) return;
|
||||
serialized = rs.getString("data_value");
|
||||
}
|
||||
|
||||
if (serialized == null || !serialized.startsWith("BNBT:")) return;
|
||||
|
||||
net.minecraft.nbt.CompoundTag attachments = VanillaSync.deserializeBinaryBase64Tag(serialized);
|
||||
if (attachments.isEmpty()) return;
|
||||
|
||||
net.minecraft.nbt.CompoundTag wrapper = new net.minecraft.nbt.CompoundTag();
|
||||
wrapper.put("neoforge:attachments", attachments);
|
||||
|
||||
DESERIALIZE_ATTACHMENTS.invoke(player, serverPlayer.getServer().registryAccess(), wrapper);
|
||||
|
||||
PlayerSync.LOGGER.debug("Restored NeoForge attachments for player {} ({} keys)",
|
||||
player.getUUID(), attachments.getAllKeys().size());
|
||||
} catch (Exception e) {
|
||||
PlayerSync.LOGGER.error("Error restoring NeoForge attachments for player {}", player.getUUID(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies pre-read NeoForge attachments data to the player entity (NO DB access).
|
||||
*/
|
||||
public static void applyAttachmentsFromData(Player player, String serialized) {
|
||||
if (serialized == null || !serialized.startsWith("BNBT:")) return;
|
||||
if (DESERIALIZE_ATTACHMENTS == null) return;
|
||||
try {
|
||||
if (!(player instanceof net.minecraft.server.level.ServerPlayer serverPlayer)) return;
|
||||
|
||||
net.minecraft.nbt.CompoundTag attachments = VanillaSync.deserializeBinaryBase64Tag(serialized);
|
||||
if (attachments.isEmpty()) return;
|
||||
|
||||
net.minecraft.nbt.CompoundTag wrapper = new net.minecraft.nbt.CompoundTag();
|
||||
wrapper.put("neoforge:attachments", attachments);
|
||||
|
||||
DESERIALIZE_ATTACHMENTS.invoke(player, serverPlayer.getServer().registryAccess(), wrapper);
|
||||
|
||||
PlayerSync.LOGGER.info("Applied NeoForge attachments for player {} ({} keys)",
|
||||
player.getUUID(), attachments.getAllKeys().size());
|
||||
} catch (Exception e) {
|
||||
PlayerSync.LOGGER.error("Error applying NeoForge attachments for player {}", player.getUUID(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Snapshot methods (main thread - entity reads only, NO DB writes)
|
||||
// These are used by auto-save and SaveToFile to capture entity state on the
|
||||
// main thread, then the actual DB writes happen on a background thread.
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Captures Accessories slot data on the main thread.
|
||||
* Returns serialized string or null if mod not loaded / no data.
|
||||
*/
|
||||
public static String snapshotAccessories(Player player) {
|
||||
if (!ModList.get().isLoaded("accessories")) return null;
|
||||
try {
|
||||
io.wispforest.accessories.api.AccessoriesCapability cap =
|
||||
io.wispforest.accessories.api.AccessoriesCapability.get(player);
|
||||
// FIX ANTI-LOSS (A2): cap==null means the capability isn't attached yet —
|
||||
// return null to SKIP write and preserve DB. Do NOT return "{}" here, as that
|
||||
// would wipe a legitimate accessories record.
|
||||
if (cap == null) {
|
||||
vip.fubuki.playersync.util.SyncLogger.modCompatSkip(
|
||||
player.getUUID().toString(), "accessories",
|
||||
"capability unavailable — skipping write to preserve DB");
|
||||
return null;
|
||||
}
|
||||
Map<String, String> flatMap = new HashMap<>();
|
||||
for (Map.Entry<String, io.wispforest.accessories.api.AccessoriesContainer> entry : cap.getContainers().entrySet()) {
|
||||
String slotType = entry.getKey();
|
||||
var accessories = entry.getValue().getAccessories();
|
||||
for (int i = 0; i < accessories.getContainerSize(); i++) {
|
||||
ItemStack stack = accessories.getItem(i);
|
||||
if (!stack.isEmpty()) {
|
||||
flatMap.put(slotType + ":" + i, VanillaSync.getNbtForStorage(stack));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Cap read OK — "{}" is intentional for truly empty slots so apply clears stale .dat.
|
||||
return flatMap.toString();
|
||||
} catch (Exception e) {
|
||||
PlayerSync.LOGGER.error("Error snapshotting Accessories for player {}", player.getUUID(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures Cosmetic Armor slot data on the main thread.
|
||||
* Returns serialized string or null if mod not loaded / no data.
|
||||
*/
|
||||
public static String snapshotCosmeticArmor(Player player) {
|
||||
if (!ModList.get().isLoaded("cosmeticarmorreworked")) return null;
|
||||
try {
|
||||
lain.mods.cos.impl.inventory.InventoryCosArmor cosInv =
|
||||
lain.mods.cos.impl.ModObjects.invMan.getCosArmorInventory(player.getUUID());
|
||||
// FIX ANTI-LOSS (A2): null manager → cannot read → SKIP write, preserve DB.
|
||||
if (cosInv == null) return null;
|
||||
Map<Integer, String> flatMap = new HashMap<>();
|
||||
for (int i = 0; i < cosInv.getContainerSize(); i++) {
|
||||
ItemStack stack = cosInv.getItem(i);
|
||||
if (!stack.isEmpty()) {
|
||||
flatMap.put(i, VanillaSync.getNbtForStorage(stack));
|
||||
}
|
||||
}
|
||||
// Read OK — "{}" for truly empty slots so apply clears stale .dat.
|
||||
return flatMap.toString();
|
||||
} catch (Exception e) {
|
||||
PlayerSync.LOGGER.error("Error snapshotting CosmeticArmor for player {}", player.getUUID(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures NeoForge attachment data on the main thread via reflection.
|
||||
* Returns BNBT-serialized string or null if no data.
|
||||
*/
|
||||
public static String snapshotAttachments(Player player) {
|
||||
if (SERIALIZE_ATTACHMENTS == null) return null;
|
||||
try {
|
||||
if (!(player instanceof net.minecraft.server.level.ServerPlayer serverPlayer)) return null;
|
||||
net.minecraft.nbt.CompoundTag attachments = (net.minecraft.nbt.CompoundTag)
|
||||
SERIALIZE_ATTACHMENTS.invoke(player, serverPlayer.getServer().registryAccess());
|
||||
if (attachments == null || attachments.isEmpty()) return null;
|
||||
return VanillaSync.serializeTagToBinaryBase64(attachments);
|
||||
} catch (Exception e) {
|
||||
PlayerSync.LOGGER.error("Error snapshotting NeoForge attachments for player {}", player.getUUID(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes pre-snapshotted mod data to the DB.
|
||||
* NO entity access — safe to call from a background thread.
|
||||
*
|
||||
* @param uuid player UUID string
|
||||
* @param accessoriesData serialized Accessories slots (may be null → skipped)
|
||||
* @param cosmeticArmor serialized Cosmetic Armor slots (may be null → skipped)
|
||||
* @param attachments serialized NeoForge attachments (may be null → skipped)
|
||||
*/
|
||||
/**
|
||||
* Writes pre-snapshotted mod data to the DB, guarded by last_server to prevent
|
||||
* stale servers from overwriting fresher data after a player switched servers.
|
||||
*/
|
||||
public static void writeModSnapshot(String uuid, String accessoriesData, String cosmeticArmor, String attachments, int serverId) throws SQLException {
|
||||
// FIX ANTI-DUPLICATION: Only write if this server still owns the player.
|
||||
// Uses UPDATE + INSERT IGNORE pattern guarded by last_server subquery.
|
||||
if (accessoriesData != null) {
|
||||
writeGuardedModData(uuid, "accessories", accessoriesData, serverId);
|
||||
}
|
||||
if (cosmeticArmor != null) {
|
||||
writeGuardedModData(uuid, "cosmeticarmor", cosmeticArmor, serverId);
|
||||
}
|
||||
if (attachments != null) {
|
||||
writeGuardedModData(uuid, "neoforge_attachments", attachments, serverId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Backwards-compatible overload (no server guard — used by direct store methods). */
|
||||
public static void writeModSnapshot(String uuid, String accessoriesData, String cosmeticArmor, String attachments) throws SQLException {
|
||||
if (accessoriesData != null) {
|
||||
JDBCsetUp.executePreparedUpdate(
|
||||
"REPLACE INTO " + Tables.modPlayerData() + " (uuid, mod_id, data_value) VALUES (?, ?, ?)",
|
||||
uuid, "accessories", accessoriesData);
|
||||
}
|
||||
if (cosmeticArmor != null) {
|
||||
JDBCsetUp.executePreparedUpdate(
|
||||
"REPLACE INTO " + Tables.modPlayerData() + " (uuid, mod_id, data_value) VALUES (?, ?, ?)",
|
||||
uuid, "cosmeticarmor", cosmeticArmor);
|
||||
}
|
||||
if (attachments != null) {
|
||||
JDBCsetUp.executePreparedUpdate(
|
||||
"REPLACE INTO " + Tables.modPlayerData() + " (uuid, mod_id, data_value) VALUES (?, ?, ?)",
|
||||
uuid, "neoforge_attachments", attachments);
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeGuardedModData(String uuid, String modId, String data, int serverId) throws SQLException {
|
||||
// FIX: Handle legacy rows with last_server IS NULL (same pattern as writeSnapshotToDB)
|
||||
String serverGuard = "(last_server=? OR last_server IS NULL)";
|
||||
// Update existing row only if this server still owns the player
|
||||
JDBCsetUp.executePreparedUpdate(
|
||||
"UPDATE " + Tables.modPlayerData() + " SET data_value=? WHERE uuid=? AND mod_id=? AND EXISTS (SELECT 1 FROM " + Tables.playerData() + " WHERE uuid=? AND " + serverGuard + ")",
|
||||
data, uuid, modId, uuid, serverId);
|
||||
// Insert if row doesn't exist yet (first save)
|
||||
JDBCsetUp.executePreparedUpdate(
|
||||
"INSERT IGNORE INTO " + Tables.modPlayerData() + " (uuid, mod_id, data_value) SELECT ?, ?, ? FROM " + Tables.playerData() + " WHERE uuid=? AND " + serverGuard,
|
||||
uuid, modId, data, uuid, serverId);
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Convenience methods
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Saves all mod-specific data for a player synchronously.
|
||||
* Called on logout and server shutdown (main thread — entity reads are safe here).
|
||||
*/
|
||||
public static void storeAll(Player player) {
|
||||
storeAccessories(player);
|
||||
storeCosmeticArmor(player);
|
||||
storeNeoForgeAttachments(player);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores all mod-specific data for a player.
|
||||
* Called on join.
|
||||
*/
|
||||
public static void restoreAll(Player player) {
|
||||
restoreAccessories(player);
|
||||
restoreCosmeticArmor(player);
|
||||
restoreNeoForgeAttachments(player);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,134 +0,0 @@
|
|||
package vip.fubuki.playersync.sync.chat;
|
||||
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.players.PlayerList;
|
||||
import net.neoforged.bus.api.SubscribeEvent;
|
||||
import net.neoforged.neoforge.event.ServerChatEvent;
|
||||
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
|
||||
import vip.fubuki.playersync.PlayerSync;
|
||||
import vip.fubuki.playersync.config.JdbcConfig;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.ConnectException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.util.Objects;
|
||||
|
||||
public class ChatSyncClient {
|
||||
static PlayerList playerList;
|
||||
static Socket clientSocket;
|
||||
static PrintWriter out;
|
||||
|
||||
private static volatile boolean running = true;
|
||||
private static final int RECONNECT_DELAY = 5000;
|
||||
private static final int MAX_RECONNECT_ATTEMPTS = 10;
|
||||
|
||||
public void run() {
|
||||
int reconnectAttempts = 0;
|
||||
|
||||
while (running && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
||||
try {
|
||||
PlayerSync.LOGGER.info("Connecting to chat server {}:{}",
|
||||
JdbcConfig.CHAT_SERVER_IP.get(),
|
||||
JdbcConfig.CHAT_SERVER_PORT.get());
|
||||
|
||||
clientSocket = new Socket();
|
||||
clientSocket.setReuseAddress(true);
|
||||
clientSocket.setKeepAlive(true);
|
||||
clientSocket.setTcpNoDelay(true);
|
||||
|
||||
clientSocket.connect(
|
||||
new InetSocketAddress(
|
||||
JdbcConfig.CHAT_SERVER_IP.get(),
|
||||
JdbcConfig.CHAT_SERVER_PORT.get()
|
||||
),
|
||||
15000
|
||||
);
|
||||
|
||||
clientSocket.setSoTimeout(0);
|
||||
|
||||
out = new PrintWriter(new BufferedWriter(
|
||||
new OutputStreamWriter(clientSocket.getOutputStream())), true);
|
||||
|
||||
PlayerSync.LOGGER.info("Successfully connected to chat server");
|
||||
reconnectAttempts = 0;
|
||||
|
||||
BufferedReader in = new BufferedReader(
|
||||
new InputStreamReader(clientSocket.getInputStream()));
|
||||
|
||||
String serverMessage;
|
||||
while (running && (serverMessage = in.readLine()) != null) {
|
||||
Component textComponents = Component.nullToEmpty(serverMessage);
|
||||
if(playerList != null){
|
||||
playerList.getServer().execute(() ->
|
||||
playerList.broadcastSystemMessage(textComponents, false));
|
||||
}else {
|
||||
PlayerSync.LOGGER.info("Received message from chat server: " + serverMessage);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (SocketTimeoutException e) {
|
||||
PlayerSync.LOGGER.warn("Chat server read timeout, reconnecting...");
|
||||
} catch (ConnectException e) {
|
||||
PlayerSync.LOGGER.warn("Cannot connect to chat server: {}", e.getMessage());
|
||||
} catch (IOException e) {
|
||||
PlayerSync.LOGGER.error("Chat client connection error: {}", e.getMessage());
|
||||
} finally {
|
||||
closeConnection();
|
||||
}
|
||||
|
||||
if (running && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
||||
reconnectAttempts++;
|
||||
PlayerSync.LOGGER.warn("Attempting to reconnect to chat server ({}/{})",
|
||||
reconnectAttempts, MAX_RECONNECT_ATTEMPTS);
|
||||
|
||||
try {
|
||||
long delay = Math.min(RECONNECT_DELAY * (long)Math.pow(2, reconnectAttempts-1), 60000);
|
||||
Thread.sleep(delay);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void closeConnection() {
|
||||
try {
|
||||
if (out != null) {
|
||||
out.close();
|
||||
out = null;
|
||||
}
|
||||
if (clientSocket != null && !clientSocket.isClosed()) {
|
||||
clientSocket.close();
|
||||
clientSocket = null;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
PlayerSync.LOGGER.error("Error closing connection: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public void shutdown() {
|
||||
running = false;
|
||||
closeConnection();
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void onPlayerChat(ServerChatEvent event) {
|
||||
String message= "<"+event.getUsername()+"> "+event.getMessage().getString();
|
||||
if (out != null) {
|
||||
out.println(message);
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void onPlayerJoin(PlayerEvent.PlayerLoggedInEvent event){
|
||||
playerList = Objects.requireNonNull(event.getEntity().getServer()).getPlayerList();
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void onPlayerLeave(PlayerEvent.PlayerLoggedOutEvent event){
|
||||
playerList = Objects.requireNonNull(event.getEntity().getServer()).getPlayerList();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
package vip.fubuki.playersync.sync.chat;
|
||||
|
||||
import vip.fubuki.playersync.PlayerSync;
|
||||
import vip.fubuki.playersync.config.JdbcConfig;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.PrintWriter;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.util.Iterator;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class ChatSyncServer {
|
||||
static ServerSocket serverSocket;
|
||||
static final Set<Socket> SocketList = ConcurrentHashMap.newKeySet();
|
||||
static final ExecutorService executorService = Executors.newCachedThreadPool();
|
||||
private volatile boolean running = true;
|
||||
|
||||
public void run() throws IOException {
|
||||
try {
|
||||
serverSocket = new ServerSocket(JdbcConfig.CHAT_SERVER_PORT.get());
|
||||
serverSocket.setReuseAddress(true);
|
||||
PlayerSync.LOGGER.info("Chat server started successfully on port {}", JdbcConfig.CHAT_SERVER_PORT.get());
|
||||
|
||||
while (running && !Thread.currentThread().isInterrupted()) {
|
||||
try {
|
||||
Socket newSocket = serverSocket.accept();
|
||||
newSocket.setSoTimeout(0);
|
||||
SocketList.add(newSocket);
|
||||
executorService.submit(() -> handleClient(newSocket));
|
||||
PlayerSync.LOGGER.info("New client connected, total clients: {}", SocketList.size());
|
||||
} catch (IOException e) {
|
||||
if (running) {
|
||||
PlayerSync.LOGGER.error("Error accepting client connection: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
private void handleClient(Socket socket) {
|
||||
String clientInfo = socket.getInetAddress() + ":" + socket.getPort();
|
||||
|
||||
try (BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(socket.getInputStream()))) {
|
||||
|
||||
String message;
|
||||
while (running && (message = reader.readLine()) != null) {
|
||||
broadcastMessage(socket, message);
|
||||
}
|
||||
|
||||
} catch (SocketTimeoutException e) {
|
||||
PlayerSync.LOGGER.warn("Client {} timeout", clientInfo);
|
||||
} catch (IOException e) {
|
||||
PlayerSync.LOGGER.error("Error handling client {}: {}", clientInfo, e.getMessage());
|
||||
} finally {
|
||||
SocketList.remove(socket);
|
||||
try {
|
||||
if (!socket.isClosed()) {
|
||||
socket.close();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
PlayerSync.LOGGER.error("Error closing client socket: {}", e.getMessage());
|
||||
}
|
||||
PlayerSync.LOGGER.info("Client disconnected, remaining clients: {}", SocketList.size());
|
||||
}
|
||||
}
|
||||
|
||||
private void broadcastMessage(Socket sender, String message) {
|
||||
Iterator<Socket> iterator = SocketList.iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Socket socket = iterator.next();
|
||||
if (!socket.equals(sender) && !socket.isClosed()) {
|
||||
try {
|
||||
PrintWriter writer = new PrintWriter(socket.getOutputStream(), true);
|
||||
writer.println(message);
|
||||
} catch (IOException e) {
|
||||
PlayerSync.LOGGER.error("Error broadcasting to client, removing: {}", e.getMessage());
|
||||
iterator.remove();
|
||||
try {
|
||||
socket.close();
|
||||
} catch (IOException ex) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void shutdown() {
|
||||
running = false;
|
||||
try {
|
||||
if (serverSocket != null && !serverSocket.isClosed()) {
|
||||
serverSocket.close();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
PlayerSync.LOGGER.error("Error closing server socket: {}", e.getMessage());
|
||||
}
|
||||
|
||||
for (Socket socket : SocketList) {
|
||||
try {
|
||||
if (!socket.isClosed()) {
|
||||
socket.close();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
SocketList.clear();
|
||||
|
||||
executorService.shutdown();
|
||||
try {
|
||||
if (!executorService.awaitTermination(5, TimeUnit.SECONDS)) {
|
||||
executorService.shutdownNow();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
executorService.shutdownNow();
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
131
src/main/java/vip/fubuki/playersync/util/CrashRecovery.java
Normal file
131
src/main/java/vip/fubuki/playersync/util/CrashRecovery.java
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
package vip.fubuki.playersync.util;
|
||||
|
||||
import vip.fubuki.playersync.PlayerSync;
|
||||
import vip.fubuki.playersync.config.JdbcConfig;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Crash-recovery + shutdown-hook helper.
|
||||
*
|
||||
* <p>Installs a JVM shutdown hook that flushes pending saves and writes a
|
||||
* graceful-shutdown marker into {@code server_info}. On next startup, scans
|
||||
* {@code player_data} for rows stuck at {@code online=1} on this server and
|
||||
* clears them — covers {@code kill -9} / OOM / JVM abort scenarios where the
|
||||
* normal ServerStoppingEvent path never ran.
|
||||
*
|
||||
* <p>Companion of {@link HeartbeatService} which keeps {@code server_info.last_update}
|
||||
* fresh so peer servers can detect this one as alive.
|
||||
*
|
||||
* @author vyrriox
|
||||
*/
|
||||
public final class CrashRecovery {
|
||||
|
||||
private CrashRecovery() {}
|
||||
|
||||
private static final AtomicBoolean HOOK_INSTALLED = new AtomicBoolean(false);
|
||||
private static volatile Runnable flushCallback;
|
||||
|
||||
/**
|
||||
* Registers a JVM shutdown hook. Called once from PlayerSync.onServerStarting
|
||||
* AFTER the DB pool is up. The {@code flushTask} is invoked on JVM shutdown —
|
||||
* use it to snapshot all still-online players synchronously (no async executor,
|
||||
* the pool may already be draining).
|
||||
*/
|
||||
public static void installShutdownHook(Runnable flushTask) {
|
||||
if (!HOOK_INSTALLED.compareAndSet(false, true)) return;
|
||||
flushCallback = flushTask;
|
||||
|
||||
Thread hook = new Thread(() -> {
|
||||
try {
|
||||
PlayerSync.LOGGER.warn("[crash-recovery] JVM shutdown hook fired — flushing pending saves");
|
||||
SyncLogger.playerEvent("SYSTEM", "JVM_SHUTDOWN_HOOK", "Flushing pending saves before JVM exit");
|
||||
if (flushCallback != null) {
|
||||
try {
|
||||
flushCallback.run();
|
||||
} catch (Throwable t) {
|
||||
PlayerSync.LOGGER.error("[crash-recovery] flush callback threw", t);
|
||||
}
|
||||
}
|
||||
// Mark this server as gracefully stopped so peers know it's dead.
|
||||
try {
|
||||
JDBCsetUp.executePreparedUpdate(
|
||||
"UPDATE " + Tables.serverInfo() + " SET enable=0, last_update=? WHERE id=?",
|
||||
System.currentTimeMillis(), JdbcConfig.SERVER_ID.get());
|
||||
} catch (Exception e) {
|
||||
PlayerSync.LOGGER.warn("[crash-recovery] could not mark server stopped: {}", e.getMessage());
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
// NEVER let the hook throw — it would block JVM exit.
|
||||
PlayerSync.LOGGER.error("[crash-recovery] hook failed", t);
|
||||
}
|
||||
}, "PlayerSync-shutdown-hook");
|
||||
hook.setDaemon(false); // MUST be non-daemon: daemon threads are killed on exit
|
||||
Runtime.getRuntime().addShutdownHook(hook);
|
||||
PlayerSync.LOGGER.info("[crash-recovery] JVM shutdown hook installed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans {@code player_data} for orphaned online=1 rows on this server and
|
||||
* clears them. Called from PlayerSync.onServerStarting AFTER the tables are
|
||||
* created. This is the recovery path for players who were online when the
|
||||
* server was killed ungracefully (kill -9, OOM, host reboot).
|
||||
*/
|
||||
public static void clearOrphanedOnlineFlags() {
|
||||
int serverId = JdbcConfig.SERVER_ID.get();
|
||||
try {
|
||||
// Count first so we know what we're about to clear.
|
||||
int count = 0;
|
||||
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
||||
"SELECT COUNT(*) AS c FROM " + Tables.playerData() + " WHERE last_server=? AND online=1",
|
||||
serverId)) {
|
||||
ResultSet rs = qr.resultSet();
|
||||
if (rs.next()) count = rs.getInt("c");
|
||||
}
|
||||
JDBCsetUp.executePreparedUpdate(
|
||||
"UPDATE " + Tables.playerData() + " SET online=0 WHERE last_server=? AND online=1",
|
||||
serverId);
|
||||
if (count > 0) {
|
||||
PlayerSync.LOGGER.warn("[crash-recovery] cleared {} orphan online=1 rows from previous session (server_id={})",
|
||||
count, serverId);
|
||||
SyncLogger.playerEvent("SYSTEM", "ORPHAN_CLEAR",
|
||||
"Cleared " + count + " online=1 rows left by previous session crash");
|
||||
} else {
|
||||
PlayerSync.LOGGER.info("[crash-recovery] no orphan online=1 rows found — previous shutdown was clean");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
PlayerSync.LOGGER.error("[crash-recovery] failed to scan for orphans", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports peer servers whose heartbeat is stale. Informational — useful to
|
||||
* surface zombie server_ids that could trip doPlayerJoin's poll. Called once
|
||||
* on startup.
|
||||
*/
|
||||
public static void reportZombiePeers(long staleAfterMs) {
|
||||
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
||||
"SELECT id, last_update FROM " + Tables.serverInfo() + " WHERE enable=1 AND id<>?",
|
||||
JdbcConfig.SERVER_ID.get())) {
|
||||
ResultSet rs = qr.resultSet();
|
||||
long now = System.currentTimeMillis();
|
||||
int zombies = 0;
|
||||
while (rs.next()) {
|
||||
int id = rs.getInt("id");
|
||||
long last = rs.getLong("last_update");
|
||||
long age = now - last;
|
||||
if (id == 0 || age > staleAfterMs) {
|
||||
zombies++;
|
||||
PlayerSync.LOGGER.warn("[crash-recovery] peer server_id={} is zombie (last_update age={}ms, enabled=true)",
|
||||
id, age);
|
||||
}
|
||||
}
|
||||
if (zombies > 0) {
|
||||
SyncLogger.playerEvent("SYSTEM", "ZOMBIE_PEERS", zombies + " peer server(s) appear stale");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
PlayerSync.LOGGER.warn("[crash-recovery] zombie peer scan failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
package vip.fubuki.playersync.util;
|
||||
|
||||
import vip.fubuki.playersync.PlayerSync;
|
||||
import vip.fubuki.playersync.config.JdbcConfig;
|
||||
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Periodic {@code server_info.last_update} heartbeat.
|
||||
*
|
||||
* <p>Runs on a dedicated single-threaded scheduler at a fixed interval so peer
|
||||
* servers can detect this server as alive via {@code isPeerServerStale()} in
|
||||
* {@code VanillaSync.doPlayerJoin}. Without this, a server that stops issuing
|
||||
* updates (e.g. hung main thread) would be treated as alive indefinitely by
|
||||
* rejoining players on other servers, causing the 30s poll timeouts seen in
|
||||
* production logs.
|
||||
*
|
||||
* @author vyrriox
|
||||
*/
|
||||
public final class HeartbeatService {
|
||||
|
||||
private HeartbeatService() {}
|
||||
|
||||
/**
|
||||
* Heartbeat period: configurable via {@code heartbeat_interval_seconds}.
|
||||
* Paired with {@code peer_stale_threshold_seconds}.
|
||||
*/
|
||||
private static long currentPeriodMs() {
|
||||
try {
|
||||
return JdbcConfig.HEARTBEAT_INTERVAL_SECONDS.get() * 1000L;
|
||||
} catch (Throwable t) {
|
||||
return 30_000L;
|
||||
}
|
||||
}
|
||||
|
||||
private static final AtomicBoolean RUNNING = new AtomicBoolean(false);
|
||||
private static ScheduledExecutorService scheduler;
|
||||
|
||||
public static void start() {
|
||||
if (!RUNNING.compareAndSet(false, true)) return;
|
||||
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "PlayerSync-heartbeat");
|
||||
t.setDaemon(true);
|
||||
t.setPriority(Thread.MIN_PRIORITY);
|
||||
return t;
|
||||
});
|
||||
long period = currentPeriodMs();
|
||||
scheduler.scheduleAtFixedRate(HeartbeatService::tick, period, period, TimeUnit.MILLISECONDS);
|
||||
PlayerSync.LOGGER.info("[heartbeat] started (period={}ms, server_id={})", period, JdbcConfig.SERVER_ID.get());
|
||||
}
|
||||
|
||||
public static void stop() {
|
||||
if (!RUNNING.compareAndSet(true, false)) return;
|
||||
if (scheduler != null) {
|
||||
scheduler.shutdownNow();
|
||||
scheduler = null;
|
||||
}
|
||||
PlayerSync.LOGGER.info("[heartbeat] stopped");
|
||||
}
|
||||
|
||||
private static void tick() {
|
||||
try {
|
||||
int serverId = JdbcConfig.SERVER_ID.get();
|
||||
JDBCsetUp.executePreparedUpdate(
|
||||
"UPDATE " + Tables.serverInfo() + " SET last_update=?, enable=1 WHERE id=?",
|
||||
System.currentTimeMillis(), serverId);
|
||||
} catch (Throwable t) {
|
||||
// Do not kill the scheduler on a transient DB error — log and retry next tick.
|
||||
PlayerSync.LOGGER.warn("[heartbeat] tick failed: {}", t.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,128 +1,311 @@
|
|||
package vip.fubuki.playersync.util;
|
||||
|
||||
import com.mojang.logging.LogUtils;
|
||||
import com.zaxxer.hikari.HikariConfig;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import org.slf4j.Logger;
|
||||
import vip.fubuki.playersync.config.JdbcConfig;
|
||||
|
||||
import java.sql.*;
|
||||
|
||||
/**
|
||||
* JDBC utility backed by HikariCP connection pool.
|
||||
*
|
||||
* Why HikariCP instead of the old manual pool?
|
||||
* - Old pool called conn.isValid(2) on every borrow → SELECT 1 round-trip → visible as
|
||||
* "pingInternal" in Spark profiler (~1% server thread constantly).
|
||||
* - HikariCP uses TCP keepalive and only validates idle connections at a configurable
|
||||
* interval (keepaliveTime=5min), never on hot-path queries.
|
||||
* - Automatic reconnection, proper idle-connection eviction, and thread-safe internals
|
||||
* are all handled by HikariCP without manual LinkedBlockingQueue management.
|
||||
*/
|
||||
public class JDBCsetUp {
|
||||
|
||||
private static final Logger LOGGER = LogUtils.getLogger();
|
||||
private static volatile HikariDataSource dataSource;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Pool lifecycle
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns a connection to the MySQL server.
|
||||
* @param selectDatabase if true, the returned URL includes the configured database name.
|
||||
* @return a Connection object with the database explicitly selected.
|
||||
* @throws SQLException if a database access error occurs.
|
||||
* Initialises the HikariCP pool. Must be called once after the MySQL database
|
||||
* has been created (i.e. at the end of the CREATE DATABASE step in PlayerSync).
|
||||
* Safe to call again on server-restart scenarios — closes the old pool first.
|
||||
*/
|
||||
public static Connection getConnection(boolean selectDatabase) throws SQLException {
|
||||
public static void initPool() {
|
||||
if (dataSource != null && !dataSource.isClosed()) {
|
||||
dataSource.close();
|
||||
}
|
||||
|
||||
HikariConfig cfg = new HikariConfig();
|
||||
cfg.setJdbcUrl(buildUrl(true));
|
||||
cfg.setUsername(JdbcConfig.USERNAME.get());
|
||||
cfg.setPassword(JdbcConfig.PASSWORD.get());
|
||||
|
||||
// FIX PERF (C9): right-sized pool. 25 was oversized; empirical HikariCP rule is
|
||||
// ~ cores*2 + spindles. 15 handles 35 concurrent players comfortably and reduces
|
||||
// MySQL server-side context switching.
|
||||
cfg.setMaximumPoolSize(15);
|
||||
cfg.setMinimumIdle(4);
|
||||
|
||||
// Connection lifecycle
|
||||
cfg.setConnectionTimeout(10_000L); // 10 s – fail fast on MySQL outage
|
||||
cfg.setIdleTimeout(300_000L); // 5 min – evict idle connections sooner
|
||||
cfg.setMaxLifetime(1_800_000L); // 30 min – recycle before MySQL wait_timeout
|
||||
cfg.setKeepaliveTime(300_000L); // 5 min – ping idle connections (NOT hot path)
|
||||
|
||||
cfg.setAutoCommit(true);
|
||||
cfg.setPoolName("PlayerSync");
|
||||
|
||||
// FIX PERF (C9): 25s threshold — covers worst-case doPlayerJoin poll bursts without
|
||||
// flooding logs with false positives. Previous 10s fired during legitimate 15-30s polls.
|
||||
cfg.setLeakDetectionThreshold(25_000L);
|
||||
|
||||
dataSource = new HikariDataSource(cfg);
|
||||
LOGGER.info("[PlayerSync] HikariCP pool ready (maxPool={}, minIdle={})",
|
||||
cfg.getMaximumPoolSize(), cfg.getMinimumIdle());
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes all pooled connections. Called on server shutdown.
|
||||
*/
|
||||
public static void shutdownPool() {
|
||||
if (dataSource != null && !dataSource.isClosed()) {
|
||||
dataSource.close();
|
||||
dataSource = null;
|
||||
LOGGER.info("[PlayerSync] HikariCP pool closed.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposes the HikariCP MBean for monitoring. Returns {@code null} if the
|
||||
* pool is not initialised or already closed. Used by PoolStatsReporter.
|
||||
*/
|
||||
public static com.zaxxer.hikari.HikariPoolMXBean getPoolMXBean() {
|
||||
try {
|
||||
if (dataSource == null || dataSource.isClosed()) return null;
|
||||
return dataSource.getHikariPoolMXBean();
|
||||
} catch (Throwable t) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static String buildUrl(boolean selectDatabase) {
|
||||
String dbName = JdbcConfig.DATABASE_NAME.get();
|
||||
// Build the base URL
|
||||
String url = "jdbc:mysql://" + JdbcConfig.HOST.get() + ":" + JdbcConfig.PORT.get();
|
||||
if (selectDatabase && !dbName.isEmpty()) {
|
||||
url += "/" + dbName;
|
||||
}
|
||||
// No autoReconnect — HikariCP handles reconnection transparently.
|
||||
// FIX PERF: Added MySQL performance parameters:
|
||||
// - rewriteBatchedStatements: rewrites batch INSERTs into multi-row (5-30x faster)
|
||||
// - cachePrepStmts + useServerPrepStmts: server-side prepared statement cache (15-25% CPU reduction)
|
||||
// - prepStmtCacheSize=256: keeps compiled statements in cache across queries
|
||||
// - useCompression: compresses network traffic (40-60% reduction for large NBT blobs)
|
||||
// - tcpNoDelay: disable Nagle's algorithm for lower latency
|
||||
url += "?useUnicode=true&characterEncoding=utf-8&useSSL=" + JdbcConfig.USE_SSL.get()
|
||||
+ "&serverTimezone=UTC&allowPublicKeyRetrieval=true";
|
||||
Connection conn = DriverManager.getConnection(url, JdbcConfig.USERNAME.get(), JdbcConfig.PASSWORD.get());
|
||||
// Ensure that the connection uses the desired database by explicitly issuing "USE dbName"
|
||||
if (selectDatabase && !dbName.isEmpty()) {
|
||||
try (Statement st = conn.createStatement()) {
|
||||
st.execute("USE `" + dbName + "`");
|
||||
}
|
||||
}
|
||||
return conn;
|
||||
+ "&serverTimezone=UTC&allowPublicKeyRetrieval=true"
|
||||
+ "&rewriteBatchedStatements=true"
|
||||
+ "&cachePrepStmts=true"
|
||||
+ "&useServerPrepStmts=true"
|
||||
+ "&prepStmtCacheSize=256"
|
||||
+ "&prepStmtCacheSqlLimit=2048"
|
||||
+ "&useCompression=true"
|
||||
+ "&tcpNoDelay=true";
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a connection from the HikariCP pool (selectDatabase=true)
|
||||
* or a raw DriverManager connection (selectDatabase=false, used only for
|
||||
* startup DDL that must run without a selected database).
|
||||
*
|
||||
* With HikariCP, calling connection.close() returns the connection to the
|
||||
* pool — no separate returnConnection() call needed.
|
||||
*/
|
||||
public static Connection getConnection(boolean selectDatabase) throws SQLException {
|
||||
if (!selectDatabase) {
|
||||
// Raw connection for DDL that runs before/without the pool database
|
||||
return DriverManager.getConnection(
|
||||
buildUrl(false), JdbcConfig.USERNAME.get(), JdbcConfig.PASSWORD.get());
|
||||
}
|
||||
if (dataSource == null || dataSource.isClosed()) {
|
||||
throw new SQLException("[PlayerSync] HikariCP pool is not initialised — call initPool() first.");
|
||||
}
|
||||
return dataSource.getConnection();
|
||||
}
|
||||
|
||||
// Default connection always includes the database.
|
||||
public static Connection getConnection() throws SQLException {
|
||||
return getConnection(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a query using a connection that includes the database.
|
||||
*/
|
||||
// -------------------------------------------------------------------------
|
||||
// Query helpers (API unchanged — callers need no modification)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public static QueryResult executeQuery(String sqlFormatString, Object... args) throws SQLException {
|
||||
String sql = String.format(sqlFormatString, args);
|
||||
LOGGER.trace(sql);
|
||||
Connection connection = getConnection(); // With database selected (and "USE" already run)
|
||||
PreparedStatement queryStatement = connection.prepareStatement(sql);
|
||||
ResultSet resultSet = queryStatement.executeQuery();
|
||||
return new QueryResult(connection, queryStatement, resultSet);
|
||||
Connection connection = getConnection();
|
||||
try {
|
||||
PreparedStatement stmt = connection.prepareStatement(sql);
|
||||
ResultSet rs = stmt.executeQuery();
|
||||
return new QueryResult(connection, stmt, rs);
|
||||
} catch (SQLException e) {
|
||||
try { connection.close(); } catch (SQLException ignored) {}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes an update using a connection with or without the database within the JDBC URL
|
||||
*/
|
||||
private static void executeUpdate(boolean selectDatabase, String sqlFormatString, Object... args) throws SQLException {
|
||||
private static void executeUpdateInternal(boolean selectDatabase, String sqlFormatString, Object... args) throws SQLException {
|
||||
String sql = String.format(sqlFormatString, args);
|
||||
LOGGER.trace(sql);
|
||||
try (Connection connection = getConnection()) { // With database selected
|
||||
try (PreparedStatement updateStatement = connection.prepareStatement(sql)) {
|
||||
updateStatement.executeUpdate();
|
||||
}
|
||||
try (Connection conn = getConnection(selectDatabase);
|
||||
PreparedStatement stmt = conn.prepareStatement(sql)) {
|
||||
stmt.executeUpdate();
|
||||
// conn.close() is called by try-with-resources:
|
||||
// - pool connection → returned to HikariCP pool
|
||||
// - raw connection → truly closed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes an update using a connection that includes the database in the JDBC URL
|
||||
*/
|
||||
public static void executeUpdate(String sqlFormatString, Object... args) throws SQLException {
|
||||
executeUpdate(true, sqlFormatString, args);
|
||||
executeUpdateInternal(true, sqlFormatString, args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes an update using a connection that does NOT include a default database.
|
||||
* This method is used for commands like "CREATE DATABASE IF NOT EXISTS ..."
|
||||
*/
|
||||
/** Overload used by startup DDL that must bypass the pool (selectDatabase=false). */
|
||||
public static void executeUpdate(String sql, int dummy) throws SQLException {
|
||||
LOGGER.trace(sql);
|
||||
try (Connection connection = getConnection(false)) { // Without default database
|
||||
try (PreparedStatement updateStatement = connection.prepareStatement(sql)) {
|
||||
updateStatement.executeUpdate();
|
||||
try (Connection conn = getConnection(false);
|
||||
PreparedStatement stmt = conn.prepareStatement(sql)) {
|
||||
stmt.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public static void update(String sql, String... argument) throws SQLException {
|
||||
LOGGER.trace(sql);
|
||||
try (Connection conn = getConnection();
|
||||
PreparedStatement stmt = conn.prepareStatement(sql)) {
|
||||
for (int i = 0; i < argument.length; i++) {
|
||||
stmt.setString(i + 1, argument[i]);
|
||||
}
|
||||
stmt.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public static void executePreparedUpdate(String sql, Object... params) throws SQLException {
|
||||
executePreparedUpdateRet(sql, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Variant of {@link #executePreparedUpdate(String, Object...)} that returns the
|
||||
* number of rows affected. Used by admin commands (clearorphans, peerkill, wipe)
|
||||
* to report meaningful counts to the operator.
|
||||
*/
|
||||
public static int executePreparedUpdateRet(String sql, Object... params) throws SQLException {
|
||||
LOGGER.trace(sql);
|
||||
try (Connection conn = getConnection();
|
||||
PreparedStatement stmt = conn.prepareStatement(sql)) {
|
||||
for (int i = 0; i < params.length; i++) {
|
||||
stmt.setObject(i + 1, params[i]);
|
||||
}
|
||||
return stmt.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper method for updates with parameters.
|
||||
* FIX PERF: Execute multiple SQL statements in a SINGLE transaction on ONE connection.
|
||||
* Previously, writeSnapshotToDB called executePreparedUpdate 4-8 times per player,
|
||||
* each opening a new connection from the pool. With 35 players: 140-280 connection
|
||||
* borrows + network round-trips. This batches them into 1 connection + 1 commit.
|
||||
*
|
||||
* Each entry is {sql, params...}. All execute in order within one transaction.
|
||||
* If any fails, the entire batch is rolled back.
|
||||
*
|
||||
* @return array of per-statement affected-row counts (parallel to {@code statements}).
|
||||
* Callers can inspect the first entry to detect silent no-ops caused by
|
||||
* {@code AND last_server=?} guards blocking a stale write.
|
||||
*/
|
||||
public static void update(String sql, String... argument) throws SQLException {
|
||||
public static int[] executeBatchTransaction(Object[]... statements) throws SQLException {
|
||||
int[] counts = new int[statements.length];
|
||||
try (Connection conn = getConnection()) {
|
||||
conn.setAutoCommit(false);
|
||||
try {
|
||||
for (int idx = 0; idx < statements.length; idx++) {
|
||||
Object[] entry = statements[idx];
|
||||
String sql = (String) entry[0];
|
||||
LOGGER.trace(sql);
|
||||
try (Connection connection = getConnection()) { // With database selected
|
||||
PreparedStatement updateStatement = connection.prepareStatement(sql);
|
||||
for (int i = 0; i < argument.length; i++) {
|
||||
updateStatement.setString(i + 1, argument[i]);
|
||||
try (PreparedStatement stmt = conn.prepareStatement(sql)) {
|
||||
for (int i = 1; i < entry.length; i++) {
|
||||
stmt.setObject(i, entry[i]);
|
||||
}
|
||||
updateStatement.executeUpdate();
|
||||
counts[idx] = stmt.executeUpdate();
|
||||
}
|
||||
}
|
||||
conn.commit();
|
||||
} catch (SQLException e) {
|
||||
try { conn.rollback(); } catch (SQLException rbEx) {
|
||||
LOGGER.error("[PlayerSync] Rollback failed while handling batch transaction error", rbEx);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
conn.setAutoCommit(true);
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
public static QueryResult executePreparedQuery(String sql, Object... params) throws SQLException {
|
||||
LOGGER.trace(sql);
|
||||
Connection conn = getConnection();
|
||||
try {
|
||||
PreparedStatement stmt = conn.prepareStatement(sql);
|
||||
for (int i = 0; i < params.length; i++) {
|
||||
stmt.setObject(i + 1, params[i]);
|
||||
}
|
||||
ResultSet rs = stmt.executeQuery();
|
||||
return new QueryResult(conn, stmt, rs);
|
||||
} catch (SQLException e) {
|
||||
try { conn.close(); } catch (SQLException ignored) {}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public record QueryResult(Connection connection,PreparedStatement preparedStatement, ResultSet resultSet) implements AutoCloseable {
|
||||
// -------------------------------------------------------------------------
|
||||
// QueryResult — holds connection open until caller closes it
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Auto-closeable holder for a live query result.
|
||||
* Closing it releases the ResultSet and PreparedStatement, then calls
|
||||
* connection.close() which returns the connection to the HikariCP pool.
|
||||
*/
|
||||
public record QueryResult(
|
||||
Connection connection,
|
||||
PreparedStatement preparedStatement,
|
||||
ResultSet resultSet
|
||||
) implements AutoCloseable {
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (resultSet != null) {
|
||||
try {
|
||||
resultSet.close();
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("Error closing ResultSet", e);
|
||||
try { resultSet.close(); } catch (SQLException e) {
|
||||
LOGGER.error("[PlayerSync] Error closing ResultSet", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (preparedStatement != null) {
|
||||
try {
|
||||
preparedStatement.close();
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("Error closing PreparedStatement", e);
|
||||
try { preparedStatement.close(); } catch (SQLException e) {
|
||||
LOGGER.error("[PlayerSync] Error closing PreparedStatement", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (connection != null) {
|
||||
try {
|
||||
connection.close();
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("Error closing Connection", e);
|
||||
try { connection.close(); } catch (SQLException e) {
|
||||
LOGGER.error("[PlayerSync] Error returning connection to pool", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,13 @@ public class LocalJsonUtil {
|
|||
|
||||
String key = trim.substring(0, equalIndex);
|
||||
String value = trim.substring(equalIndex + 1);
|
||||
// FIX M-1: Catch parse exceptions per-entry to prevent one malformed key
|
||||
// from emptying the entire map (e.g. cosmetic armor slots all lost)
|
||||
try {
|
||||
map.put(keyParser.apply(key), value);
|
||||
} catch (Exception e) {
|
||||
// Skip malformed entries instead of crashing the whole parse
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
package vip.fubuki.playersync.util;
|
||||
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.neoforged.neoforge.server.ServerLifecycleHooks;
|
||||
import vip.fubuki.playersync.PlayerSync;
|
||||
import vip.fubuki.playersync.config.JdbcConfig;
|
||||
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Independent scheduler that triggers a full periodic flush for every online
|
||||
* player at {@code auto_save_interval_minutes} intervals.
|
||||
*
|
||||
* <p>This is decoupled from NeoForge's {@code PlayerEvent.SaveToFile} so the
|
||||
* cadence is predictable and configurable — NeoForge's event fires on the
|
||||
* vanilla autosave tick, which an admin may have tuned elsewhere. We delegate
|
||||
* the actual save work to the main thread via {@code server.execute(...)} so
|
||||
* snapshots run on the safe thread, then the save itself hops to the BG pool
|
||||
* via the usual {@code PlayerEvent.SaveToFile} path.
|
||||
*
|
||||
* @author vyrriox
|
||||
*/
|
||||
public final class PeriodicSaveService {
|
||||
|
||||
private PeriodicSaveService() {}
|
||||
|
||||
private static final AtomicBoolean RUNNING = new AtomicBoolean(false);
|
||||
private static ScheduledExecutorService scheduler;
|
||||
|
||||
public static void start() {
|
||||
int minutes = JdbcConfig.AUTO_SAVE_INTERVAL_MINUTES.get();
|
||||
if (minutes <= 0) {
|
||||
PlayerSync.LOGGER.info("[periodic-save] disabled (auto_save_interval_minutes=0)");
|
||||
return;
|
||||
}
|
||||
if (!RUNNING.compareAndSet(false, true)) return;
|
||||
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "PlayerSync-periodic-save");
|
||||
t.setDaemon(true);
|
||||
t.setPriority(Thread.MIN_PRIORITY);
|
||||
return t;
|
||||
});
|
||||
long periodMs = minutes * 60_000L;
|
||||
// First tick after one full period, not immediately — gives the server
|
||||
// time to finish startup before we start scheduling DB work.
|
||||
scheduler.scheduleAtFixedRate(PeriodicSaveService::tick, periodMs, periodMs, TimeUnit.MILLISECONDS);
|
||||
PlayerSync.LOGGER.info("[periodic-save] started (interval={}min)", minutes);
|
||||
}
|
||||
|
||||
public static void stop() {
|
||||
if (!RUNNING.compareAndSet(true, false)) return;
|
||||
if (scheduler != null) {
|
||||
scheduler.shutdownNow();
|
||||
scheduler = null;
|
||||
}
|
||||
PlayerSync.LOGGER.info("[periodic-save] stopped");
|
||||
}
|
||||
|
||||
private static void tick() {
|
||||
try {
|
||||
MinecraftServer server = ServerLifecycleHooks.getCurrentServer();
|
||||
if (server == null || !server.isRunning()) return;
|
||||
// Hop to main thread — snapshots must happen on server thread.
|
||||
// PHASE 7 PERF: skip the whole tick if no one is online.
|
||||
if (server.getPlayerList().getPlayers().isEmpty()) return;
|
||||
// PHASE 18: instead of hopping to main thread and snapshotting every player
|
||||
// in one tick (the lag spike every 10 min), ENQUEUE all online players into
|
||||
// the existing 1-player/tick staggered auto-save queue. Drain happens in
|
||||
// onServerTick at a rate of 1 player per tick (20/sec), so 35 players take
|
||||
// 1.75s to fully process — imperceptible per-tick.
|
||||
server.execute(() -> {
|
||||
try {
|
||||
int before = server.getPlayerList().getPlayerCount();
|
||||
vip.fubuki.playersync.sync.VanillaSync.enqueueAllOnlineForStaggeredSave(server);
|
||||
PlayerSync.LOGGER.info("[periodic-save] enqueued {} players for staggered save", before);
|
||||
SyncLogger.playerEvent("SYSTEM", "PERIODIC_TICK",
|
||||
"Enqueued " + before + " player(s) for staggered save");
|
||||
} catch (Throwable t) {
|
||||
PlayerSync.LOGGER.error("[periodic-save] tick body failed", t);
|
||||
}
|
||||
});
|
||||
} catch (Throwable t) {
|
||||
PlayerSync.LOGGER.warn("[periodic-save] scheduling tick failed: {}", t.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
package vip.fubuki.playersync.util;
|
||||
|
||||
import com.zaxxer.hikari.HikariPoolMXBean;
|
||||
import vip.fubuki.playersync.PlayerSync;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Periodic reporter that logs executor + HikariCP stats every 5 minutes into
|
||||
* the PlayerSync sync.log. Lets admins spot queue saturation or pool
|
||||
* exhaustion trends without waiting for a crash. Non-invasive — pure read-only.
|
||||
*
|
||||
* @author vyrriox
|
||||
*/
|
||||
public final class PoolStatsReporter {
|
||||
|
||||
private PoolStatsReporter() {}
|
||||
|
||||
private static final AtomicBoolean RUNNING = new AtomicBoolean(false);
|
||||
private static ScheduledExecutorService scheduler;
|
||||
|
||||
public static void start() {
|
||||
int minutes;
|
||||
try {
|
||||
minutes = vip.fubuki.playersync.config.JdbcConfig.POOL_STATS_INTERVAL_MINUTES.get();
|
||||
} catch (Throwable t) {
|
||||
minutes = 5;
|
||||
}
|
||||
if (minutes <= 0) {
|
||||
PlayerSync.LOGGER.info("[pool-stats] disabled (pool_stats_interval_minutes=0)");
|
||||
return;
|
||||
}
|
||||
if (!RUNNING.compareAndSet(false, true)) return;
|
||||
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "PlayerSync-pool-stats");
|
||||
t.setDaemon(true);
|
||||
t.setPriority(Thread.MIN_PRIORITY);
|
||||
return t;
|
||||
});
|
||||
long periodMs = minutes * 60_000L;
|
||||
scheduler.scheduleAtFixedRate(PoolStatsReporter::tick, periodMs, periodMs, TimeUnit.MILLISECONDS);
|
||||
PlayerSync.LOGGER.info("[pool-stats] reporter started (period={}ms)", periodMs);
|
||||
}
|
||||
|
||||
public static void stop() {
|
||||
if (!RUNNING.compareAndSet(true, false)) return;
|
||||
if (scheduler != null) {
|
||||
scheduler.shutdownNow();
|
||||
scheduler = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void tick() {
|
||||
try {
|
||||
// Pull executor stats via reflection — VanillaSync.executorService is package-private static
|
||||
ThreadPoolExecutor exec = getExecutor();
|
||||
int active = exec != null ? exec.getActiveCount() : -1;
|
||||
int queue = exec != null ? exec.getQueue().size() : -1;
|
||||
int idle = exec != null ? exec.getPoolSize() - exec.getActiveCount() : -1;
|
||||
|
||||
HikariPoolMXBean hikari = JDBCsetUp.getPoolMXBean();
|
||||
int hActive = hikari != null ? hikari.getActiveConnections() : -1;
|
||||
int hIdle = hikari != null ? hikari.getIdleConnections() : -1;
|
||||
|
||||
SyncLogger.poolStats(active, queue, idle, hActive, hIdle);
|
||||
|
||||
// Warn if queue is getting dangerously full
|
||||
if (queue > 400) {
|
||||
PlayerSync.LOGGER.warn("[pool-stats] executor queue high: {}/512 — risk of CallerRunsPolicy blocking main thread", queue);
|
||||
SyncLogger.warnPlayer("SYSTEM", "Executor queue high: " + queue + "/512");
|
||||
}
|
||||
if (hActive >= 0 && hActive >= 14) {
|
||||
PlayerSync.LOGGER.warn("[pool-stats] HikariCP active connections high: {}/15 — risk of connection starvation", hActive);
|
||||
SyncLogger.warnPlayer("SYSTEM", "HikariCP active: " + hActive + "/15");
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
PlayerSync.LOGGER.warn("[pool-stats] tick failed: {}", t.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static ThreadPoolExecutor getExecutor() {
|
||||
try {
|
||||
Class<?> c = Class.forName("vip.fubuki.playersync.sync.VanillaSync");
|
||||
java.lang.reflect.Field f = c.getDeclaredField("executorService");
|
||||
f.setAccessible(true);
|
||||
Object o = f.get(null);
|
||||
if (o instanceof ThreadPoolExecutor tpe) return tpe;
|
||||
} catch (Throwable ignored) {}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
282
src/main/java/vip/fubuki/playersync/util/SyncLogger.java
Normal file
282
src/main/java/vip/fubuki/playersync/util/SyncLogger.java
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
package vip.fubuki.playersync.util;
|
||||
|
||||
import vip.fubuki.playersync.config.JdbcConfig;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.file.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Dedicated file logger for PlayerSync diagnostics.
|
||||
* Writes to logs/playersync/sync.log with automatic rotation (max 10MB per file, 5 files kept).
|
||||
*
|
||||
* Tracks: saves, restores, errors, potential duplications, data loss warnings,
|
||||
* cross-server race conditions, and performance metrics.
|
||||
*
|
||||
* Thread-safe: uses a lock-free queue + async flush to avoid blocking the main thread.
|
||||
*
|
||||
* @author vyrriox
|
||||
*/
|
||||
public class SyncLogger {
|
||||
|
||||
private static final String LOG_DIR = "logs/playersync";
|
||||
private static final String LOG_FILE = "sync.log";
|
||||
private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
private static final int MAX_FILES = 5;
|
||||
private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
|
||||
|
||||
// Lock-free queue for async writes (no main thread blocking)
|
||||
private static final ConcurrentLinkedQueue<String> writeQueue = new ConcurrentLinkedQueue<>();
|
||||
private static final AtomicBoolean initialized = new AtomicBoolean(false);
|
||||
private static Path logPath;
|
||||
|
||||
// FIX PERF (C3): Dedicated daemon scheduler so log() never opens/closes the file on
|
||||
// the caller thread. Previous impl called flushQueue() inline → every log call from
|
||||
// the main thread opened a FileWriter, wrote, and closed synchronously.
|
||||
private static final ScheduledExecutorService FLUSH_EXEC = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "PlayerSync-logflush");
|
||||
t.setDaemon(true);
|
||||
t.setPriority(Thread.MIN_PRIORITY);
|
||||
return t;
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Initialization
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public static void init() {
|
||||
if (initialized.getAndSet(true)) return;
|
||||
try {
|
||||
Path dir = Paths.get(LOG_DIR);
|
||||
Files.createDirectories(dir);
|
||||
logPath = dir.resolve(LOG_FILE);
|
||||
rotateIfNeeded();
|
||||
writeRaw("=".repeat(80));
|
||||
writeRaw("PlayerSync Log — Server ID: " + JdbcConfig.SERVER_ID.get() + " — Started: " + LocalDateTime.now().format(TIME_FMT));
|
||||
writeRaw("=".repeat(80));
|
||||
// FIX PERF (C3): single background flush every 500ms — no file I/O on hot path.
|
||||
FLUSH_EXEC.scheduleWithFixedDelay(SyncLogger::flushQueue, 500, 500, TimeUnit.MILLISECONDS);
|
||||
} catch (Exception e) {
|
||||
System.err.println("[PlayerSync] Failed to initialize SyncLogger: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public API — categorized log methods
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Normal sync operations (save/restore completed successfully) */
|
||||
public static void info(String message, Object... args) {
|
||||
log("INFO", message, args);
|
||||
}
|
||||
|
||||
/** Warnings that may indicate issues (timeouts, fallbacks, edge cases) */
|
||||
public static void warn(String message, Object... args) {
|
||||
log("WARN", message, args);
|
||||
}
|
||||
|
||||
/** Errors that caused data loss or corruption */
|
||||
public static void error(String message, Object... args) {
|
||||
log("ERROR", message, args);
|
||||
}
|
||||
|
||||
/** Potential duplication detected — highest severity */
|
||||
public static void dupeRisk(String playerUuid, String detail) {
|
||||
log("DUPE_RISK", "[{}] {}", playerUuid, detail);
|
||||
}
|
||||
|
||||
/** Potential data loss detected */
|
||||
public static void dataLoss(String playerUuid, String detail) {
|
||||
log("DATA_LOSS", "[{}] {}", playerUuid, detail);
|
||||
}
|
||||
|
||||
/** Cross-server race condition event */
|
||||
public static void raceCondition(String playerUuid, String detail) {
|
||||
log("RACE", "[{}] {}", playerUuid, detail);
|
||||
}
|
||||
|
||||
/** Performance metric */
|
||||
public static void perf(String operation, long durationMs) {
|
||||
if (durationMs > 50) { // Only log slow operations (> 50ms)
|
||||
log("PERF_SLOW", "{} took {}ms", operation, durationMs);
|
||||
}
|
||||
}
|
||||
|
||||
/** Player join/leave tracking */
|
||||
public static void playerEvent(String playerUuid, String eventType, String detail) {
|
||||
log("EVENT", "[{}] {} — {}", playerUuid, eventType, detail);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Save tracking — logs every save with metadata for debugging
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public static void saveStarted(String playerUuid, String saveType) {
|
||||
log("SAVE", "[{}] {} started", playerUuid, saveType);
|
||||
}
|
||||
|
||||
public static void saveCompleted(String playerUuid, String saveType, long durationMs) {
|
||||
log("SAVE", "[{}] {} completed in {}ms", playerUuid, saveType, durationMs);
|
||||
}
|
||||
|
||||
public static void saveFailed(String playerUuid, String saveType, String reason) {
|
||||
log("SAVE_FAIL", "[{}] {} FAILED: {}", playerUuid, saveType, reason);
|
||||
}
|
||||
|
||||
public static void saveSkipped(String playerUuid, String saveType, String reason) {
|
||||
log("SAVE_SKIP", "[{}] {} skipped: {}", playerUuid, saveType, reason);
|
||||
}
|
||||
|
||||
/** Logs when a write was blocked by the last_server guard (stale server tried to write) */
|
||||
public static void guardBlocked(String playerUuid, int thisServerId, String detail) {
|
||||
log("GUARD", "[{}] Write blocked (server={}) — {}", playerUuid, thisServerId, detail);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Restore tracking
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public static void restoreStarted(String playerUuid) {
|
||||
log("RESTORE", "[{}] Data restore started", playerUuid);
|
||||
}
|
||||
|
||||
public static void restoreCompleted(String playerUuid, long durationMs) {
|
||||
log("RESTORE", "[{}] Data restore completed in {}ms", playerUuid, durationMs);
|
||||
}
|
||||
|
||||
public static void restoreFailed(String playerUuid, String reason) {
|
||||
log("RESTORE_FAIL", "[{}] Data restore FAILED: {}", playerUuid, reason);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Phase 5: structured diagnostic events
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Force-close of a container on player logout (anti-duplication). */
|
||||
public static void containerForceClosed(String playerUuid, String reason) {
|
||||
log("CONTAINER_CLOSE", "[{}] {}", playerUuid, reason);
|
||||
}
|
||||
|
||||
/** Mod-compat save skipped because capability/handler was unavailable. */
|
||||
public static void modCompatSkip(String playerUuid, String modId, String reason) {
|
||||
log("MOD_SKIP", "[{}] {} — {}", playerUuid, modId, reason);
|
||||
}
|
||||
|
||||
/** Mod-compat save succeeded with metadata (e.g. slot count, NBT keys). */
|
||||
public static void modCompatSaved(String playerUuid, String modId, String detail) {
|
||||
log("MOD_SAVE", "[{}] {} — {}", playerUuid, modId, detail);
|
||||
}
|
||||
|
||||
/** Mod-compat restore succeeded with metadata. */
|
||||
public static void modCompatRestored(String playerUuid, String modId, String detail) {
|
||||
log("MOD_RESTORE", "[{}] {} — {}", playerUuid, modId, detail);
|
||||
}
|
||||
|
||||
/** RS2/backpack/SS storage-level save detail (keyed by storage UUID, not player). */
|
||||
public static void storageSave(String storageUuid, String kind, String detail) {
|
||||
log("STORAGE", "[{}] {} — {}", storageUuid, kind, detail);
|
||||
}
|
||||
|
||||
/** Periodic pool / queue status snapshot (every N minutes). */
|
||||
public static void poolStats(int active, int queueSize, int idle, int hikariActive, int hikariIdle) {
|
||||
log("POOL", "executor active={} queue={} pool_idle={} | hikari active={} idle={}",
|
||||
active, queueSize, idle, hikariActive, hikariIdle);
|
||||
}
|
||||
|
||||
/** Generic warning with player context. */
|
||||
public static void warnPlayer(String playerUuid, String detail) {
|
||||
log("WARN", "[{}] {}", playerUuid, detail);
|
||||
}
|
||||
|
||||
/** Detected NBT anomaly (suspicious shape / size). */
|
||||
public static void nbtAnomaly(String playerUuid, String detail) {
|
||||
log("NBT_ANOMALY", "[{}] {}", playerUuid, detail);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal — async file writing
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static void log(String level, String message, Object... args) {
|
||||
if (!initialized.get()) return;
|
||||
try {
|
||||
String formatted = formatMessage(message, args);
|
||||
String line = String.format("[%s] [%s] [%s] %s",
|
||||
LocalDateTime.now().format(TIME_FMT),
|
||||
Thread.currentThread().getName(),
|
||||
level,
|
||||
formatted);
|
||||
writeQueue.add(line);
|
||||
// FIX PERF (C3): no inline flush — background scheduler drains the queue.
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
private static String formatMessage(String template, Object... args) {
|
||||
if (args == null || args.length == 0) return template;
|
||||
// Simple {} placeholder replacement (like SLF4J)
|
||||
StringBuilder sb = new StringBuilder();
|
||||
int argIdx = 0;
|
||||
int i = 0;
|
||||
while (i < template.length()) {
|
||||
if (i < template.length() - 1 && template.charAt(i) == '{' && template.charAt(i + 1) == '}' && argIdx < args.length) {
|
||||
sb.append(args[argIdx++]);
|
||||
i += 2;
|
||||
} else {
|
||||
sb.append(template.charAt(i));
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static void flushQueue() {
|
||||
if (logPath == null) return;
|
||||
try (BufferedWriter writer = new BufferedWriter(new FileWriter(logPath.toFile(), true))) {
|
||||
String line;
|
||||
int count = 0;
|
||||
while ((line = writeQueue.poll()) != null && count < 100) {
|
||||
writer.write(line);
|
||||
writer.newLine();
|
||||
count++;
|
||||
}
|
||||
} catch (IOException ignored) {}
|
||||
}
|
||||
|
||||
private static void writeRaw(String line) {
|
||||
writeQueue.add(line);
|
||||
}
|
||||
|
||||
private static void rotateIfNeeded() {
|
||||
if (logPath == null) return;
|
||||
try {
|
||||
if (Files.exists(logPath) && Files.size(logPath) > MAX_FILE_SIZE) {
|
||||
// Rotate: sync.log → sync.1.log → sync.2.log → ... → delete oldest
|
||||
for (int i = MAX_FILES - 1; i >= 1; i--) {
|
||||
Path src = Paths.get(LOG_DIR, "sync." + i + ".log");
|
||||
Path dst = Paths.get(LOG_DIR, "sync." + (i + 1) + ".log");
|
||||
if (Files.exists(src)) {
|
||||
if (i == MAX_FILES - 1) {
|
||||
Files.delete(src);
|
||||
} else {
|
||||
Files.move(src, dst, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
}
|
||||
Files.move(logPath, Paths.get(LOG_DIR, "sync.1.log"), StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} catch (IOException ignored) {}
|
||||
}
|
||||
|
||||
/** Call on server shutdown to flush remaining entries and stop the background writer. */
|
||||
public static void shutdown() {
|
||||
try { FLUSH_EXEC.shutdown(); } catch (Exception ignored) {}
|
||||
try { FLUSH_EXEC.awaitTermination(2, TimeUnit.SECONDS); } catch (InterruptedException ignored) {}
|
||||
flushQueue();
|
||||
}
|
||||
}
|
||||
56
src/main/java/vip/fubuki/playersync/util/Tables.java
Normal file
56
src/main/java/vip/fubuki/playersync/util/Tables.java
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package vip.fubuki.playersync.util;
|
||||
|
||||
import vip.fubuki.playersync.config.JdbcConfig;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Central source of truth for PlayerSync table names.
|
||||
*
|
||||
* <p>Reads the optional {@code table_prefix} config at every call so that
|
||||
* administrators can safely share a single MySQL database with other mods
|
||||
* without colliding on generic names such as {@code player_data} or
|
||||
* {@code server_info}. The prefix defaults to an empty string to preserve
|
||||
* backward compatibility with existing installations.
|
||||
*
|
||||
* <p>Only the <em>table</em> identifier is prefixed. The database schema
|
||||
* qualifier (if any) must be added by the caller, e.g. via
|
||||
* {@code "`" + dbName + "`." + Tables.playerData()}.
|
||||
*
|
||||
* @author vyrriox
|
||||
*/
|
||||
public final class Tables {
|
||||
|
||||
private Tables() {}
|
||||
|
||||
// FIX PERF: precompile the validation pattern and cache the validated prefix.
|
||||
// String.matches() recompiles the regex on every call; this was invoked from
|
||||
// every SQL statement the mod issues (heartbeat, auto-save, join, logout, ...).
|
||||
// The config value cannot change at runtime, so a lazy singleton cache is safe.
|
||||
private static final Pattern VALID_PREFIX = Pattern.compile("[A-Za-z0-9_]*");
|
||||
private static volatile String cachedPrefix;
|
||||
private static volatile String cachedRaw;
|
||||
|
||||
private static String prefix() {
|
||||
String raw;
|
||||
try { raw = JdbcConfig.TABLE_PREFIX.get(); }
|
||||
catch (Exception e) { return ""; }
|
||||
if (raw == null) raw = "";
|
||||
// Fast path: same raw value as last call → return cached validated prefix.
|
||||
String lastRaw = cachedRaw;
|
||||
if (lastRaw != null && lastRaw.equals(raw)) {
|
||||
return cachedPrefix;
|
||||
}
|
||||
// Validate and cache.
|
||||
String validated = VALID_PREFIX.matcher(raw).matches() ? raw : "";
|
||||
cachedPrefix = validated;
|
||||
cachedRaw = raw;
|
||||
return validated;
|
||||
}
|
||||
|
||||
public static String playerData() { return prefix() + "player_data"; }
|
||||
public static String serverInfo() { return prefix() + "server_info"; }
|
||||
public static String curios() { return prefix() + "curios"; }
|
||||
public static String backpackData() { return prefix() + "backpack_data"; }
|
||||
public static String modPlayerData() { return prefix() + "mod_player_data"; }
|
||||
}
|
||||
|
|
@ -3,16 +3,8 @@
|
|||
"package": "vip.fubuki.playersync.mixin",
|
||||
"compatibilityLevel": "JAVA_21",
|
||||
"refmap": "thirst.refmap.json",
|
||||
"mixins": [
|
||||
"cobblemon.MixinFileBackedPokemonStoreFactory",
|
||||
"cobblemon.MixinNbtBackedPlayerData",
|
||||
"cobblemon.MixinPartyStore",
|
||||
"cobblemon.MixinPCStore",
|
||||
"cobblemon.accessor.FileBasedPlayerDataStoreBackendAccessor",
|
||||
"cobblemon.accessor.NbtBackedPlayerDataAccessor"
|
||||
],
|
||||
"client": [
|
||||
],
|
||||
"mixins": [],
|
||||
"client": [],
|
||||
"injectors": {
|
||||
"defaultRequire": 1
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user