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:
Executable
+167
@@ -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();
|
||||
}
|
||||
}
|
||||
Executable
+165
@@ -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);
|
||||
}
|
||||
}
|
||||
Executable
+109
@@ -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'));
|
||||
}
|
||||
}
|
||||
Executable
+131
@@ -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!');
|
||||
}
|
||||
}
|
||||
Executable
+134
@@ -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();
|
||||
}
|
||||
}
|
||||
Executable
+47
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user