From 729c0be834af0273bfb80007d8eb3a0abaf00153 Mon Sep 17 00:00:00 2001 From: Armel van Ravels Date: Tue, 8 Sep 2026 22:33:51 +0200 Subject: [PATCH] Some improvements --- app/Console/Commands/GameStressTest.php | 184 +++++++++++++++++++++ app/Events/GameSnapshot.php | 23 ++- app/Game/GameEngine.php | 2 +- app/Game/Spawner.php | 18 +-- app/Game/StressTestHarness.php | 189 ++++++++++++++++++++++ app/Providers/AppServiceProvider.php | 5 +- config/reverb.php | 4 +- devenv.lock | 12 +- devenv.nix | 4 +- docker-compose.yml | 2 +- resources/js/main.js | 2 +- resources/js/net/NetworkClient.js | 7 +- resources/js/net/SocketClient.js | 7 +- tests/Feature/GameSnapshotTest.php | 28 ++++ tests/Feature/GameStressTestTest.php | 50 ++++++ tests/Unit/Game/StressTestHarnessTest.php | 76 +++++++++ 16 files changed, 577 insertions(+), 36 deletions(-) create mode 100644 app/Console/Commands/GameStressTest.php create mode 100644 app/Game/StressTestHarness.php create mode 100644 tests/Feature/GameSnapshotTest.php create mode 100644 tests/Feature/GameStressTestTest.php create mode 100644 tests/Unit/Game/StressTestHarnessTest.php diff --git a/app/Console/Commands/GameStressTest.php b/app/Console/Commands/GameStressTest.php new file mode 100644 index 0000000..822cedf --- /dev/null +++ b/app/Console/Commands/GameStressTest.php @@ -0,0 +1,184 @@ +resolveScenarios((string) $this->option('scenario')); + if ($scenarios === []) { + $this->error('Unknown scenario. Use zombies, projectiles, or both.'); + + return self::FAILURE; + } + + $from = (int) $this->option('entities-from'); + $to = (int) $this->option('entities-to'); + $step = (int) $this->option('step'); + $players = (int) $this->option('players'); + $ticks = (int) $this->option('ticks'); + + if ($step <= 0 || $to < $from) { + $this->error('Invalid sweep range (--entities-to must be >= --entities-from, --step must be > 0).'); + + return self::FAILURE; + } + + $counts = $this->range($from, $to, $step); + + $tickSamples = []; + $snapshotSamples = []; + foreach ($counts as $count) { + $this->line(sprintf(' sampling %d entities...', $count)); + $tickSamples[$count] = $harness->measureTickTime($count, $players, $ticks, $scenarios); + $snapshotSamples[$count] = $harness->measureSnapshotBytes($count, $players, $scenarios); + } + + if ($this->option('breakpoint-only')) { + $this->printBreakpoints($tickSamples, $snapshotSamples); + + return self::SUCCESS; + } + + $this->printTables($counts, $scenarios, $tickSamples, $snapshotSamples); + $this->printBreakpoints($tickSamples, $snapshotSamples); + + return self::SUCCESS; + } + + /** @return array */ + private function resolveScenarios(string $scenario): array + { + return match ($scenario) { + 'both' => StressTestHarness::SCENARIOS, + default => in_array($scenario, StressTestHarness::SCENARIOS, true) + ? [$scenario] + : [], + }; + } + + /** @return array */ + private function range(int $from, int $to, int $step): array + { + $counts = []; + for ($i = $from; $i <= $to; $i += $step) { + $counts[] = $i; + } + + return $counts; + } + + private function printTables(array $counts, array $scenarios, array $tickSamples, array $snapshotSamples): void + { + $this->newLine(); + $this->info('Tick time per entity count (avg/median/max ms; budget '.round(StressTestHarness::TICK_BUDGET_SECONDS * 1000, 2).' ms):'); + $this->table( + array_merge(['Entities'], $this->scenarioHeaders($scenarios)), + array_map(fn (int $count) => array_merge( + [(string) $count], + array_map(fn (string $scenario) => sprintf( + '%s/%s/%s %s', + $tickSamples[$count]['scenarios'][$scenario]['avgMs'], + $tickSamples[$count]['scenarios'][$scenario]['medianMs'], + $tickSamples[$count]['scenarios'][$scenario]['maxMs'], + $tickSamples[$count]['scenarios'][$scenario]['budgetOk'] ? '' : '⚠', + ), $scenarios), + ), $counts), + ); + + $this->newLine(); + $this->info('Snapshot broadcast size per entity count (Reverb cap '.StressTestHarness::REVERB_MAX_BYTES.' bytes):'); + $this->table( + array_merge(['Entities'], $this->scenarioHeaders($scenarios)), + array_map(fn (int $count) => array_merge( + [(string) $count], + array_map(fn (string $scenario) => sprintf( + '%d B (%.1f B/ent) %s', + $snapshotSamples[$count]['scenarios'][$scenario]['bytes'], + $snapshotSamples[$count]['scenarios'][$scenario]['bytesPerEntity'], + $snapshotSamples[$count]['scenarios'][$scenario]['exceedsReverb'] ? '⚠' : '', + ), $scenarios), + ), $counts), + ); + } + + private function printBreakpoints(array $tickSamples, array $snapshotSamples): void + { + $this->newLine(); + $this->info('Derived capacity limits:'); + + $budgetMs = StressTestHarness::TICK_BUDGET_SECONDS * 1000.0; + + $badTick = $this->firstBad('budgetOk', false, $tickSamples); + if ($badTick !== null) { + $this->line(sprintf( + ' Avg tick exceeds %.2f ms budget at %d entities (scenario: %s).', + $budgetMs, + $badTick['count'], + $badTick['scenario'], + )); + } else { + $this->line(sprintf(' Avg tick stays under %.2f ms across all sampled entity counts.', $budgetMs)); + } + + $badSnapshot = $this->firstBad('exceedsReverb', true, $snapshotSamples); + if ($badSnapshot !== null) { + $this->line(sprintf( + ' Snapshot exceeds %d-byte Reverb cap at %d entities (scenario: %s).', + StressTestHarness::REVERB_MAX_BYTES, + $badSnapshot['count'], + $badSnapshot['scenario'], + )); + } else { + $this->line(sprintf(' Snapshot stays under %d-byte Reverb cap across all sampled entity counts.', StressTestHarness::REVERB_MAX_BYTES)); + } + } + + /** + * @param array> $samples + * @return array{count: int, scenario: string}|null + */ + private function firstBad(string $key, mixed $target, array $samples): ?array + { + foreach ($samples as $count => $sample) { + foreach (array_keys($sample['scenarios']) as $scenario) { + if ($sample['scenarios'][$scenario][$key] === $target) { + return ['count' => $count, 'scenario' => $scenario]; + } + } + } + + return null; + } + + /** @return array */ + private function scenarioHeaders(array $scenarios): array + { + return array_map(fn (string $scenario) => ucfirst($scenario), $scenarios); + } +} diff --git a/app/Events/GameSnapshot.php b/app/Events/GameSnapshot.php index 7367946..eef43d8 100644 --- a/app/Events/GameSnapshot.php +++ b/app/Events/GameSnapshot.php @@ -18,21 +18,20 @@ final class GameSnapshot implements ShouldBroadcastNow use SerializesModels; /** - * @param array $players - * @param array $entities - * @param array $events + * @param array $players + * @param array $entities + * @param array $events */ public function __construct( public Room $room, public array $players, public array $entities, public array $events, - ) { - } + ) {} public function broadcastOn(): Channel { - return new Channel('game.' . $this->room->id); + return new Channel('game.'.$this->room->id); } public function broadcastAs(): string @@ -50,6 +49,16 @@ final class GameSnapshot implements ShouldBroadcastNow 'players' => $this->players, 'entities' => $this->entities, 'events' => $this->events, + 'msgCap' => $this->messageSizeCap(), ]; } -} \ No newline at end of file + + /** + * Largest snapshot the WebSocket layer will deliver in one frame; used by + * the client debug HUD to show broadcast headroom against the Reverb cap. + */ + private function messageSizeCap(): int + { + return (int) config('reverb.apps.apps.0.max_message_size', 20_000); + } +} diff --git a/app/Game/GameEngine.php b/app/Game/GameEngine.php index d2571d8..e95ff45 100644 --- a/app/Game/GameEngine.php +++ b/app/Game/GameEngine.php @@ -15,7 +15,7 @@ final class GameEngine { private const PICKUP_INTERVAL = 10.0; - private const MAX_ZOMBIES = 30; + private const MAX_ZOMBIES = 45; public function __construct( private readonly Room $room, diff --git a/app/Game/Spawner.php b/app/Game/Spawner.php index 1e9d92d..04ae81e 100644 --- a/app/Game/Spawner.php +++ b/app/Game/Spawner.php @@ -16,42 +16,42 @@ final class Spawner { public function spawnInterval(float $elapsed): float { - return max(0.35, 1.2 - $elapsed * 0.008); + return max(0.22, 1.0 - $elapsed * 0.01); } public function speedMultiplier(float $elapsed): float { - return min(2.2, 1.0 + $elapsed * 0.004); + return min(2.6, 1.0 + $elapsed * 0.006); } private function tankChance(float $elapsed): float { - return min(0.5, 0.15 + $elapsed * 0.002); + return min(0.55, 0.18 + $elapsed * 0.0025); } private function megaTankChance(float $elapsed): float { - return min(0.22, $elapsed * 0.0015); + return min(0.3, $elapsed * 0.002); } private function runnerChance(float $elapsed): float { - return min(0.4, 0.2 + $elapsed * 0.001); + return min(0.45, 0.22 + $elapsed * 0.0015); } private function leaperChance(float $elapsed): float { - return min(0.12, 0.04 + $elapsed * 0.06); + return min(0.16, 0.05 + $elapsed * 0.08); } private function witchChance(float $elapsed): float { - return min(0.3, 0.1 + $elapsed * 0.002); + return min(0.35, 0.12 + $elapsed * 0.0025); } private function megaWitchChance(float $elapsed): float { - return $elapsed < 45.0 ? 0.0 : min(0.12, ($elapsed - 45.0) * 0.0008); + return $elapsed < 40.0 ? 0.0 : min(0.16, ($elapsed - 40.0) * 0.001); } public function spawnZombie(Room $room): void @@ -204,7 +204,7 @@ final class Spawner $room->entities->append(new Leaper($x, $y, $room->entities, $room, $this->initialTarget($room), [ 'speed' => (80.0 + $this->rand() * 40.0) * $speedMultiplier, 'size' => 28.0, - 'health' => 3, + 'health' => 10, 'knockback' => 240.0, 'scoreValue' => 6, 'color' => [0.15, 0.25, 0.75, 1.0], diff --git a/app/Game/StressTestHarness.php b/app/Game/StressTestHarness.php new file mode 100644 index 0000000..f1aa786 --- /dev/null +++ b/app/Game/StressTestHarness.php @@ -0,0 +1,189 @@ +} + */ + public function measureTickTime(int $entityCount, int $players = 2, int $ticks = 600, array $scenarios = self::SCENARIOS): array + { + $results = ['entities' => $entityCount, 'scenarios' => []]; + + foreach ($scenarios as $scenario) { + $room = $this->makeRoom($entityCount, $scenario, $players); + $engine = new GameEngine($room, new Spawner); + + for ($i = 0; $i < 30; $i++) { + $this->stepAndRefill($engine, $room, $entityCount, $scenario); + } + + $samples = []; + for ($i = 0; $i < $ticks; $i++) { + $start = hrtime(true); + $this->stepAndRefill($engine, $room, $entityCount, $scenario); + $samples[] = (hrtime(true) - $start) / 1e6; + } + + sort($samples); + $avg = array_sum($samples) / count($samples); + $median = $samples[(int) floor(count($samples) / 2)]; + $max = $samples[count($samples) - 1]; + + $results['scenarios'][$scenario] = [ + 'avgMs' => round($avg, 3), + 'medianMs' => round($median, 3), + 'maxMs' => round($max, 3), + 'budgetOk' => $avg <= self::TICK_BUDGET_SECONDS * 1000.0, + ]; + } + + return $results; + } + + /** Run one tick, then re-seed the target population for steady-state load. */ + private function stepAndRefill(GameEngine $engine, Room $room, int $target, string $scenario): void + { + $engine->tick(self::TICK_BUDGET_SECONDS); + + $current = 0; + foreach ($room->entities as $entity) { + if ($scenario === 'projectiles') { + if ($entity instanceof Witch) { + $current++; + } + } elseif ($entity instanceof Zombie) { + $current++; + } + } + + for ($i = $current; $i < $target; $i++) { + $x = 300.0 + (($i + $room->seq) % 20) * 40.0; + $y = 100.0 + intdiv($i + $room->seq, 20) * 60.0; + if ($scenario === 'projectiles') { + $room->entities->append(new Witch($x, $y, $room->entities, $room, $this->firstPlayer($room), [ + 'emergeTime' => 0.0, + 'fireInterval' => 0.4, + ])); + } else { + $room->entities->append(new Zombie($x, $y, $room->entities, $room, [ + 'emergeTime' => 0.0, + 'speed' => 150.0, + 'health' => 1, + ], fn (Zombie $z) => $z->target = $this->firstPlayer($room))); + } + } + } + + /** + * @return array{entities: int, scenarios: array} + */ + public function measureSnapshotBytes(int $entityCount, int $players = 2, array $scenarios = self::SCENARIOS): array + { + $results = ['entities' => $entityCount, 'scenarios' => []]; + + foreach ($scenarios as $scenario) { + $room = $this->makeRoom($entityCount, $scenario, $players); + + $payload = null; + $simulator = new GameSimulator(function (Room $r, array $playersList, array $entities, array $events) use (&$payload): void { + $payload = [ + 'state' => $r->state, + 'elapsed' => round($r->elapsed, 2), + 'seq' => $r->seq, + 'players' => $playersList, + 'entities' => $entities, + 'events' => $events, + ]; + }); + + $simulator->broadcastRoom($room); + + $bytes = strlen(json_encode($payload) ?: ''); + $results['scenarios'][$scenario] = [ + 'bytes' => $bytes, + 'bytesPerEntity' => $entityCount > 0 ? round($bytes / $entityCount, 1) : 0.0, + 'exceedsReverb' => $bytes > self::REVERB_MAX_BYTES, + ]; + } + + return $results; + } + + private function makeRoom(int $entityCount, string $scenario, int $players): Room + { + $room = new Room('bench'); + $room->state = Room::STATE_PLAYING; + + for ($i = 0; $i < $players; $i++) { + $input = new InputState; + $player = new Player((string) $i, "P$i", $input, $room->entities, $room, [ + 'x' => 200.0 + $i * 60.0, + 'y' => 540.0, + 'fireRate' => 0.1, + 'bulletCount' => 3, + ]); + $room->addPlayer($player); + } + + for ($i = 0; $i < $entityCount; $i++) { + $x = 300.0 + ($i % 20) * 40.0; + $y = 100.0 + intdiv($i, 20) * 60.0; + if ($scenario === 'projectiles') { + $room->entities->append(new Witch($x, $y, $room->entities, $room, $this->firstPlayer($room), [ + 'emergeTime' => 0.0, + 'fireInterval' => 0.5, + ])); + } else { + $room->entities->append(new Zombie($x, $y, $room->entities, $room, [ + 'emergeTime' => 0.0, + 'speed' => 150.0, + 'health' => 2, + ], fn (Zombie $z) => $z->target = $this->firstPlayer($room))); + } + } + + foreach ($room->entities as $entity) { + if ($entity instanceof Zombie || $entity instanceof WitchProjectile) { + $entity->target = $this->firstPlayer($room); + } + } + + return $room; + } + + private function firstPlayer(Room $room): ?Player + { + return $room->players === [] ? null : $room->players[array_key_first($room->players)]; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..de27c93 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,7 @@ namespace App\Providers; +use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -19,6 +20,8 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { - // + if (app()->environment('production')) { + URL::forceScheme('https'); + } } } diff --git a/config/reverb.php b/config/reverb.php index 872ab98..fb129b5 100644 --- a/config/reverb.php +++ b/config/reverb.php @@ -36,7 +36,7 @@ return [ 'options' => [ 'tls' => [], ], - 'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000), + 'max_request_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 20_000), 'scaling' => [ 'enabled' => env('REVERB_SCALING_ENABLED', false), 'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'), @@ -86,7 +86,7 @@ return [ 'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60), 'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30), 'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'), - 'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000), + 'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 20_000), 'accept_client_events_from' => env('REVERB_APP_ACCEPT_CLIENT_EVENTS_FROM', 'all'), 'rate_limiting' => [ 'enabled' => env('REVERB_APP_RATE_LIMITING_ENABLED', false), diff --git a/devenv.lock b/devenv.lock index 3c13aac..874b241 100644 --- a/devenv.lock +++ b/devenv.lock @@ -3,11 +3,11 @@ "devenv": { "locked": { "dir": "src/modules", - "lastModified": 1788707710, - "narHash": "sha256-+ZyV4DJ5ljfp3ouZ1m3mAVWawzXLsn3i5YPvg34aURs=", + "lastModified": 1788819331, + "narHash": "sha256-PXClmncMTmWutJvFGrA6rKyvxWtUwaMUT615XrDHdB8=", "owner": "cachix", "repo": "devenv", - "rev": "34eb5ddc0db51fbf6f3a8df5fc42b0e91a5be8f8", + "rev": "190959a9a4bb52d4802f076a90c3c4e3aa2e6fa2", "type": "github" }, "original": { @@ -19,11 +19,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1788549839, - "narHash": "sha256-kOrCcSIA6w9J1hX5DqHy2k9pDTJymExTsbV74U9UtCA=", + "lastModified": 1788834054, + "narHash": "sha256-9dzTcXMtlknZyT47Vv1PnpGgN7UYA8WxxqyxsKdvYbo=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "17de0b976395537756f30a3e78f2f06e5cec89ed", + "rev": "e9b9cbecbf6c25ca26c150d78570e6332694a129", "type": "github" }, "original": { diff --git a/devenv.nix b/devenv.nix index d4946f1..fb62ae1 100644 --- a/devenv.nix +++ b/devenv.nix @@ -13,7 +13,7 @@ pkgs.php85Packages.composer ]; - enterShell = '' + enterShell = '' echo "Multiplayer game server dev environment ready." php -v | head -1 redis-cli ping >/dev/null 2>&1 || echo "Redis: start with: devenv up" @@ -23,4 +23,4 @@ scripts.reverb.exec = "php artisan reverb:start"; scripts.simulate.exec = "php artisan game:simulate"; scripts.test.exec = "php artisan test"; -} \ No newline at end of file +} diff --git a/docker-compose.yml b/docker-compose.yml index e3ff11b..0558f95 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,7 +23,7 @@ services: APP_NAME: Zombies APP_ENV: production APP_DEBUG: "false" - APP_URL: http://localhost + APP_URL: https://022e-62-250-222-180.ngrok-free.app APP_KEY: ${APP_KEY:-} LOG_CHANNEL: stderr BROADCAST_CONNECTION: reverb diff --git a/resources/js/main.js b/resources/js/main.js index 5a19c9f..8596c33 100644 --- a/resources/js/main.js +++ b/resources/js/main.js @@ -1098,4 +1098,4 @@ window.addEventListener('beforeunload', () => { networking.leave(); }); -requestAnimationFrame(gameLoop); \ No newline at end of file +requestAnimationFrame(gameLoop); diff --git a/resources/js/net/NetworkClient.js b/resources/js/net/NetworkClient.js index dadc697..da0080b 100644 --- a/resources/js/net/NetworkClient.js +++ b/resources/js/net/NetworkClient.js @@ -66,9 +66,9 @@ export class NetworkClient { this.currAt = 0; this.socket.subscribe(this._snapshotChannel(), { - onEvent: (event, payload) => { + onEvent: (event, payload, rawBytes) => { if (event === 'snapshot') { - this._ingest(payload); + this._ingest(payload, rawBytes); } else if (event === 'room-error') { this.onRoomError?.(payload); } @@ -188,13 +188,14 @@ export class NetworkClient { }); } - _ingest(payload) { + _ingest(payload, rawBytes = 0) { if (this.curr && payload.seq !== undefined && this.curr.seq !== undefined && payload.seq <= this.curr.seq) { return; } this.prev = this.curr; this.prevAt = this.currAt; this.curr = payload; + this.curr.bytes = rawBytes; this.currAt = performance.now(); } } \ No newline at end of file diff --git a/resources/js/net/SocketClient.js b/resources/js/net/SocketClient.js index 647cf98..42161bd 100644 --- a/resources/js/net/SocketClient.js +++ b/resources/js/net/SocketClient.js @@ -42,7 +42,8 @@ export class SocketClient { } catch { return; } - this._handle(msg); + const rawBytes = typeof event.data === 'string' ? new Blob([event.data]).size : 0; + this._handle(msg, rawBytes); }; socket.onclose = () => { @@ -76,7 +77,7 @@ export class SocketClient { this.socketId = null; } - _handle(msg) { + _handle(msg, rawBytes = 0) { const { event, channel } = msg; if (event === 'pusher:connection_established') { @@ -117,7 +118,7 @@ export class SocketClient { return; } } - this.handlers.get(channel)?.onEvent?.(event, payload); + this.handlers.get(channel)?.onEvent?.(event, payload, rawBytes); } } diff --git a/tests/Feature/GameSnapshotTest.php b/tests/Feature/GameSnapshotTest.php new file mode 100644 index 0000000..9564b34 --- /dev/null +++ b/tests/Feature/GameSnapshotTest.php @@ -0,0 +1,28 @@ +broadcastWith(); + + $this->assertArrayHasKey('msgCap', $payload); + $this->assertSame( + (int) config('reverb.apps.apps.0.max_message_size', 0), + $payload['msgCap'], + 'client debug HUD reports the same cap the Reverb server enforces', + ); + } +} diff --git a/tests/Feature/GameStressTestTest.php b/tests/Feature/GameStressTestTest.php new file mode 100644 index 0000000..8a99d0c --- /dev/null +++ b/tests/Feature/GameStressTestTest.php @@ -0,0 +1,50 @@ +artisan('game:stress', ['--scenario' => 'aliens']) + ->expectsOutputToContain('Unknown scenario') + ->assertExitCode(1); + } + + #[Test] + public function step_below_one_rejects_with_failure(): void + { + $this->artisan('game:stress', ['--step' => '0']) + ->expectsOutputToContain('Invalid sweep range') + ->assertExitCode(1); + } + + #[Test] + public function inverted_range_rejects_with_failure(): void + { + $this->artisan('game:stress', ['--entities-from' => '200', '--entities-to' => '100']) + ->expectsOutputToContain('Invalid sweep range') + ->assertExitCode(1); + } + + #[Test] + public function breakpoint_run_reports_capacity_limits(): void + { + $this->artisan('game:stress', [ + '--entities-from' => '50', + '--entities-to' => '50', + '--step' => '50', + '--ticks' => '5', + '--breakpoint-only' => true, + ]) + ->expectsOutputToContain('Derived capacity limits') + ->expectsOutputToContain('Reverb cap') + ->assertExitCode(0); + } +} diff --git a/tests/Unit/Game/StressTestHarnessTest.php b/tests/Unit/Game/StressTestHarnessTest.php new file mode 100644 index 0000000..9e53588 --- /dev/null +++ b/tests/Unit/Game/StressTestHarnessTest.php @@ -0,0 +1,76 @@ +harness()->measureTickTime(50, players: 2, ticks: 20); + + $this->assertSame(50, $report['entities']); + $this->assertArrayHasKey('zombies', $report['scenarios']); + $this->assertArrayHasKey('projectiles', $report['scenarios']); + foreach ($report['scenarios'] as $result) { + $this->assertArrayHasKey('avgMs', $result); + $this->assertArrayHasKey('medianMs', $result); + $this->assertArrayHasKey('maxMs', $result); + $this->assertArrayHasKey('budgetOk', $result); + } + } + + #[Test] + public function tick_time_is_measured_as_positive(): void + { + $report = $this->harness()->measureTickTime(50, players: 2, ticks: 20); + + foreach ($report['scenarios'] as $result) { + $this->assertGreaterThan(0.0, $result['avgMs']); + $this->assertGreaterThan(0.0, $result['maxMs']); + } + } + + #[Test] + public function snapshot_bytes_grow_with_entity_count(): void + { + $small = $this->harness()->measureSnapshotBytes(50, players: 2, scenarios: ['zombies']); + $large = $this->harness()->measureSnapshotBytes(400, players: 2, scenarios: ['zombies']); + + $this->assertGreaterThan( + $small['scenarios']['zombies']['bytes'], + $large['scenarios']['zombies']['bytes'], + 'snapshot payload should grow as entity count grows', + ); + } + + #[Test] + public function snapshot_report_tracks_bytes_per_entity(): void + { + $report = $this->harness()->measureSnapshotBytes(100, players: 2, scenarios: ['zombies']); + + $this->assertGreaterThan(0, $report['scenarios']['zombies']['bytes']); + $this->assertGreaterThan(0.0, $report['scenarios']['zombies']['bytesPerEntity']); + } + + #[Test] + public function reverb_cap_flag_flips_between_small_and_large_rooms(): void + { + $small = $this->harness()->measureSnapshotBytes(50, players: 2, scenarios: ['zombies']); + $large = $this->harness()->measureSnapshotBytes(2000, players: 2, scenarios: ['zombies']); + + $this->assertFalse($small['scenarios']['zombies']['exceedsReverb']); + $this->assertTrue($large['scenarios']['zombies']['exceedsReverb']); + } +}