Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion player-counter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
80 changes: 66 additions & 14 deletions player-counter/database/Seeders/PlayerCounterSeeder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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) {
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace Boy132\PlayerCounter\Extensions\Query\Schemas;

use App\Models\Server;

class MinecraftProxyQueryTypeSchema extends MinecraftJavaQueryTypeSchema
{
public function getId(): string
{
return 'minecraft_proxy';
}

public function getName(): string
{
return 'Minecraft (Proxy)';
}

/** @return ?array{hostname: string, map: string, current_players: int, max_players: int, players: array<array{id: string, name: string}>} */
public function process(Server $server, string $ip, int $port): ?array
{
$ping = $this->tryPing($ip, $port);
if ($ping) {
return $ping;
}

return null;
}
}
9 changes: 6 additions & 3 deletions player-counter/src/Filament/Server/Pages/PlayersPage.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ class PlayersPage extends Page implements HasTable

public bool $isMinecraft = false;

public bool $isProxy = false;

/** @var array<string, mixed> */
public array $players = [];

Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -185,15 +188,15 @@ 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),
]),
])
->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')
Expand Down Expand Up @@ -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')
Expand Down
2 changes: 2 additions & 0 deletions player-counter/src/Providers/PlayerCounterPluginProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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());

Expand Down
Loading