185 lines
6.8 KiB
PHP
185 lines
6.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Game\StressTestHarness;
|
|
use Illuminate\Console\Command;
|
|
|
|
/**
|
|
* Runs the authoritative simulation stress sweep and reports capacity limits.
|
|
*
|
|
* Measures GameEngine::tick() time and snapshot broadcast payload size at
|
|
* increasing entity counts, then derives the entity count where the average
|
|
* tick exceeds the 60 Hz budget and where the snapshot exceeds Reverb's max
|
|
* message size.
|
|
*/
|
|
final class GameStressTest extends Command
|
|
{
|
|
protected $signature = 'game:stress
|
|
{--scenario=both : zombies, projectiles, or both}
|
|
{--players=2 : number of players in the room}
|
|
{--entities-from=50 : first entity count sampled}
|
|
{--entities-to=400 : last entity count sampled}
|
|
{--step=50 : increment between sampled entity counts}
|
|
{--ticks=600 : simulation ticks measured per sample}
|
|
{--breakpoint-only : print only the derived capacity limits}';
|
|
|
|
protected $description = 'Benchmark simulation tick time and snapshot payload size across entity counts';
|
|
|
|
public function handle(StressTestHarness $harness): int
|
|
{
|
|
$scenarios = $this->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<int, string> */
|
|
private function resolveScenarios(string $scenario): array
|
|
{
|
|
return match ($scenario) {
|
|
'both' => StressTestHarness::SCENARIOS,
|
|
default => in_array($scenario, StressTestHarness::SCENARIOS, true)
|
|
? [$scenario]
|
|
: [],
|
|
};
|
|
}
|
|
|
|
/** @return array<int, int> */
|
|
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<int, array<string, mixed>> $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<int, string> */
|
|
private function scenarioHeaders(array $scenarios): array
|
|
{
|
|
return array_map(fn (string $scenario) => ucfirst($scenario), $scenarios);
|
|
}
|
|
}
|