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); } }