resolveCity($request); return view('city.viewer', [ 'city' => $city, ]); } /** * JSON payload driving the 3D scene for the selected city. */ public function data(Request $request): JsonResponse { $city = $this->resolveCity($request); $user = Auth::user(); $me = $user ? Character::ensureForUser($user, $city) : null; if ($me && (int) $me->city_id !== (int) $city->id) { // Keep the player's home city authoritative when no explicit ?city= given. if (! $request->filled('city')) { $city = $me->city; } } $stores = Store::query() ->orderBy('id') ->get(['id', 'name', 'type', 'pos_x', 'pos_y', 'pos_z', 'width', 'height', 'depth', 'is_open', 'owner_id']) ->map(fn (Store $s): array => [ 'id' => $s->id, 'name' => $s->name, 'type' => $s->type, 'position' => [(float) $s->pos_x, (float) $s->pos_y, (float) $s->pos_z], 'size' => [(float) $s->width, (float) $s->height, (float) $s->depth], 'is_open' => (bool) $s->is_open, 'owner_id' => $s->owner_id, ]); $characters = Character::query() ->where('city_id', $city->id) ->with('user:id,name') ->get() ->map(fn (Character $c): array => [ 'id' => $c->id, 'name' => $c->name, 'type' => $c->user_id === null ? 'child' : 'citizen', 'status' => $c->state, 'position' => [(float) $c->pos_x, (float) $c->pos_y, (float) $c->pos_z], 'is_online' => (bool) $c->is_online, 'is_me' => $me !== null && (int) $me->id === (int) $c->id, 'owner_name' => $c->user?->name, ]); $buildings = Building::query() ->where('city_id', $city->id) ->get() ->map(fn (Building $b): array => [ 'id' => $b->id, 'type' => $b->type, 'name' => $b->name, 'position' => [(float) $b->pos_x, 0, (float) $b->pos_z], 'size' => [(float) $b->width, (float) $b->height, (float) $b->depth], 'is_complete' => (bool) $b->is_complete, 'build_progress' => (int) $b->build_progress, 'owner_character_id' => $b->owner_character_id, ]); $jobs = Job::query() ->where('city_id', $city->id) ->get() ->map(fn (Job $j): array => [ 'id' => $j->id, 'title' => $j->title, 'salary' => (float) $j->salary, 'requires_education' => (int) $j->requires_education, 'store_id' => $j->store_id, ]); $payload = [ 'generated_at' => now()->toIso8601String(), 'city' => [ 'id' => $city->id, 'name' => $city->name, 'slug' => $city->slug, 'bounds' => [ 'min_x' => (float) $city->bounds_min_x, 'max_x' => (float) $city->bounds_max_x, 'min_z' => (float) $city->bounds_min_z, 'max_z' => (float) $city->bounds_max_z, ], ], 'stores' => $stores, 'characters' => $characters, 'buildings' => $buildings, 'jobs' => $jobs, 'me' => $me ? [ 'id' => $me->id, 'name' => $me->name, 'state' => $me->state, 'money' => (float) $me->money, 'energy' => (int) $me->energy, 'hunger' => (int) $me->hunger, 'thirst' => (int) $me->thirst, 'health' => (int) $me->health, 'education' => (int) $me->education, 'exercise_count' => (int) $me->exercise_count, 'job_id' => $me->job_id, 'job_title' => $me->job?->title, 'house_id' => $me->house_id, 'position' => [(float) $me->pos_x, (float) $me->pos_y, (float) $me->pos_z], 'hospitalized_until' => $me->hospitalized_until?->toIso8601String(), 'inventory' => $me->inventory()->get()->mapWithKeys(fn ($i) => [$i->item => (int) $i->qty])->all(), ] : null, '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, 'type' => $a->type, 'status' => $a->status, 'position' => [(float) $a->pos_x, (float) $a->pos_y, (float) $a->pos_z], 'rotation_y' => (float) $a->rotation_y, 'current_goal' => $a->current_goal, 'last_decision' => $a->last_decision, ]), ]; 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) !== '') { $text = mb_substr(trim($text), 0, 240); if (self::looksLikeClearance($text, $flight)) { return $text; } // Model echoed the instruction instead of the clearance: // reject and continue down the chain (next model or the // deterministic fallback in the caller). Log::warning('ATC clearance rejected (not a clearance)', ['model' => $model]); } } return null; } /** * Heuristic guard: a clearance is short, speech-like and about the * requested flight — never a restatement of the prompt itself. * * @param array{number: string, airline: string, runway: string} $flight */ private static function looksLikeClearance(string $text, array $flight): bool { $lower = mb_strtolower($text); $promptMeta = [ 'the user wants', 'respond only', 'no quotes', 'atc style', 'short sentence', 'for example', 'instructions', ]; foreach ($promptMeta as $needle) { if (str_contains($lower, $needle)) { return false; } } return str_contains($text, $flight['number']) || preg_match('/autoriz|pista|runway|cleared|descolagem|decolag/iu', $text) === 1; } /** * Resolve the city from the query string or the active default. */ private function resolveCity(Request $request): City { $slug = $request->query('city'); if (is_string($slug) && $slug !== '') { $city = City::query() ->where('slug', $slug) ->orWhere('id', ctype_digit($slug) ? (int) $slug : 0) ->first(); if ($city) { return $city; } } $user = Auth::user(); if ($user) { $character = Character::query()->where('user_id', $user->id)->first(); if ($character?->city) { return $character->city; } } return City::active() ?? City::query()->firstOrFail(); } /** * Build the street grid layout from city bounds and store anchors. * * @param array> $storeRows * @return array */ private function cityLayout(City $city, array $storeRows): array { $minX = (int) floor(((float) $city->bounds_min_x) / 40) * 40 - 40; $maxX = (int) ceil(((float) $city->bounds_max_x) / 40) * 40 + 40; $minZ = (int) floor(((float) $city->bounds_min_z) / 40) * 40 - 40; $maxZ = (int) ceil(((float) $city->bounds_max_z) / 40) * 40 + 40; $roads = []; for ($x = $minX; $x <= $maxX; $x += 40) { $roads[] = ['from' => [$x, 0, $minZ], 'to' => [$x, 0, $maxZ], 'width' => 8, 'axis' => 'z']; } for ($z = $minZ; $z <= $maxZ; $z += 40) { $roads[] = ['from' => [$minX, 0, $z], 'to' => [$maxX, 0, $z], 'width' => 8, 'axis' => 'x']; } $blocks = []; for ($x = $minX; $x < $maxX; $x += 40) { for ($z = $minZ; $z < $maxZ; $z += 40) { $blocks[] = ['min' => [$x + 5, 0, $z + 5], 'max' => [$x + 35, 0, $z + 35]]; } } return [ 'roads' => $roads, 'blocks' => $blocks, 'origin' => [0, 0, 0], 'bounds' => [ 'min_x' => $minX, 'max_x' => $maxX, 'min_z' => $minZ, 'max_z' => $maxZ, ], ]; } }