feat(city): add airports, ATC agent, luminous signs, and AI model fallback chain
- 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.
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
<?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'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user