Files
zombie-game/tests/Unit/Game/GameSimulatorTest.php
T
2026-09-07 20:37:40 +02:00

359 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Unit\Game;
use App\Game\Entities\Bullet;
use App\Game\Entities\Zombie;
use App\Game\GameSimulator;
use App\Game\Room;
use App\Game\Vector2;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class GameSimulatorTest extends TestCase
{
/** @var array<int, array<string, mixed>> */
private array $broadcasts = [];
/** @var array<int, array{room: string, code: string}> */
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');
}
}