- Add two airports (north/south) with full geometry: runways, taxiways,
aprons, terminals, hangars, stands, and helipads; walkable airport
grounds extend beyond city bounds.
- Introduce ATC AI agent ("Torre de Controle City10") using AI_ATC_MODEL
to generate takeoff clearances; includes dispatch endpoint with
30-second cooldown and deterministic fallback on failure.
- Implement AI model fallback chain: preferred model → AI_FALLBACK_MODEL
before failing, applied in ChatController and PersonTalkController.
- Add luminous sign labels (letreiros) for plazas, beach, food, hospital,
school, city hall, and airports with day/night glow.
- Fix city presence cache: compute is_me per viewer instead of baking
into shared per-second cache to prevent cross-user flag leakage.
- Add tests: AirportTest, CityPresenceTest, SignLabelsTest, and
PersonTalkTest fallback model retry scenario.
235 lines
8.6 KiB
PHP
235 lines
8.6 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\AiAgent;
|
|
use App\Models\Character;
|
|
use App\Models\User;
|
|
use App\Support\Airports;
|
|
use Database\Seeders\CityWorldSeeder;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* Airports payload, control-tower agent (AI_ATC_MODEL) and the AI
|
|
* clearance dispatch used by the flight operations.
|
|
*/
|
|
class AirportTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
protected User $user;
|
|
|
|
protected Character $character;
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
/**
|
|
* 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'));
|
|
}
|
|
}
|