initial commit
This commit is contained in:
commit
e8c3f9862a
193 files changed
+29795
No files matched your search
@@ -0,0 +1,358 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user