> */ private array $broadcasts = []; /** @var array */ private array $errors = []; private function makeSimulator(): GameSimulator { $this->broadcasts = []; $this->errors = []; return new GameSimulator( function (Room $room, array $players, array $entities, array $events): void { $this->broadcasts[] = [ 'room' => $room->id, 'state' => $room->state, 'players' => $players, 'entities' => $entities, 'events' => $events, ]; }, function (string $roomId, string $code): void { $this->errors[] = ['room' => $roomId, 'code' => $code]; }, ); } #[Test] public function create_creates_room_and_broadcasts_lobby(): void { $sim = $this->makeSimulator(); $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'Alice']); $this->assertCount(1, $sim->roomIds()); $this->assertSame(Room::STATE_LOBBY, $sim->room('room-a')->state); $this->assertCount(1, $sim->room('room-a')->players); $this->assertSame('Alice', $this->broadcasts[0]['players'][0]['name']); $this->assertSame('p1', $sim->room('room-a')->hostId); } #[Test] public function snapshot_marks_host(): void { $sim = $this->makeSimulator(); $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); $this->assertTrue($this->broadcasts[0]['players'][0]['host']); $sim->applyControl('room-a', ['type' => 'join', 'id' => 'p2', 'name' => 'B']); $last = $this->broadcasts[array_key_last($this->broadcasts)]; $hosts = []; foreach ($last['players'] as $player) { $hosts[$player['id']] = $player['host']; } $this->assertSame(['p1' => true, 'p2' => false], $hosts); } #[Test] public function start_transitions_to_playing(): void { $sim = $this->makeSimulator(); $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); $sim->applyControl('room-a', ['type' => 'start', 'id' => 'p1']); $this->assertSame(Room::STATE_PLAYING, $sim->room('room-a')->state); } #[Test] public function guest_cannot_start_room(): void { $sim = $this->makeSimulator(); $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); $sim->applyControl('room-a', ['type' => 'join', 'id' => 'p2', 'name' => 'B']); $sim->applyControl('room-a', ['type' => 'start', 'id' => 'p2']); $this->assertSame(Room::STATE_LOBBY, $sim->room('room-a')->state); $sim->applyControl('room-a', ['type' => 'start', 'id' => 'p1']); $this->assertSame(Room::STATE_PLAYING, $sim->room('room-a')->state); } #[Test] public function host_promoted_when_host_leaves(): void { $sim = $this->makeSimulator(); $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); $sim->applyControl('room-a', ['type' => 'join', 'id' => 'p2', 'name' => 'B']); $sim->applyControl('room-a', ['type' => 'leave', 'id' => 'p1']); $this->assertSame('p2', $sim->room('room-a')->hostId); $sim->applyControl('room-a', ['type' => 'start', 'id' => 'p2']); $this->assertSame(Room::STATE_PLAYING, $sim->room('room-a')->state); } #[Test] public function reset_rejects_non_host(): void { $sim = $this->makeSimulator(); $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); $sim->applyControl('room-a', ['type' => 'join', 'id' => 'p2', 'name' => 'B']); $room = $sim->room('room-a'); $room->state = Room::STATE_OVER; $sim->applyControl('room-a', ['type' => 'reset', 'id' => 'p2']); $this->assertSame(Room::STATE_OVER, $room->state); $sim->applyControl('room-a', ['type' => 'reset', 'id' => 'p1']); $this->assertSame(Room::STATE_LOBBY, $room->state); } #[Test] public function reset_does_nothing_in_lobby(): void { $sim = $this->makeSimulator(); $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); $room = $sim->room('room-a'); $sim->applyControl('room-a', ['type' => 'reset', 'id' => 'p1']); $this->assertSame(Room::STATE_LOBBY, $room->state); $this->assertCount(1, $this->broadcasts); } #[Test] public function reset_clears_entities_and_restores_players(): void { $sim = $this->makeSimulator(); $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); $room = $sim->room('room-a'); $room->state = Room::STATE_OVER; $room->entities->append(new Zombie(100.0, 100.0, $room->entities, $room, ['emergeTime' => 0.0])); $player = $room->players['p1']; $player->health = 1; $player->score = 99; $player->pos = new Vector2(5.0, 5.0); $player->upgrades['rapid'] = 1; $sim->applyControl('room-a', ['type' => 'reset', 'id' => 'p1']); $this->assertSame(Room::STATE_LOBBY, $room->state); $this->assertSame([], $room->entities->findByKind('zombie')); $this->assertSame($player->maxHealth, $player->health); $this->assertSame(0, $player->score); $this->assertSame(0, $player->upgrades['rapid']); $this->assertEqualsWithDelta(960.0, $player->pos->x, 1e-6); $this->assertSame(Room::STATE_LOBBY, $this->broadcasts[array_key_last($this->broadcasts)]['state']); } #[Test] public function start_does_nothing_with_empty_world(): void { $sim = $this->makeSimulator(); $sim->applyControl('empty-room', ['type' => 'start']); $this->assertSame([], $sim->roomIds()); } #[Test] public function apply_input_moves_player(): void { $sim = $this->makeSimulator(); $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); $room = $sim->room('room-a'); $x = $room->players['p1']->pos->x; $y = $room->players['p1']->pos->y; $sim->applyControl('room-a', ['type' => 'start', 'id' => 'p1']); $sim->applyInput('room-a', ['id' => 'p1', 'keys' => ['d'], 'aim' => ['x' => 1.0, 'y' => 0.0], 'fire' => false]); $sim->tick(1.0 / 60.0); $sim->tick(1.0 / 60.0); $player = $room->players['p1']; $this->assertGreaterThan($x, $player->pos->x, 'player moves right'); $this->assertEqualsWithDelta($y, $player->pos->y, 1e-6, 'player stays on same y'); } #[Test] public function tick_spawns_zombies_from_input(): void { $sim = $this->makeSimulator(); $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); $sim->applyControl('room-a', ['type' => 'start', 'id' => 'p1']); $sim->applyInput('room-a', ['id' => 'p1', 'keys' => [], 'aim' => ['x' => 1.0, 'y' => 0.0], 'fire' => true, 'autoAim' => true]); $sawZombie = false; for ($i = 0; $i < 600; $i++) { $sim->tick(1.0 / 60.0); $room = $sim->room('room-a'); if ($room->entities->findByKind('zombie') !== []) { $sawZombie = true; } if ($room->state === Room::STATE_OVER) { break; } } $this->assertTrue($sawZombie, 'zombies spawn during play'); } #[Test] public function leave_removes_player_and_destroys_room_when_empty(): void { $sim = $this->makeSimulator(); $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); $sim->applyControl('room-a', ['type' => 'leave', 'id' => 'p1']); $this->assertSame([], $sim->roomIds()); } #[Test] public function leave_keeps_room_when_other_players_remain(): void { $sim = $this->makeSimulator(); $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); $sim->applyControl('room-a', ['type' => 'join', 'id' => 'p2', 'name' => 'B']); $sim->applyControl('room-a', ['type' => 'leave', 'id' => 'p1']); $this->assertSame(['room-a'], $sim->roomIds()); $this->assertCount(1, $sim->room('room-a')->players); } #[Test] public function decode_parses_json_frames(): void { $sim = $this->makeSimulator(); $frame = $sim->decode('{"type":"join","id":"p1"}'); $this->assertSame('join', $frame['type']); $this->assertSame('p1', $frame['id']); $this->assertNull($sim->decode('not json')); } #[Test] public function snapshot_includes_entities_but_not_players(): void { $sim = $this->makeSimulator(); $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); $room = $sim->room('room-a'); $player = $room->players['p1']; $zombie = new Zombie(100.0, 100.0, $room->entities, $room, ['emergeTime' => 0.0]); $room->entities->append($zombie); $bullet = new Bullet(5.0, 5.0, new Vector2(1.0, 0.0), $room->entities, $room, $player); $room->entities->append($bullet); $sim->applyControl('room-a', ['type' => 'start', 'id' => 'p1']); $sim->tick(1.0 / 60.0); $sim->tick(1.0 / 60.0); $snapshot = $this->broadcasts[array_key_last($this->broadcasts)]; $kinds = array_map(static fn (array $e) => $e['d'] ?? null, $snapshot['entities']); $this->assertContains('zombie', $kinds); $this->assertNotContains('player', $kinds); $this->assertNotContains('player', $snapshot['entities']); } #[Test] public function join_is_rejected_after_start(): void { $sim = $this->makeSimulator(); $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); $sim->applyControl('room-a', ['type' => 'start', 'id' => 'p1']); $sim->applyControl('room-a', ['type' => 'join', 'id' => 'p2', 'name' => 'B']); $this->assertCount(1, $sim->room('room-a')->players, 'no new join after start'); $this->assertSame('room_not_open', $this->errors[0]['code']); } #[Test] public function join_rejects_missing_room_without_creating_it(): void { $sim = $this->makeSimulator(); $sim->applyControl('nope', ['type' => 'join', 'id' => 'p1', 'name' => 'A']); $this->assertSame([], $sim->roomIds(), 'no room is created for an unknown code'); $this->assertSame('room_not_found', $this->errors[0]['code']); $this->assertSame('nope', $this->errors[0]['room']); $this->assertSame([], $this->broadcasts, 'nothing is broadcast for an unknown code'); } #[Test] public function create_rejects_room_owned_by_another_host(): void { $sim = $this->makeSimulator(); $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p2', 'name' => 'B']); $this->assertSame('p1', $sim->room('room-a')->hostId, 'creator keeps host'); $this->assertCount(1, $sim->room('room-a')->players, 'second host is not added'); $this->assertSame('room_exists', $this->errors[0]['code']); } #[Test] public function create_is_idempotent_rejoin_for_the_host(): void { $sim = $this->makeSimulator(); $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); $this->assertCount(1, $sim->room('room-a')->players); $this->assertSame('p1', $sim->room('room-a')->hostId); $this->assertSame([], $this->errors); } #[Test] public function guest_joins_existing_room_as_non_host(): void { $sim = $this->makeSimulator(); $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); $sim->applyControl('room-a', ['type' => 'join', 'id' => 'p2', 'name' => 'B']); $this->assertCount(2, $sim->room('room-a')->players); $this->assertSame([], $this->errors, 'joining an existing open room succeeds'); } #[Test] public function join_rejects_when_room_is_full(): void { $sim = $this->makeSimulator(); $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); for ($i = 2; $i <= 8; $i++) { $sim->applyControl('room-a', ['type' => 'join', 'id' => 'p'.$i, 'name' => 'P'.$i]); } $sim->applyControl('room-a', ['type' => 'join', 'id' => 'p9', 'name' => 'P9']); $this->assertCount(8, $sim->room('room-a')->players); $this->assertSame('room_full', $this->errors[0]['code']); } #[Test] public function bullet_impact_emits_hit_event(): void { $sim = $this->makeSimulator(); $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); $room = $sim->room('room-a'); $room->state = Room::STATE_PLAYING; $player = $room->players['p1']; $zombie = new Zombie($player->pos->x + 40.0, $player->pos->y, $room->entities, $room, ['emergeTime' => 0.0]); $room->entities->append($zombie); $sim->applyInput('room-a', ['id' => 'p1', 'keys' => [], 'aim' => ['x' => 1.0, 'y' => 0.0], 'fire' => true, 'autoAim' => false]); $hit = false; $kills = false; for ($i = 0; $i < 30; $i++) { $sim->tick(1.0 / 60.0); foreach ($this->broadcasts[array_key_last($this->broadcasts)]['events'] as $event) { $hit = $hit || $event['k'] === 'bullethit'; $kills = $kills || $event['k'] === 'kill'; } if ($hit && $kills) { break; } } $this->assertTrue($hit, 'a bullet impact emits a bullethit event'); $this->assertTrue($kills, 'the zombie dies and emits a kill event'); } }