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:
2026-10-31 22:06:18 -03:00
parent a12807492f
commit e7498e1566
26 changed files with 2226 additions and 108 deletions
+234
View File
@@ -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'));
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
namespace Tests\Feature;
use App\Models\Character;
use App\Models\User;
use Database\Seeders\CityWorldSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Presence feed correctness: the per-second rows are shared, but the
* is_me flag must always reflect the viewer of the current request —
* otherwise a citizen sees their own character as a remote player and
* the bump-to-talk chat opens with themselves.
*/
class CityPresenceTest extends TestCase
{
use RefreshDatabase;
/**
* Boot world fixtures.
*/
protected function setUp(): void
{
parent::setUp();
$this->seed(CityWorldSeeder::class);
}
/**
* Two viewers polling within the same second each get their own
* is_me flags from the shared cache.
*/
public function test_is_me_is_per_viewer_with_shared_cache(): void
{
[$userA, $charA] = $this->makeCitizen();
[$userB, $charB] = $this->makeCitizen();
// Viewer A seeds the shared per-second cache first.
$first = $this->actingAs($userA)
->getJson('/city/presence')
->assertOk()
->json('citizens');
$this->assertTrue($this->flagFor($first, $charA->id), 'A must see itself as me');
$this->assertFalse($this->flagFor($first, $charB->id), 'A must not see B as me');
// Viewer B hits the SAME cache entry moments later.
$second = $this->actingAs($userB)
->getJson('/city/presence')
->assertOk()
->json('citizens');
$this->assertTrue($this->flagFor($second, $charB->id), 'B must see itself as me');
$this->assertFalse($this->flagFor($second, $charA->id), 'B must not see A as me');
}
/**
* Create an active user paired with its in-world character.
*
* @return array{0: User, 1: Character}
*/
private function makeCitizen(): array
{
$user = User::factory()->create([
'status' => User::STATUS_ACTIVE,
'is_active' => true,
]);
return [$user, Character::ensureForUser($user)];
}
/**
* Extract the is_me flag for a character id from a presence payload.
*
* @param list<array<string, mixed>> $citizens
*/
private function flagFor(array $citizens, int $characterId): bool
{
foreach ($citizens as $c) {
if ((int) $c['id'] === $characterId) {
return (bool) $c['is_me'];
}
}
$this->fail("Character {$characterId} missing from presence payload");
}
}
+46
View File
@@ -639,4 +639,50 @@ class PersonTalkTest extends TestCase
$this->assertLessThanOrEqual(68, $character->ai_age);
$this->assertContains($character->ai_personality, Character::AI_PERSONALITIES);
}
/**
* When the resolved primary model fails, the call retries with
* AI_FALLBACK_MODEL (owner-approved xiaomi/mimo-v2.6-flash).
*/
public function test_person_talk_retries_with_fallback_model(): void
{
config([
'ai.openrouter.api_key' => 'test-key',
'ai.models' => ['openrouter/free'],
'ai.character_models' => [],
]);
Http::fake([
'openrouter.ai/*' => Http::sequence()
->push(['message' => 'Model overloaded'], 503)
->push(['choices' => [['message' => ['content' => 'Tudo bem por aqui!']]]], 200),
]);
[$userA, $me] = $this->makeCitizen();
[, $them] = $this->makeCitizen(); // silent peer → AI reply path
$this->actingAs($userA)
->postJson('/talk/person', [
'kind' => 'character',
'peer' => $them->id,
'message' => 'Alô?',
])
->assertCreated();
$reply = PersonTalk::query()
->where('from_character_id', $them->id)
->where('is_fallback', false)
->orderByDesc('id')
->first();
$this->assertNotNull($reply);
$this->assertSame('Tudo bem por aqui!', $reply->body);
$models = [];
Http::assertSent(function ($request) use (&$models): bool {
$models[] = $request['model'];
return true;
});
$this->assertSame(['openrouter/free', 'xiaomi/mimo-v2.6-flash'], $models);
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Database\Seeders\CityWorldSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Luminous-sign labels must exist in every locale (RULES.md 1.4:
* every user-visible string ships in pt_BR, en and es).
*/
class SignLabelsTest extends TestCase
{
use RefreshDatabase;
/**
* Locales covered by the platform.
*
* @var list<string>
*/
private const LOCALES = ['pt_BR', 'en', 'es'];
/**
* Sign label keys rendered by the 3D letreiros.
*
* @var list<string>
*/
private const KEYS = [
'city.sign_plaza',
'city.sign_beach',
'city.sign_food',
'city.sign_hospital',
'city.sign_school',
'city.sign_cityhall',
];
/**
* Every sign label exists (non-empty) in all three languages.
*/
public function test_sign_labels_exist_in_all_locales(): void
{
foreach (self::LOCALES as $locale) {
$path = base_path("lang/{$locale}.json");
$this->assertFileExists($path);
$labels = json_decode((string) file_get_contents($path), true);
$this->assertIsArray($labels);
foreach (self::KEYS as $key) {
$this->assertArrayHasKey($key, $labels, "Missing {$key} in {$locale}");
$this->assertNotSame('', trim((string) $labels[$key]), "Empty {$key} in {$locale}");
}
}
}
/**
* The labels reach the viewer page for the active locale.
*/
public function test_viewer_page_carries_sign_labels(): void
{
$this->seed(CityWorldSeeder::class);
$user = User::factory()->create([
'status' => User::STATUS_ACTIVE,
'is_active' => true,
]);
$this->actingAs($user);
$html = $this->get('/city')->assertOk()->getContent();
foreach (['signPlaza', 'signBeach', 'signFood', 'signHospital', 'signSchool', 'signCityhall'] as $jsKey) {
$this->assertStringContainsString($jsKey.':', $html, "Missing i18n entry {$jsKey}");
}
}
}