feat(world): add persistent citizen simulation with needs, jobs, and social features

- Adds Character model with survival needs (hunger, thirst, energy) and state machine
- Implements city:tick world ticker for offline progression every minute
- Adds city territory expansion and building construction system
- Introduces job economy, inventory, and transaction audit trail
- Adds social graph (friends, marriage, children) and character chat
- Implements admin approval workflow for new registrations
- Adds hospital admission system for traffic accidents
- Includes comprehensive feature tests and world seeders
This commit is contained in:
2026-10-17 21:46:23 -03:00
parent 79d12fa2f4
commit 79ce2a1825
132 changed files with 11809 additions and 1036 deletions
+167
View File
@@ -0,0 +1,167 @@
<?php
namespace Tests\Feature;
use App\Models\Character;
use App\Models\City;
use App\Models\LegalDocument;
use App\Models\User;
use App\Models\UserConsent;
use Database\Seeders\CityWorldSeeder;
use Database\Seeders\LegalDocumentSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Registration approval workflow for citizen accounts.
*/
class CitizenApprovalTest extends TestCase
{
use RefreshDatabase;
/**
* Boot legal docs + world for each test.
*/
protected function setUp(): void
{
parent::setUp();
$this->seed(LegalDocumentSeeder::class);
$this->seed(CityWorldSeeder::class);
}
/**
* Create an admin that already accepted all current legal documents.
*/
private function makeAdmin(): User
{
$admin = User::factory()->admin()->create();
foreach (LegalDocument::where('is_current', true)->pluck('id') as $docId) {
UserConsent::create([
'user_id' => $admin->id,
'legal_document_id' => $docId,
'accepted_at' => now(),
'ip_address' => '127.0.0.1',
'user_agent' => 'phpunit',
'source' => 'web_form',
]);
}
return $admin;
}
/**
* New registrations must start as pending, not active.
*/
public function test_register_creates_pending_user(): void
{
$response = $this->post('/register', [
'name' => 'Nova Pessoa',
'email' => 'nova@example.com',
'password' => 'Secret123!',
'password_confirmation' => 'Secret123!',
'locale' => 'pt_BR',
'captcha' => $this->captchaForRegister(),
]);
$user = User::where('email', 'nova@example.com')->first();
$this->assertNotNull($user);
$this->assertSame(User::STATUS_PENDING, $user->status);
$this->assertFalse($user->is_active);
$response->assertRedirect(route('terms.accept'));
}
/**
* Pending users are sent to the approval wait screen from the dashboard.
*/
public function test_pending_user_sees_wait_screen(): void
{
$user = User::factory()->pending()->create();
$this->actingAs($user)
->get('/dashboard')
->assertRedirect(route('account.pending'));
$this->actingAs($user)
->get(route('account.pending'))
->assertOk()
->assertSee(__('auth.pending_title'), false);
}
/**
* Login attempt by a pending account shows the approval message.
*/
public function test_login_rejects_pending_with_message(): void
{
$user = User::factory()->pending()->create(['password' => 'Secret123!']);
$response = $this->post('/login', [
'email' => $user->email,
'password' => 'Secret123!',
'captcha' => $this->captchaForLogin(),
]);
$response->assertSessionHasErrors('email');
$this->assertSame(__('auth.pending'), session('errors')->first('email'));
}
/**
* Admin approval activates the account and creates the character.
*/
public function test_admin_approval_creates_character(): void
{
$admin = $this->makeAdmin();
$pending = User::factory()->pending()->create(['name' => 'Ana Silva']);
$this->actingAs($admin)
->post(route('admin.citizens.approve', $pending))
->assertRedirect(route('admin.citizens.index'));
$pending->refresh();
$this->assertSame(User::STATUS_ACTIVE, $pending->status);
$this->assertTrue($pending->is_active);
$character = Character::query()->where('user_id', $pending->id)->first();
$this->assertNotNull($character);
$this->assertSame('Ana', $character->name);
$this->assertNotNull(City::active());
$this->assertSame(City::active()->id, $character->city_id);
}
/**
* Banned accounts cannot authenticate.
*/
public function test_banned_user_cannot_login(): void
{
$user = User::factory()->inactive()->create(['password' => 'Secret123!']);
$response = $this->post('/login', [
'email' => $user->email,
'password' => 'Secret123!',
'captcha' => $this->captchaForLogin(),
]);
$response->assertSessionHasErrors('email');
}
/**
* Solve the session math CAPTCHA for a register request.
*/
private function captchaForRegister(): int
{
// Prime the session by loading the form first in real usage; in tests
// we set the session keys directly to avoid an extra round-trip.
$a = random_int(1, 9);
$b = random_int(1, 9);
session(['captcha_result' => $a + $b, 'captcha_expires_at' => now()->addMinutes(10)->getTimestamp()]);
return $a + $b;
}
/**
* Solve the session math CAPTCHA for a login request.
*/
private function captchaForLogin(): int
{
return $this->captchaForRegister();
}
}
+165
View File
@@ -0,0 +1,165 @@
<?php
namespace Tests\Feature;
use App\Models\Building;
use App\Models\Character;
use App\Models\InventoryItem;
use App\Models\Job;
use App\Models\Transaction;
use App\Models\User;
use Database\Seeders\CityWorldSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Player lifecycle actions and city data payload.
*/
class CityActionsTest extends TestCase
{
use RefreshDatabase;
/**
* Seed world and create an approved user with a character.
*/
protected Character $character;
protected User $user;
/**
* Boot world fixtures.
*/
protected function setUp(): void
{
parent::setUp();
$this->seed(CityWorldSeeder::class);
$this->user = User::factory()->create(['status' => User::STATUS_ACTIVE, 'is_active' => true]);
$this->character = Character::ensureForUser($this->user);
$this->actingAs($this->user);
}
/**
* City payload includes bounds, characters, jobs and own character.
*/
public function test_city_data_payload(): void
{
$response = $this->getJson('/city/data');
$response->assertOk()
->assertJsonPath('city.slug', 'city10')
->assertJsonStructure([
'city' => ['bounds' => ['min_x', 'max_x', 'min_z', 'max_z']],
'characters', 'buildings', 'jobs', 'me', 'layout' => ['roads', 'blocks'],
]);
$this->assertTrue($response->json('me.id') === $this->character->id);
$this->assertNotEmpty($response->json('layout.roads'));
}
/**
* Position updates clamp inside city bounds and persist.
*/
public function test_position_persists_and_clamps(): void
{
$this->postJson('/city/actions/position', ['pos_x' => 9999, 'pos_z' => -9999])
->assertOk();
$this->character->refresh();
$city = $this->character->city;
$this->assertSame((float) $city->bounds_max_x, $this->character->pos_x);
$this->assertSame((float) $city->bounds_min_z, $this->character->pos_z);
}
/**
* Eating requires food inventory and restores hunger.
*/
public function test_eat_consumes_food(): void
{
$this->character->forceFill(['hunger' => 40])->save();
InventoryItem::updateOrCreate(
['character_id' => $this->character->id, 'item' => 'food'],
['qty' => 1],
);
$this->postJson('/city/actions/eat')->assertOk();
$this->character->refresh();
$this->assertGreaterThan(40, $this->character->hunger);
$this->assertSame(0, (int) InventoryItem::where('character_id', $this->character->id)->where('item', 'food')->value('qty'));
}
/**
* Sleep is rejected without a completed house.
*/
public function test_sleep_requires_house(): void
{
$this->postJson('/city/actions/sleep')->assertStatus(422);
}
/**
* Applying a job attaches employment.
*/
public function test_apply_job(): void
{
$job = Job::firstOrFail();
$this->postJson('/city/actions/apply-job', ['job_id' => $job->id])->assertOk();
$this->character->refresh();
$this->assertSame($job->id, $this->character->job_id);
}
/**
* Buying moves money into inventory and records a transaction.
*/
public function test_buy_food(): void
{
$this->character->forceFill(['money' => 50])->save();
$this->postJson('/city/actions/buy', [
'item' => 'food',
'qty' => 2,
'price' => 5,
])->assertOk();
$this->character->refresh();
$this->assertSame(40.0, (float) $this->character->money);
// Starter kit provides 3 food; buying 2 more yields 5.
$this->assertSame(5, (int) InventoryItem::where('character_id', $this->character->id)->where('item', 'food')->value('qty'));
$this->assertSame(1, Transaction::count());
}
/**
* Building requires materials and free terrain.
*/
public function test_build_house(): void
{
InventoryItem::updateOrCreate(
['character_id' => $this->character->id, 'item' => 'materials'],
['qty' => 50],
);
$this->postJson('/city/actions/build', [
'pos_x' => 60,
'pos_z' => 60,
'name' => 'Minha casa',
])->assertOk();
$building = Building::where('owner_character_id', $this->character->id)->first();
$this->assertNotNull($building);
$this->assertSame(0, $building->build_progress);
$this->assertSame(0, (int) InventoryItem::where('character_id', $this->character->id)->where('item', 'materials')->value('qty'));
}
/**
* Second house on the same lot is rejected.
*/
public function test_build_rejects_occupied_site(): void
{
InventoryItem::updateOrCreate(
['character_id' => $this->character->id, 'item' => 'materials'],
['qty' => 100],
);
$payload = ['pos_x' => -60, 'pos_z' => 20, 'name' => 'A'];
$this->postJson('/city/actions/build', $payload)->assertOk();
$this->postJson('/city/actions/build', $payload)->assertStatus(422);
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
namespace Tests\Feature;
use App\Models\City;
use App\Models\LegalDocument;
use App\Models\User;
use App\Models\UserConsent;
use Database\Seeders\CityWorldSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Admin territory expansion and multi-city creation.
*/
class CityExpansionTest extends TestCase
{
use RefreshDatabase;
/**
* Seed world fixtures.
*/
protected function setUp(): void
{
parent::setUp();
$this->seed(CityWorldSeeder::class);
$this->admin = User::factory()->admin()->create();
foreach (LegalDocument::where('is_current', true)->pluck('id') as $docId) {
UserConsent::create([
'user_id' => $this->admin->id,
'legal_document_id' => $docId,
'accepted_at' => now(),
'ip_address' => '127.0.0.1',
'user_agent' => 'phpunit',
'source' => 'web_form',
]);
}
$this->actingAs($this->admin);
}
protected User $admin;
/**
* Admin can create a new city with its own bounds.
*/
public function test_create_city(): void
{
$before = City::count();
$this->post(route('admin.cities.store'), ['name' => 'Nova Cidade'])
->assertRedirect(route('admin.cities.index'));
$this->assertSame($before + 1, City::count());
$city = City::where('name', 'Nova Cidade')->first();
$this->assertNotNull($city);
$this->assertFalse($city->is_active);
$this->assertSame(-80.0, $city->bounds_min_x);
$this->assertSame(80.0, $city->bounds_max_x);
}
/**
* Expanding a city grows its maximum bounds by one grid step.
*/
public function test_expand_city_bounds(): void
{
$city = City::where('slug', 'city10')->firstOrFail();
$beforeMax = $city->bounds_max_x;
$this->post(route('admin.cities.expand', $city))
->assertRedirect(route('admin.cities.index'));
$city->refresh();
$this->assertSame($beforeMax + 40, $city->bounds_max_x);
$this->assertSame(1, $city->expansions);
}
/**
* Expansion is capped to protect map generation.
*/
public function test_expand_capped(): void
{
$city = City::where('slug', 'city10')->firstOrFail();
$city->forceFill(['expansions' => City::MAX_EXPANSIONS])->save();
$this->from(route('admin.cities.index'))
->post(route('admin.cities.expand', $city))
->assertSessionHasErrors('city');
}
/**
* City layout for a second city uses its own bounds.
*/
public function test_city_data_for_second_city(): void
{
$other = City::create([
'name' => 'Cidade B',
'slug' => 'cidade-b',
'bounds_min_x' => 0,
'bounds_max_x' => 120,
'bounds_min_z' => 0,
'bounds_max_z' => 80,
'is_active' => false,
'expansions' => 0,
]);
$response = $this->getJson('/city/data?city=cidade-b');
$response->assertOk()->assertJsonPath('city.slug', 'cidade-b');
$this->assertNotEmpty($response->json('layout.roads'));
}
}
+131
View File
@@ -0,0 +1,131 @@
<?php
namespace Tests\Feature;
use App\Models\Character;
use App\Models\CharacterMessage;
use App\Models\Relationship;
use App\Models\User;
use Database\Seeders\CityWorldSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Social graph and character chat coverage.
*/
class CitySocialTest extends TestCase
{
use RefreshDatabase;
/**
* Seed world fixtures.
*/
protected function setUp(): void
{
parent::setUp();
$this->seed(CityWorldSeeder::class);
}
/**
* Friendship request flows from pending to accepted.
*/
public function test_friend_request_and_accept(): void
{
$a = Character::factory()->create();
$b = Character::factory()->create();
$userA = User::factory()->create(['status' => User::STATUS_ACTIVE, 'is_active' => true]);
$a->user_id = $userA->id;
$a->save();
$this->actingAs($userA)
->postJson('/city/social/friend', ['character_id' => $b->id])
->assertOk();
$rel = Relationship::where('type', Relationship::TYPE_FRIEND)
->where('character_a_id', $a->id)
->where('character_b_id', $b->id)
->first();
$this->assertNotNull($rel);
$this->assertSame(Relationship::STATUS_PENDING, $rel->status);
$userB = User::factory()->create(['status' => User::STATUS_ACTIVE, 'is_active' => true]);
$b->user_id = $userB->id;
$b->save();
$this->actingAs($userB)
->postJson('/city/social/accept-friend', ['relationship_id' => $rel->id])
->assertOk();
$this->assertSame(Relationship::STATUS_ACCEPTED, $rel->fresh()->status);
}
/**
* Marriage requires an accepted friendship.
*/
public function test_marry_requires_friendship(): void
{
$userA = User::factory()->create(['status' => User::STATUS_ACTIVE, 'is_active' => true]);
$a = Character::ensureForUser($userA);
$b = Character::factory()->create(['city_id' => $a->city_id]);
$this->actingAs($userA)
->postJson('/city/social/marry', ['character_id' => $b->id])
->assertStatus(422);
}
/**
* Spouses can have an NPC child linked to both parents.
*/
public function test_child_creates_npc_character(): void
{
$userA = User::factory()->create(['status' => User::STATUS_ACTIVE, 'is_active' => true]);
$a = Character::ensureForUser($userA);
$b = Character::factory()->create(['city_id' => $a->city_id, 'name' => 'Parceiro']);
Relationship::create([
'character_a_id' => $a->id,
'character_b_id' => $b->id,
'type' => Relationship::TYPE_FRIEND,
'status' => Relationship::STATUS_ACCEPTED,
]);
Relationship::create([
'character_a_id' => $a->id,
'character_b_id' => $b->id,
'type' => Relationship::TYPE_SPOUSE,
'status' => Relationship::STATUS_ACCEPTED,
]);
$before = Character::count();
$this->actingAs($userA)
->postJson('/city/social/child', ['character_id' => $b->id])
->assertOk();
$this->assertSame($before + 1, Character::count());
$child = Character::orderByDesc('id')->first();
$this->assertNull($child->user_id);
$this->assertSame(2, Relationship::where('type', Relationship::TYPE_PARENT_CHILD)
->where('character_a_id', $child->id)->count());
}
/**
* Messages persist between characters.
*/
public function test_character_chat_persists(): void
{
$userA = User::factory()->create(['status' => User::STATUS_ACTIVE, 'is_active' => true]);
$a = Character::ensureForUser($userA);
$b = Character::factory()->create(['city_id' => $a->city_id]);
$this->actingAs($userA)
->postJson("/city/messages/{$b->id}", ['body' => 'Olá vizinho!'])
->assertOk();
$this->assertSame(1, CharacterMessage::count());
$this->actingAs($userA)
->getJson("/city/messages/{$b->id}")
->assertOk()
->assertJsonPath('me', $a->id)
->assertJsonPath('messages.0.body', 'Olá vizinho!');
}
}
+134
View File
@@ -0,0 +1,134 @@
<?php
namespace Tests\Feature;
use App\Models\Building;
use App\Models\Character;
use App\Models\Job;
use Database\Seeders\CityWorldSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* World ticker behaviour: needs, salary, construction progress.
*/
class CityTickTest extends TestCase
{
use RefreshDatabase;
/**
* Seed world data.
*/
protected function setUp(): void
{
parent::setUp();
$this->seed(CityWorldSeeder::class);
}
/**
* Needs decay on the first tick for awake characters.
*/
public function test_tick_decays_needs_once(): void
{
$character = Character::factory()->create([
'state' => Character::STATE_AWAKE,
'energy' => 100,
'hunger' => 100,
'thirst' => 100,
'last_tick_at' => null,
]);
$this->artisan('city:tick')->assertSuccessful();
$character->refresh();
$this->assertLessThan(100, $character->hunger);
$this->assertLessThan(100, $character->thirst);
$this->assertNotNull($character->last_tick_at);
$hungerAfterFirst = $character->hunger;
$this->artisan('city:tick')->assertSuccessful();
$character->refresh();
$this->assertSame($hungerAfterFirst, $character->hunger, 'Second immediate tick must be a no-op');
}
/**
* Sleeping restores energy and wakes the character at full energy.
*/
public function test_tick_recovers_energy_while_sleeping(): void
{
$character = Character::factory()->create([
'state' => Character::STATE_SLEEPING,
'energy' => 50,
'last_tick_at' => now()->subMinute(),
]);
$this->artisan('city:tick')->assertSuccessful();
$character->refresh();
$this->assertGreaterThan(50, $character->energy);
// Force another tick window.
$character->forceFill(['last_tick_at' => now()->subMinute(), 'energy' => 95])->save();
$this->artisan('city:tick')->assertSuccessful();
$character->refresh();
$this->assertSame(100, $character->energy);
$this->assertSame(Character::STATE_AWAKE, $character->state);
}
/**
* Working characters accrue salary each tick.
*/
public function test_tick_pays_salary_while_working(): void
{
$character = Character::factory()->create([
'state' => Character::STATE_WORKING,
'money' => 100,
'last_tick_at' => now()->subMinute(),
]);
$character->job()->associate(Job::firstOrFail());
$character->save();
$before = (float) $character->money;
$this->artisan('city:tick')->assertSuccessful();
$character->refresh();
$this->assertGreaterThan($before, (float) $character->money);
}
/**
* Incomplete buildings advance and complete houses attach to the owner.
*/
public function test_tick_progresses_construction(): void
{
$character = Character::factory()->create();
$building = Building::create([
'city_id' => $character->city_id,
'owner_character_id' => $character->id,
'type' => Building::TYPE_HOUSE,
'name' => 'Casa',
'pos_x' => 20,
'pos_z' => -70,
'width' => 10,
'depth' => 10,
'height' => 5,
'is_complete' => false,
'build_progress' => 90,
]);
$this->artisan('city:tick')->assertSuccessful();
$building->refresh();
$character->refresh();
$this->assertTrue($building->is_complete);
$this->assertSame(100, $building->build_progress);
$this->assertSame($building->id, $character->house_id);
}
/**
* Scheduler registers the ticker every minute.
*/
public function test_scheduler_includes_city_tick(): void
{
$this->artisan('schedule:list')
->expectsOutputToContain('city:tick')
->assertSuccessful();
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace Tests\Feature;
use App\Models\Character;
use App\Models\City;
use App\Models\User;
use Database\Seeders\CityWorldSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* World schema and seeding coverage.
*/
class CityWorldSeedingTest extends TestCase
{
use RefreshDatabase;
/**
* Seeder creates City10, jobs and a character for active users.
*/
public function test_seeder_bootstraps_city_jobs_and_characters(): void
{
$this->seed(CityWorldSeeder::class);
$city = City::query()->where('slug', 'city10')->first();
$this->assertNotNull($city);
$this->assertTrue($city->is_active);
$this->assertSame(4, $city->jobs()->count());
$user = User::factory()->create(['status' => User::STATUS_ACTIVE, 'is_active' => true]);
$this->seed(CityWorldSeeder::class);
$this->assertNotNull(Character::query()->where('user_id', $user->id)->first());
}
/**
* Migration backfills legacy is_active flags onto status.
*/
public function test_status_backfill_matches_is_active(): void
{
$this->seed(CityWorldSeeder::class);
$active = User::where('is_active', true)->where('status', '!=', User::STATUS_ACTIVE)->count();
$this->assertSame(0, $active);
}
}
+150
View File
@@ -0,0 +1,150 @@
<?php
namespace Tests\Feature;
use App\Models\Building;
use App\Models\Character;
use App\Models\City;
use App\Models\User;
use Database\Seeders\CityWorldSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Traffic accident → hospital admission for two game days.
*/
class HospitalAdmissionTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Character $character;
/**
* Seed world with the hospital building.
*/
protected function setUp(): void
{
parent::setUp();
$this->seed(CityWorldSeeder::class);
$this->user = User::factory()->create(['status' => User::STATUS_ACTIVE, 'is_active' => true]);
$this->character = Character::ensureForUser($this->user);
$this->actingAs($this->user);
}
/**
* A vehicle collision admits the character for two game days at the hospital.
*/
public function test_accident_admits_character_for_two_game_days(): void
{
$response = $this->postJson('/city/actions/accident');
$response->assertOk()
->assertJsonPath('character.state', Character::STATE_HOSPITAL)
->assertJsonStructure(['message', 'hospitalized_until', 'bed' => ['pos_x', 'pos_z']]);
$this->character->refresh();
$this->assertSame(Character::STATE_HOSPITAL, $this->character->state);
$this->assertNotNull($this->character->hospitalized_until);
$this->assertEqualsWithDelta(
now()->addSeconds(Character::HOSPITAL_STAY_SECONDS)->getTimestamp(),
$this->character->hospitalized_until->getTimestamp(),
5,
);
$this->assertSame(Character::HOSPITAL_STAY_SECONDS, 480, 'Two game days = 2 × 240s');
$hospital = Building::where('type', Building::TYPE_HOSPITAL)->firstOrFail();
$this->assertSame((float) $hospital->pos_x, (float) $this->character->pos_x);
}
/**
* While admitted, ordinary actions are rejected.
*/
public function test_actions_blocked_while_hospitalized(): void
{
$this->character->admitToHospital();
$this->postJson('/city/actions/eat')->assertStatus(422);
$this->postJson('/city/actions/work')->assertStatus(422);
$this->postJson('/city/actions/build', [
'pos_x' => 10, 'pos_z' => 50,
])->assertStatus(422);
}
/**
* Position updates keep the character in the hospital bed while admitted.
*/
public function test_position_frozen_while_hospitalized(): void
{
$bed = $this->character->admitToHospital();
$this->postJson('/city/actions/position', ['pos_x' => 10, 'pos_z' => 60])
->assertOk()
->assertJsonPath('state', Character::STATE_HOSPITAL);
$this->character->refresh();
$this->assertSame($bed[0], (float) $this->character->pos_x);
$this->assertSame($bed[1], (float) $this->character->pos_z);
}
/**
* After the two-day stay the ticker releases the character.
*/
public function test_ticker_releases_after_two_game_days(): void
{
$this->character->admitToHospital();
// Move the stay into the past to simulate elapsed time.
$this->character->forceFill([
'hospitalized_until' => now()->subSecond(),
'last_tick_at' => null,
])->save();
$this->artisan('city:tick')->assertSuccessful();
$this->character->refresh();
$this->assertSame(Character::STATE_AWAKE, $this->character->state);
$this->assertNull($this->character->hospitalized_until);
}
/**
* While still within the stay the ticker keeps the hospital state.
*/
public function test_ticker_keeps_admission_before_discharge(): void
{
$this->character->admitToHospital();
$this->character->forceFill(['last_tick_at' => null])->save();
$this->artisan('city:tick')->assertSuccessful();
$this->character->refresh();
$this->assertSame(Character::STATE_HOSPITAL, $this->character->state);
$this->assertTrue($this->character->hospitalized_until->isFuture());
}
/**
* City data payload exposes the discharge timestamp.
*/
public function test_city_data_includes_hospital_state(): void
{
$this->character->admitToHospital();
$this->getJson('/city/data')
->assertOk()
->assertJsonPath('me.state', Character::STATE_HOSPITAL)
->assertJsonPath('me.hospitalized_until', $this->character->hospitalized_until->toIso8601String());
}
/**
* Hospital building exists in the seeded city.
*/
public function test_hospital_building_seeded(): void
{
$city = City::where('slug', 'city10')->firstOrFail();
$hospital = Building::where('city_id', $city->id)
->where('type', Building::TYPE_HOSPITAL)
->first();
$this->assertNotNull($hospital);
$this->assertTrue($hospital->is_complete);
}
}