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); } }