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:
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user