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