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
+44 -15
View File
@@ -127,22 +127,51 @@ class ChatController extends Controller
."\n\nContexto simulado da cidade: ".json_encode($cityContext, JSON_UNESCAPED_UNICODE)
."\nResponda de forma curta e natural, em português. Não invente dados pessoais de usuários.";
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$apiKey,
'HTTP-Referer' => config('ai.openrouter.app_url'),
'X-Title' => config('ai.openrouter.app_name'),
])->timeout((int) config('ai.openrouter.timeout', 30))
->post(config('ai.openrouter.base_url').'/chat/completions', [
'model' => $agent->model,
'messages' => [
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $userMessage],
],
'max_tokens' => 300,
]);
// Preferred agent model first, then the configured fallback
// chain (AI_FALLBACK_MODEL) before failing the turn.
$models = [$agent->model];
$fallback = config('ai.fallback_model');
if (is_string($fallback) && $fallback !== ''
&& $fallback !== $agent->model
&& in_array($fallback, config('ai.allowed_models', []), true)) {
$models[] = $fallback;
}
if ($response->failed()) {
self::fail($agent, 'HTTP '.$response->status());
$response = null;
foreach ($models as $candidate) {
try {
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$apiKey,
'HTTP-Referer' => config('ai.openrouter.app_url'),
'X-Title' => config('ai.openrouter.app_name'),
])->timeout((int) config('ai.openrouter.timeout', 30))
->post(config('ai.openrouter.base_url').'/chat/completions', [
'model' => $candidate,
'messages' => [
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $userMessage],
],
'max_tokens' => 300,
]);
} catch (\Throwable $e) {
Log::warning('NPC think model exception', ['model' => $candidate, 'error' => $e->getMessage()]);
$response = null;
continue;
}
if ($response->failed()) {
Log::warning('NPC think model failed', ['model' => $candidate, 'status' => $response->status()]);
$response = null;
continue;
}
break;
}
if ($response === null) {
self::fail($agent, 'all models failed');
return;
}