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
@@ -9,11 +9,14 @@ use App\Models\Character;
use App\Models\City;
use App\Models\Job;
use App\Models\Store;
use App\Support\Airports;
use App\Support\Beach;
use App\Support\CityPlazas;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
/**
@@ -143,6 +146,7 @@ class CityViewerController extends Controller
'layout' => $this->cityLayout($city, $stores->all()),
'plazas' => CityPlazas::forCity($city),
'beach' => Beach::geometry($city),
'airports' => Airports::geometry($city),
'agents' => AiAgent::orderBy('id')->get()->map(fn ($a): array => [
'id' => $a->id,
'name' => $a->name,
@@ -158,6 +162,216 @@ class CityViewerController extends Controller
return response()->json($payload)->header('Cache-Control', 'no-store');
}
/**
* Airport summary: sites, control-tower agent and latest clearance.
*
* The client polls this while aircraft operate so AI-generated tower
* clearances (AI_ATC_MODEL) show up as they are produced.
*/
public function airport(Request $request): JsonResponse
{
$city = $this->resolveCity($request);
$atc = AiAgent::query()->where('type', 'atc')->first();
return response()->json([
'sites' => array_map(
static fn (array $site): array => ['id' => $site['id'], 'name' => $site['name'], 'code' => $site['code']],
Airports::geometry($city)['sites']
),
'atc' => $atc !== null ? [
'id' => (int) $atc->id,
'name' => $atc->name,
'model' => $atc->model,
'status' => $atc->status,
'error_count' => (int) $atc->error_count,
] : null,
'clearance' => cache()->get('airport.clearance'),
])->header('Cache-Control', 'no-store');
}
/**
* Ask the control tower for the next takeoff clearance (fire and
* forget). Internally throttled: one generation per 30 seconds.
*/
public function dispatch(): JsonResponse
{
if (! cache()->add('airport.dispatch.lock', 1, 30)) {
return response()->json(['accepted' => false, 'reason' => 'cooldown'], 202);
}
$atc = AiAgent::query()->where('type', 'atc')->first();
if ($atc === null) {
return response()->json(['message' => __('city.atc_missing')], 422);
}
dispatch(function (): void {
self::requestClearance();
})->afterResponse();
return response()->json(['accepted' => true], 202);
}
/**
* Generate one ATC clearance with the configured tower model.
*
* Runs after the response (no queue worker needed). The payload only
* carries simulated flight data — no user PII (RULES.md 2.2). On any
* failure a deterministic clearance is stored so operations continue
* and the failure is counted for backoff (RULES.md 2.4).
*/
public static function requestClearance(): void
{
try {
$atc = AiAgent::query()->where('type', 'atc')->first();
if ($atc === null) {
return;
}
$flight = self::clearanceFlight();
// Backoff (RULES.md 2.4): after 5 consecutive failures only
// retry every 5 minutes; never call without an API key.
$mayTry = false;
if (filled(config('ai.openrouter.api_key'))) {
$lastTry = (int) cache()->get('airport.atc.last_try', 0);
$mayTry = (int) $atc->error_count < 5 || $lastTry < time() - 300;
if ($mayTry) {
cache()->put('airport.atc.last_try', time(), now()->addHour());
}
}
$text = $mayTry ? self::askTower($atc, $flight) : null;
if ($text === null) {
// Count real attempts only (backoff-blocked skips and
// missing-key short-circuits keep their own semantics).
$attempted = $mayTry || ! filled(config('ai.openrouter.api_key'));
$errors = (int) $atc->error_count + ($attempted ? 1 : 0);
$atc->update([
'status' => $errors >= 5 ? 'error' : 'active',
'error_count' => $errors,
]);
$text = sprintf(
'Torre: %s (%s) autorizado para decolagem na pista %s.',
$flight['number'],
$flight['airline'],
$flight['runway']
);
} else {
$atc->update([
'status' => 'active',
'error_count' => 0,
'last_think_at' => now(),
]);
}
cache()->put('airport.clearance', [
'text' => $text,
'flight' => $flight['number'],
'airline' => $flight['airline'],
'runway' => $flight['runway'],
'source' => $atc->error_count > 0 ? 'fallback' : 'model',
'at' => now()->toIso8601String(),
], now()->addHours(6));
$log = $atc->last_decision ?? [];
$log[] = ['role' => 'atc', 'content' => $text, 'at' => now()->toIso8601String()];
$atc->update(['last_decision' => array_slice($log, -20)]);
} catch (\Throwable $e) {
Log::warning('ATC clearance generation failed', ['error' => $e->getMessage()]);
}
}
/**
* Random simulated flight for the next clearance (no real bookings).
*
* @return array{number: string, airline: string, runway: string}
*/
private static function clearanceFlight(): array
{
$airlines = ['City10 Air', 'AzulCéu', 'Bandeirante', 'Sol Nascente', 'Pampa Air', 'NorteLine'];
$runways = ['09', '27', '18', '36'];
return [
'number' => 'C10-'.random_int(101, 399),
'airline' => $airlines[array_rand($airlines)],
'runway' => $runways[array_rand($runways)],
];
}
/**
* Call the tower for one short clearance phrase; null on failure.
*
* Tries the configured ATC model first (AI_ATC_MODEL), then the
* agent's own model and finally the platform default — all
* allow-listed — before the caller stores the deterministic
* fallback. Clearances stay AI-generated even while a preferred id
* is missing from the live OpenRouter catalogue.
*
* @param array{number: string, airline: string, runway: string} $flight
*/
private static function askTower(AiAgent $atc, array $flight): ?string
{
// Without an API key there is nothing to ask (no try consumed).
if (! filled(config('ai.openrouter.api_key'))) {
return null;
}
$allowed = config('ai.allowed_models', []);
$candidates = [];
foreach ([config('ai.atc_model'), $atc->model, config('ai.default_model'), config('ai.fallback_model')] as $model) {
if (is_string($model) && $model !== '' && in_array($model, $allowed, true)) {
$candidates[$model] = $model;
}
}
$prompt = 'Você é a torre de controle aéreo do Aeroporto City10. Autorize em UMA frase '
.'curta, em português, estilo controle de tráfego aéreo, a decolagem do voo '
.$flight['number'].' ('.$flight['airline'].') na pista '.$flight['runway']
.'. Responda apenas com a frase de autorização, sem aspas nem explicações.';
$timeout = min((int) config('ai.openrouter.timeout', 30), 15);
foreach ($candidates as $model) {
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' => [['role' => 'user', 'content' => $prompt]],
'max_tokens' => 120,
]);
} catch (\Throwable $e) {
Log::warning('ATC tower model exception', ['model' => $model, 'error' => $e->getMessage()]);
continue;
}
if ($response->failed()) {
Log::warning('ATC tower model failed', ['model' => $model, 'status' => $response->status()]);
continue;
}
$text = $response->json('choices.0.message.content');
if (is_array($text)) {
$text = implode('', array_map(
static fn ($part): string => is_array($part) ? (string) ($part['text'] ?? '') : (string) $part,
$text
));
}
if (is_string($text) && trim($text) !== '') {
return mb_substr(trim($text), 0, 240);
}
}
return null;
}
/**
* Resolve the city from the query string or the active default.
*/