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
@@ -515,49 +515,66 @@ class PersonTalkController extends Controller
// the after-response job is never killed mid-flight.
$timeout = min((int) config('ai.openrouter.timeout', 30), 20);
try {
$response = Http::withHeaders([
'Authorization' => 'Bearer '.config('ai.openrouter.api_key'),
'HTTP-Referer' => config('ai.openrouter.app_url'),
'X-Title' => config('ai.openrouter.app_name'),
])->timeout($timeout)
->post(config('ai.openrouter.base_url').'/chat/completions', [
'model' => $model,
'messages' => $messages,
// Room for models that reason before answering — too low
// a budget starves the visible content (e.g. "Ah").
'max_tokens' => 700,
// Preferred model first, then the configured fallback chain
// (AI_FALLBACK_MODEL) before giving up to the caller.
$models = [$model];
$fallback = config('ai.fallback_model');
if (is_string($fallback) && $fallback !== ''
&& $fallback !== $model
&& in_array($fallback, config('ai.allowed_models', []), true)) {
$models[] = $fallback;
}
foreach ($models as $candidate) {
try {
$response = Http::withHeaders([
'Authorization' => 'Bearer '.config('ai.openrouter.api_key'),
'HTTP-Referer' => config('ai.openrouter.app_url'),
'X-Title' => config('ai.openrouter.app_name'),
])->timeout($timeout)
->post(config('ai.openrouter.base_url').'/chat/completions', [
'model' => $candidate,
'messages' => $messages,
// Room for models that reason before answering — too low
// a budget starves the visible content (e.g. "Ah").
'max_tokens' => 700,
]);
} catch (\Throwable $e) {
Log::warning('Person talk upstream exception', ['model' => $candidate, 'error' => $e->getMessage()]);
continue;
}
if ($response->failed()) {
Log::warning('Person talk upstream failed', ['model' => $candidate, 'status' => $response->status()]);
continue;
}
$reply = $response->json('choices.0.message.content');
// Some models return content as an array of text parts.
if (is_array($reply)) {
$reply = implode('', array_map(
static fn ($part): string => is_array($part) ? (string) ($part['text'] ?? '') : (string) $part,
$reply
));
}
if (is_string($reply) && mb_strlen(trim($reply)) < 10) {
Log::warning('Person talk suspiciously short reply', [
'model' => $candidate,
'finish_reason' => $response->json('choices.0.finish_reason'),
'raw' => substr((string) json_encode($response->json('choices.0.message'), JSON_UNESCAPED_UNICODE), 0, 800),
]);
} catch (\Throwable $e) {
Log::warning('Person talk upstream exception', ['error' => $e->getMessage()]);
}
return null;
if (is_string($reply) && trim($reply) !== '') {
return $reply;
}
}
if ($response->failed()) {
Log::warning('Person talk upstream failed', ['status' => $response->status()]);
return null;
}
$reply = $response->json('choices.0.message.content');
// Some models return content as an array of text parts.
if (is_array($reply)) {
$reply = implode('', array_map(
static fn ($part): string => is_array($part) ? (string) ($part['text'] ?? '') : (string) $part,
$reply
));
}
if (is_string($reply) && mb_strlen(trim($reply)) < 10) {
Log::warning('Person talk suspiciously short reply', [
'finish_reason' => $response->json('choices.0.finish_reason'),
'raw' => substr((string) json_encode($response->json('choices.0.message'), JSON_UNESCAPED_UNICODE), 0, 800),
]);
}
return is_string($reply) && trim($reply) !== '' ? $reply : null;
return null;
}
/**