Some improvements
This commit is contained in:
1 parent
a914bd74fe
commit
729c0be834
16 files changed
+577
-36
No files matched your search
@@ -0,0 +1,184 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,21 +18,20 @@ final class GameSnapshot implements ShouldBroadcastNow
|
|||||||
use SerializesModels;
|
use SerializesModels;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $players
|
* @param array<string, mixed> $players
|
||||||
* @param array<int, mixed> $entities
|
* @param array<int, mixed> $entities
|
||||||
* @param array<int, mixed> $events
|
* @param array<int, mixed> $events
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public Room $room,
|
public Room $room,
|
||||||
public array $players,
|
public array $players,
|
||||||
public array $entities,
|
public array $entities,
|
||||||
public array $events,
|
public array $events,
|
||||||
) {
|
) {}
|
||||||
}
|
|
||||||
|
|
||||||
public function broadcastOn(): Channel
|
public function broadcastOn(): Channel
|
||||||
{
|
{
|
||||||
return new Channel('game.' . $this->room->id);
|
return new Channel('game.'.$this->room->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function broadcastAs(): string
|
public function broadcastAs(): string
|
||||||
@@ -50,6 +49,16 @@ final class GameSnapshot implements ShouldBroadcastNow
|
|||||||
'players' => $this->players,
|
'players' => $this->players,
|
||||||
'entities' => $this->entities,
|
'entities' => $this->entities,
|
||||||
'events' => $this->events,
|
'events' => $this->events,
|
||||||
|
'msgCap' => $this->messageSizeCap(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ final class GameEngine
|
|||||||
{
|
{
|
||||||
private const PICKUP_INTERVAL = 10.0;
|
private const PICKUP_INTERVAL = 10.0;
|
||||||
|
|
||||||
private const MAX_ZOMBIES = 30;
|
private const MAX_ZOMBIES = 45;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly Room $room,
|
private readonly Room $room,
|
||||||
|
|||||||
@@ -16,42 +16,42 @@ final class Spawner
|
|||||||
{
|
{
|
||||||
public function spawnInterval(float $elapsed): float
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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), [
|
$room->entities->append(new Leaper($x, $y, $room->entities, $room, $this->initialTarget($room), [
|
||||||
'speed' => (80.0 + $this->rand() * 40.0) * $speedMultiplier,
|
'speed' => (80.0 + $this->rand() * 40.0) * $speedMultiplier,
|
||||||
'size' => 28.0,
|
'size' => 28.0,
|
||||||
'health' => 3,
|
'health' => 10,
|
||||||
'knockback' => 240.0,
|
'knockback' => 240.0,
|
||||||
'scoreValue' => 6,
|
'scoreValue' => 6,
|
||||||
'color' => [0.15, 0.25, 0.75, 1.0],
|
'color' => [0.15, 0.25, 0.75, 1.0],
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Game;
|
||||||
|
|
||||||
|
use App\Game\Entities\Witch;
|
||||||
|
use App\Game\Entities\WitchProjectile;
|
||||||
|
use App\Game\Entities\Zombie;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only benchmark harness for the authoritative game simulation.
|
||||||
|
*
|
||||||
|
* Measures per-tick elapsed time of GameEngine::tick() and the serialized
|
||||||
|
* snapshot payload size produced by GameSimulator::broadcastRoom() at various
|
||||||
|
* entity counts, so we can reason about capacity without touching production
|
||||||
|
* logic or firing real broadcasts.
|
||||||
|
*/
|
||||||
|
final class StressTestHarness
|
||||||
|
{
|
||||||
|
/** Reliable per-frame budget of the fixed 60 Hz simulation loop. */
|
||||||
|
public const TICK_BUDGET_SECONDS = 1.0 / 60.0;
|
||||||
|
|
||||||
|
/** Reverb hard cap on a single broadcast message (config/reverb.php). */
|
||||||
|
public const REVERB_MAX_BYTES = 20000;
|
||||||
|
|
||||||
|
public const SCENARIOS = ['zombies', 'projectiles'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measures sustained per-tick cost while keeping the entity population at
|
||||||
|
* (and refilling to) the requested count each tick, so the sample reflects
|
||||||
|
* steady-state load rather than a population that the players quickly thin
|
||||||
|
* out. The cost measured is the full collision/resolution path of
|
||||||
|
* GameEngine::tick(), which is what determines whether the 60 Hz loop can
|
||||||
|
* keep up.
|
||||||
|
*
|
||||||
|
* @return array{entities: int, scenarios: array<string, array{avgMs: float, medianMs: float, maxMs: float, budgetOk: bool}>}
|
||||||
|
*/
|
||||||
|
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<string, array{bytes: int, bytesPerEntity: float, exceedsReverb: bool}>}
|
||||||
|
*/
|
||||||
|
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)];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
@@ -19,6 +20,8 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
//
|
if (app()->environment('production')) {
|
||||||
|
URL::forceScheme('https');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-2
@@ -36,7 +36,7 @@ return [
|
|||||||
'options' => [
|
'options' => [
|
||||||
'tls' => [],
|
'tls' => [],
|
||||||
],
|
],
|
||||||
'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
|
'max_request_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 20_000),
|
||||||
'scaling' => [
|
'scaling' => [
|
||||||
'enabled' => env('REVERB_SCALING_ENABLED', false),
|
'enabled' => env('REVERB_SCALING_ENABLED', false),
|
||||||
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
|
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
|
||||||
@@ -86,7 +86,7 @@ return [
|
|||||||
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
|
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
|
||||||
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
|
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
|
||||||
'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
|
'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'),
|
'accept_client_events_from' => env('REVERB_APP_ACCEPT_CLIENT_EVENTS_FROM', 'all'),
|
||||||
'rate_limiting' => [
|
'rate_limiting' => [
|
||||||
'enabled' => env('REVERB_APP_RATE_LIMITING_ENABLED', false),
|
'enabled' => env('REVERB_APP_RATE_LIMITING_ENABLED', false),
|
||||||
|
|||||||
+6
-6
@@ -3,11 +3,11 @@
|
|||||||
"devenv": {
|
"devenv": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"dir": "src/modules",
|
"dir": "src/modules",
|
||||||
"lastModified": 1788707710,
|
"lastModified": 1788819331,
|
||||||
"narHash": "sha256-+ZyV4DJ5ljfp3ouZ1m3mAVWawzXLsn3i5YPvg34aURs=",
|
"narHash": "sha256-PXClmncMTmWutJvFGrA6rKyvxWtUwaMUT615XrDHdB8=",
|
||||||
"owner": "cachix",
|
"owner": "cachix",
|
||||||
"repo": "devenv",
|
"repo": "devenv",
|
||||||
"rev": "34eb5ddc0db51fbf6f3a8df5fc42b0e91a5be8f8",
|
"rev": "190959a9a4bb52d4802f076a90c3c4e3aa2e6fa2",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -19,11 +19,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1788549839,
|
"lastModified": 1788834054,
|
||||||
"narHash": "sha256-kOrCcSIA6w9J1hX5DqHy2k9pDTJymExTsbV74U9UtCA=",
|
"narHash": "sha256-9dzTcXMtlknZyT47Vv1PnpGgN7UYA8WxxqyxsKdvYbo=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "17de0b976395537756f30a3e78f2f06e5cec89ed",
|
"rev": "e9b9cbecbf6c25ca26c150d78570e6332694a129",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
+2
-2
@@ -13,7 +13,7 @@
|
|||||||
pkgs.php85Packages.composer
|
pkgs.php85Packages.composer
|
||||||
];
|
];
|
||||||
|
|
||||||
enterShell = ''
|
enterShell = ''
|
||||||
echo "Multiplayer game server dev environment ready."
|
echo "Multiplayer game server dev environment ready."
|
||||||
php -v | head -1
|
php -v | head -1
|
||||||
redis-cli ping >/dev/null 2>&1 || echo "Redis: start with: devenv up"
|
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.reverb.exec = "php artisan reverb:start";
|
||||||
scripts.simulate.exec = "php artisan game:simulate";
|
scripts.simulate.exec = "php artisan game:simulate";
|
||||||
scripts.test.exec = "php artisan test";
|
scripts.test.exec = "php artisan test";
|
||||||
}
|
}
|
||||||
+1
-1
@@ -23,7 +23,7 @@ services:
|
|||||||
APP_NAME: Zombies
|
APP_NAME: Zombies
|
||||||
APP_ENV: production
|
APP_ENV: production
|
||||||
APP_DEBUG: "false"
|
APP_DEBUG: "false"
|
||||||
APP_URL: http://localhost
|
APP_URL: https://022e-62-250-222-180.ngrok-free.app
|
||||||
APP_KEY: ${APP_KEY:-}
|
APP_KEY: ${APP_KEY:-}
|
||||||
LOG_CHANNEL: stderr
|
LOG_CHANNEL: stderr
|
||||||
BROADCAST_CONNECTION: reverb
|
BROADCAST_CONNECTION: reverb
|
||||||
|
|||||||
@@ -1098,4 +1098,4 @@ window.addEventListener('beforeunload', () => {
|
|||||||
networking.leave();
|
networking.leave();
|
||||||
});
|
});
|
||||||
|
|
||||||
requestAnimationFrame(gameLoop);
|
requestAnimationFrame(gameLoop);
|
||||||
@@ -66,9 +66,9 @@ export class NetworkClient {
|
|||||||
this.currAt = 0;
|
this.currAt = 0;
|
||||||
|
|
||||||
this.socket.subscribe(this._snapshotChannel(), {
|
this.socket.subscribe(this._snapshotChannel(), {
|
||||||
onEvent: (event, payload) => {
|
onEvent: (event, payload, rawBytes) => {
|
||||||
if (event === 'snapshot') {
|
if (event === 'snapshot') {
|
||||||
this._ingest(payload);
|
this._ingest(payload, rawBytes);
|
||||||
} else if (event === 'room-error') {
|
} else if (event === 'room-error') {
|
||||||
this.onRoomError?.(payload);
|
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) {
|
if (this.curr && payload.seq !== undefined && this.curr.seq !== undefined && payload.seq <= this.curr.seq) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.prev = this.curr;
|
this.prev = this.curr;
|
||||||
this.prevAt = this.currAt;
|
this.prevAt = this.currAt;
|
||||||
this.curr = payload;
|
this.curr = payload;
|
||||||
|
this.curr.bytes = rawBytes;
|
||||||
this.currAt = performance.now();
|
this.currAt = performance.now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,7 +42,8 @@ export class SocketClient {
|
|||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this._handle(msg);
|
const rawBytes = typeof event.data === 'string' ? new Blob([event.data]).size : 0;
|
||||||
|
this._handle(msg, rawBytes);
|
||||||
};
|
};
|
||||||
|
|
||||||
socket.onclose = () => {
|
socket.onclose = () => {
|
||||||
@@ -76,7 +77,7 @@ export class SocketClient {
|
|||||||
this.socketId = null;
|
this.socketId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
_handle(msg) {
|
_handle(msg, rawBytes = 0) {
|
||||||
const { event, channel } = msg;
|
const { event, channel } = msg;
|
||||||
|
|
||||||
if (event === 'pusher:connection_established') {
|
if (event === 'pusher:connection_established') {
|
||||||
@@ -117,7 +118,7 @@ export class SocketClient {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.handlers.get(channel)?.onEvent?.(event, payload);
|
this.handlers.get(channel)?.onEvent?.(event, payload, rawBytes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Events\GameSnapshot;
|
||||||
|
use App\Game\Room;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
final class GameSnapshotTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
public function broadcast_payload_discloses_message_size_cap(): void
|
||||||
|
{
|
||||||
|
$room = new Room('test');
|
||||||
|
|
||||||
|
$payload = (new GameSnapshot($room, [], [], []))->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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
final class GameStressTestTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
public function unknown_scenario_rejects_with_failure(): void
|
||||||
|
{
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Unit\Game;
|
||||||
|
|
||||||
|
use App\Game\StressTestHarness;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class StressTestHarnessTest extends TestCase
|
||||||
|
{
|
||||||
|
private function harness(): StressTestHarness
|
||||||
|
{
|
||||||
|
return new StressTestHarness;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function tick_report_has_expected_shape(): void
|
||||||
|
{
|
||||||
|
$report = $this->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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user