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); } /** * The world payload carries both airports with their core geometry. */ public function test_city_data_includes_two_airports(): void { $payload = $this->getJson('/city/data')->assertOk()->json(); $this->assertArrayHasKey('airports', $payload); $sites = $payload['airports']['sites']; $this->assertCount(2, $sites); // Sites follow the compass: +Z is south (noon sun points +Z). $this->assertSame(['sul', 'norte'], array_column($sites, 'id')); $this->assertSame('Aeroporto City10 Sul', $sites[0]['name']); $this->assertSame('Aeroporto Regional do Norte', $sites[1]['name']); $this->assertSame('x', $sites[0]['runway']['axis']); $this->assertSame('z', $sites[1]['runway']['axis']); foreach ($sites as $site) { $this->assertNotEmpty($site['runway']); $this->assertNotEmpty($site['taxiways']); $this->assertNotEmpty($site['apron']); $this->assertNotEmpty($site['terminal']); $this->assertNotEmpty($site['hangars']); $this->assertNotEmpty($site['stands']); $this->assertNotEmpty($site['helipad']); $this->assertNotEmpty($site['tower']); } $this->assertEquals(Airports::VISIT_HALF_WIDTH, $payload['airports']['visit']['half_width']); $this->assertEquals(Airports::VISIT_MAX_Z, $payload['airports']['visit']['max_z']); } /** * The control tower agent exists with the configured ATC model and * stands at the north tower. */ public function test_control_tower_agent_uses_atc_model(): void { $atc = AiAgent::query()->where('type', 'atc')->firstOrFail(); $this->assertSame('Torre de Controle City10', $atc->name); $this->assertSame(config('ai.atc_model'), $atc->model); $this->assertSame('poolside/laguna-xs-2.1:free', $atc->model); $this->assertContains($atc->model, config('ai.allowed_models')); // Tower of the north site: layoutMaxZ(160) + 118 = 278, x = 10. $this->assertEquals(10.0, (float) $atc->pos_x); $this->assertEquals(278.0, (float) $atc->pos_z); } /** * The airport endpoint exposes sites, tower identity and clearance. */ public function test_airport_endpoint_exposes_atc_info(): void { $this->getJson('/city/airport') ->assertOk() ->assertJsonPath('atc.name', 'Torre de Controle City10') ->assertJsonPath('atc.model', 'poolside/laguna-xs-2.1:free') ->assertJsonPath('clearance', null); $payload = $this->getJson('/city/airport')->json(); $this->assertCount(2, $payload['sites']); } /** * Dispatch asks the tower model for a clearance and stores it. */ public function test_dispatch_generates_model_clearance(): void { config(['ai.openrouter.api_key' => 'test-key']); Http::fake([ 'openrouter.ai/*' => Http::response([ 'choices' => [['message' => ['content' => 'C10-123, AzulCéu, autorizado decolar pista 09.']]], ]), ]); $this->postJson('/city/airport/dispatch') ->assertStatus(202) ->assertJsonPath('accepted', true); // The after-response job ran with the request lifecycle. $clearance = cache()->get('airport.clearance'); $this->assertNotNull($clearance); $this->assertSame('model', $clearance['source']); $this->assertSame('C10-123, AzulCéu, autorizado decolar pista 09.', $clearance['text']); Http::assertSent(function ($request): bool { return $request['model'] === config('ai.atc_model') && str_contains((string) $request['messages'][0]['content'], 'torre de controle'); }); $atc = AiAgent::query()->where('type', 'atc')->firstOrFail(); $this->assertSame(0, (int) $atc->error_count); $this->assertSame('active', $atc->status); $decision = collect($atc->last_decision)->last(); $this->assertSame('atc', $decision['role'] ?? null); } /** * Without an API key the deterministic fallback clearance is stored * and the failure counts toward backoff. */ public function test_dispatch_without_key_stores_fallback(): void { config(['ai.openrouter.api_key' => null]); Http::preventStrayRequests(); $this->postJson('/city/airport/dispatch') ->assertStatus(202) ->assertJsonPath('accepted', true); $clearance = cache()->get('airport.clearance'); $this->assertNotNull($clearance); $this->assertSame('fallback', $clearance['source']); $this->assertStringContainsString('autorizado para decolagem', $clearance['text']); $atc = AiAgent::query()->where('type', 'atc')->firstOrFail(); $this->assertSame(1, (int) $atc->error_count); } /** * Internal cooldown: only one dispatch per 30 seconds. */ public function test_dispatch_has_internal_cooldown(): void { config(['ai.openrouter.api_key' => null]); Http::preventStrayRequests(); $this->postJson('/city/airport/dispatch')->assertJsonPath('accepted', true); $this->postJson('/city/airport/dispatch') ->assertStatus(202) ->assertJsonPath('accepted', false) ->assertJsonPath('reason', 'cooldown'); $this->assertNotNull(cache()->get('airport.dispatch.lock')); } /** * Position sync reaches both airport grounds inside their X band. */ public function test_position_allows_airport_grounds(): void { $this->postJson('/city/actions/position', ['pos_x' => 0, 'pos_z' => 300]) ->assertOk(); $this->character->refresh(); $this->assertEquals(300.0, (float) $this->character->pos_z); $this->postJson('/city/actions/position', ['pos_x' => 0, 'pos_z' => -320]) ->assertOk(); $this->character->refresh(); $this->assertEquals(-320.0, (float) $this->character->pos_z); // Outside the airport band the old bounds still apply. $this->postJson('/city/actions/position', ['pos_x' => 150, 'pos_z' => 300]) ->assertOk(); $this->character->refresh(); $this->assertEquals((float) $this->character->city->bounds_max_z, (float) $this->character->pos_z); } /** * The clearance chain walks ATC model → default → fallback model * (AI_FALLBACK_MODEL) until one answers. */ public function test_dispatch_falls_back_through_model_chain(): void { config(['ai.openrouter.api_key' => 'test-key']); Http::fake([ 'openrouter.ai/*' => Http::sequence() ->push(['message' => 'No such model'], 404) ->push(['message' => 'Upstream error'], 500) ->push(['choices' => [['message' => ['content' => 'C10-310 autorizado, pista 09.']]]], 200), ]); $this->postJson('/city/airport/dispatch') ->assertStatus(202) ->assertJsonPath('accepted', true); $clearance = cache()->get('airport.clearance'); $this->assertNotNull($clearance); $this->assertSame('model', $clearance['source']); $this->assertSame('C10-310 autorizado, pista 09.', $clearance['text']); $models = []; Http::assertSentCount(3); Http::assertSent(function ($request) use (&$models): bool { $models[] = $request['model']; return true; }); $this->assertSame(config('ai.atc_model'), $models[0]); $this->assertSame(config('ai.default_model'), $models[1]); $this->assertSame('xiaomi/mimo-v2.6-flash', $models[2]); $this->assertSame('xiaomi/mimo-v2.6-flash', config('ai.fallback_model')); } }