diff --git a/player-counter/README.md b/player-counter/README.md index 65298820..21f16435 100644 --- a/player-counter/README.md +++ b/player-counter/README.md @@ -12,6 +12,14 @@ For each game you need to create a Game Query in the admin area. Minecraft servers will first try the query (which requires you to set `enable-query` to true and `query-port` to your server port in `server.properties`) and will fallback to ping. It is recommended to enable query. +### Minecraft Proxy (Velocity/BungeeCord/Waterfall) + +Use the `Minecraft (Proxy)` query type when the server you are querying is actually a Velocity, BungeeCord or Waterfall proxy sitting in front of one or more backend servers, instead of a standalone Minecraft server. All three proxies speak the same Java Edition status/ping protocol as a vanilla server, so this query type returns the aggregated player count/list across all backend servers behind the proxy. + +Unlike the `Minecraft (Java)` type, this always uses the ping/status protocol only and never attempts the legacy `enable-query`/`query-port` query, since proxy software does not support that legacy query protocol reliably (it caused connection errors during testing). No proxy-side query configuration is needed. + +Since a proxy has no `whitelist.json`, `ops.json` or player data files of its own, the whitelist, OP list and player avatar features on the players page are disabled for this query type. Kick and ban are disabled too, since stock Velocity/BungeeCord/Waterfall don't provide those console commands out of the box. Whitelist/OP/kick/ban management still works normally when applied directly to the backend servers using the regular `Minecraft (Java)` query type. + ### Palworld For Palworld servers you need to set `RESTAPIEnabled` to `true` and `RESTAPIPort` to your server port in `PalWorldSettings.ini`. You also need to set an admin password via the `ADMIN_PASSWORD` startup variable. @@ -28,7 +36,7 @@ For Palworld servers you need to set `RESTAPIEnabled` to `true` and `RESTAPIPort ### Supported Games -- Minecraft (Java/Bedrock) +- Minecraft (Java/Bedrock), including Velocity/BungeeCord/Waterfall proxies - FiveM/RedM - Palworld - Any game server that uses [Valve's A2S query protocol](https://developer.valvesoftware.com/wiki/Server_queries), e.g. Garry's Mod, Rust, Barotrauma, Valheim, V Rising, The Forest, Arma 3, Arma Reforger, ARK: SE (ARK: SA will _NOT_ work), Unturned, Insurgency, Insurgency: Sandstorm + many more. diff --git a/player-counter/database/Seeders/PlayerCounterSeeder.php b/player-counter/database/Seeders/PlayerCounterSeeder.php index 8d2055d4..96e58e60 100644 --- a/player-counter/database/Seeders/PlayerCounterSeeder.php +++ b/player-counter/database/Seeders/PlayerCounterSeeder.php @@ -83,6 +83,31 @@ class PlayerCounterSeeder extends Seeder 'query_port_offset' => null, 'query_port_variable' => null, ], + // Proxy mappings must come before the generic 'minecraft' tag mapping below: an egg + // can carry both tags (e.g. a Velocity egg also tagged 'minecraft'), and only the + // first match in this list is applied per egg, so the more specific proxy mapping + // has to win the tie instead of being shadowed by the generic Java one. + [ + 'names' => 'Velocity', + 'tag' => 'velocity', + 'query_type' => 'minecraft_proxy', + 'query_port_offset' => null, + 'query_port_variable' => null, + ], + [ + 'names' => 'BungeeCord', + 'tag' => 'bungeecord', + 'query_type' => 'minecraft_proxy', + 'query_port_offset' => null, + 'query_port_variable' => null, + ], + [ + 'names' => 'Waterfall', + 'tag' => 'waterfall', + 'query_type' => 'minecraft_proxy', + 'query_port_offset' => null, + 'query_port_variable' => null, + ], [ 'tag' => 'minecraft', 'query_type' => 'minecraft_java', @@ -102,23 +127,50 @@ public function run(): void foreach (Egg::all() as $egg) { $tags = $egg->tags ?? []; - foreach (self::MAPPINGS as $mapping) { - if ((array_key_exists('names', $mapping) && in_array($egg->name, array_wrap($mapping['names']))) || (array_key_exists('tag', $mapping) && in_array($mapping['tag'], $tags))) { - try { - $query = GameQuery::firstOrCreate([ - 'query_type' => $mapping['query_type'], - 'query_port_offset' => $mapping['query_port_offset'], - 'query_port_variable' => $mapping['query_port_variable'], - ]); + // Only the first (highest-priority) match in MAPPINGS applies per egg: an egg can + // match more than one mapping (e.g. a Velocity egg also tagged 'minecraft'), and + // MAPPINGS is ordered so the more specific one wins that tie. + $mapping = null; + foreach (self::MAPPINGS as $candidate) { + if ((array_key_exists('names', $candidate) && in_array($egg->name, array_wrap($candidate['names']))) || (array_key_exists('tag', $candidate) && in_array($candidate['tag'], $tags))) { + $mapping = $candidate; + + break; + } + } + + if (!$mapping) { + continue; + } + + try { + $query = GameQuery::firstOrCreate([ + 'query_type' => $mapping['query_type'], + 'query_port_offset' => $mapping['query_port_offset'], + 'query_port_variable' => $mapping['query_port_variable'], + ]); + + /** @var ?EggGameQuery $existing */ + $existing = EggGameQuery::where('egg_id', $egg->id)->first(); - EggGameQuery::firstOrCreate([ - 'egg_id' => $egg->id, - ], [ - 'game_query_id' => $query->id, - ]); - } catch (Exception) { + if ($existing) { + // Correct the one known bad state an older version of this seeder could + // produce: a proxy egg (also tagged 'minecraft') mis-assigned minecraft_java + // because the generic mapping used to be checked before the proxy ones. + // Any other existing association is left alone, so manual admin changes + // to unrelated eggs survive a re-run of this seeder. + if ($mapping['query_type'] === 'minecraft_proxy' && GameQuery::find($existing->game_query_id)?->query_type === 'minecraft_java') { + $existing->update(['game_query_id' => $query->id]); } + + continue; } + + EggGameQuery::create([ + 'egg_id' => $egg->id, + 'game_query_id' => $query->id, + ]); + } catch (Exception) { } } diff --git a/player-counter/src/Extensions/Query/Schemas/MinecraftProxyQueryTypeSchema.php b/player-counter/src/Extensions/Query/Schemas/MinecraftProxyQueryTypeSchema.php new file mode 100644 index 00000000..1ee00423 --- /dev/null +++ b/player-counter/src/Extensions/Query/Schemas/MinecraftProxyQueryTypeSchema.php @@ -0,0 +1,29 @@ +} */ + public function process(Server $server, string $ip, int $port): ?array + { + $ping = $this->tryPing($ip, $port); + if ($ping) { + return $ping; + } + + return null; + } +} diff --git a/player-counter/src/Filament/Server/Pages/PlayersPage.php b/player-counter/src/Filament/Server/Pages/PlayersPage.php index 53ebadbc..83c9290e 100644 --- a/player-counter/src/Filament/Server/Pages/PlayersPage.php +++ b/player-counter/src/Filament/Server/Pages/PlayersPage.php @@ -44,6 +44,8 @@ class PlayersPage extends Page implements HasTable public bool $isMinecraft = false; + public bool $isProxy = false; + /** @var array */ public array $players = []; @@ -106,6 +108,7 @@ protected function loadPlayersData(): void $gameQuery = $server->egg->gameQuery; // @phpstan-ignore property.notFound $this->isMinecraft = $gameQuery?->query_type === 'minecraft_java'; + $this->isProxy = $gameQuery?->query_type === 'minecraft_proxy'; $this->whitelist = []; $this->ops = []; @@ -185,7 +188,7 @@ public function table(Table $table): Table ->grow(false) ->state(fn (array $record) => in_array($record['name'], $this->ops) ? trans('player-counter::query.op') : null), TextColumn::make('time') - ->hidden(fn () => $this->isMinecraft) + ->hidden(fn () => $this->isMinecraft || $this->isProxy) ->badge() ->grow(false) ->formatStateUsing(fn ($state) => $state ? CarbonInterval::seconds($state)->cascade()->forHumans() : null), @@ -193,7 +196,7 @@ public function table(Table $table): Table ]) ->recordActions([ Action::make('exclude_kick') - ->visible(fn () => !$this->activeTab || $this->activeTab === 'online') + ->visible(fn () => (!$this->activeTab || $this->activeTab === 'online') && !$this->isProxy) ->label(trans('player-counter::query.kick')) ->icon('tabler-door-exit') ->color('danger') @@ -222,7 +225,7 @@ public function table(Table $table): Table } }), Action::make('exclude_ban') - ->visible(fn () => !$this->activeTab || $this->activeTab === 'online') + ->visible(fn () => (!$this->activeTab || $this->activeTab === 'online') && !$this->isProxy) ->label(trans('player-counter::query.ban')) ->icon('tabler-hammer') ->color('danger') diff --git a/player-counter/src/Providers/PlayerCounterPluginProvider.php b/player-counter/src/Providers/PlayerCounterPluginProvider.php index 5ee32eab..a8a9c458 100644 --- a/player-counter/src/Providers/PlayerCounterPluginProvider.php +++ b/player-counter/src/Providers/PlayerCounterPluginProvider.php @@ -11,6 +11,7 @@ use Boy132\PlayerCounter\Extensions\Query\Schemas\GoldSourceQueryTypeSchema; use Boy132\PlayerCounter\Extensions\Query\Schemas\MinecraftBedrockQueryTypeSchema; use Boy132\PlayerCounter\Extensions\Query\Schemas\MinecraftJavaQueryTypeSchema; +use Boy132\PlayerCounter\Extensions\Query\Schemas\MinecraftProxyQueryTypeSchema; use Boy132\PlayerCounter\Extensions\Query\Schemas\PalworldQueryTypeSchema; use Boy132\PlayerCounter\Extensions\Query\Schemas\SourceQueryTypeSchema; use Boy132\PlayerCounter\Filament\Server\Widgets\ServerPlayerWidget; @@ -35,6 +36,7 @@ public function register(): void $service->register(new GoldSourceQueryTypeSchema()); $service->register(new MinecraftJavaQueryTypeSchema()); $service->register(new MinecraftBedrockQueryTypeSchema()); + $service->register(new MinecraftProxyQueryTypeSchema()); $service->register(new CitizenFXQueryTypeSchema()); $service->register(new PalworldQueryTypeSchema());