diff --git a/.env.example b/.env.example index 3b54ae0..ffc770b 100755 --- a/.env.example +++ b/.env.example @@ -67,6 +67,14 @@ AI_MODELS=deepseek/deepseek-v4-flash-0731,nvidia/nemotron-3.5-lightning:free,liq # separated), e.g. Maria=liquid/lfm-2.5-2.6b:free,7=openrouter/free AI_CHARACTER_MODELS= +# AI_ATC_MODEL: default model of the control-tower agent (airport +# clearances: takeoff authorizations). Must be allow-listed. +AI_ATC_MODEL=poolside/laguna-xs-2.1:free + +# AI_FALLBACK_MODEL: tried when the preferred model of a call fails +# (owner-approved paid model with near-zero cost). +AI_FALLBACK_MODEL=xiaomi/mimo-v2.6-flash + AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 diff --git a/.playwright-mcp/aeroporto-sul-patio.png b/.playwright-mcp/aeroporto-sul-patio.png new file mode 100644 index 0000000..3d53b34 Binary files /dev/null and b/.playwright-mcp/aeroporto-sul-patio.png differ diff --git a/.playwright-mcp/aeroporto-sul-pista.png b/.playwright-mcp/aeroporto-sul-pista.png new file mode 100644 index 0000000..d9507cd Binary files /dev/null and b/.playwright-mcp/aeroporto-sul-pista.png differ diff --git a/.playwright-mcp/letreiro-praca-noite.png b/.playwright-mcp/letreiro-praca-noite.png new file mode 100644 index 0000000..f2002e4 Binary files /dev/null and b/.playwright-mcp/letreiro-praca-noite.png differ diff --git a/.playwright-mcp/letreiro-praia-entrada.png b/.playwright-mcp/letreiro-praia-entrada.png new file mode 100644 index 0000000..b26d9b8 Binary files /dev/null and b/.playwright-mcp/letreiro-praia-entrada.png differ diff --git a/.playwright-mcp/letreiros-loja-noite.png b/.playwright-mcp/letreiros-loja-noite.png new file mode 100644 index 0000000..0eae9ae Binary files /dev/null and b/.playwright-mcp/letreiros-loja-noite.png differ diff --git a/README.md b/README.md index 5f1a984..168b968 100755 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ O visualizador `/city` carrega o mundo completo em tempo real: - **Cidade de 72 quarteirões** (grade 9 × 8 de blocos de 40 m; bounds `−120..160 × −120..120`), com rio e ponte a oeste, extensão rodoviária ao - norte, hospital, escola, prefeitura e casas de jogadores. + sul, hospital, escola, prefeitura e casas de jogadores. - **Trânsito**: carros, motos, bikes e ônibus com tráfego à direita, loop por quarteirão e **semáforos por cruzamento** (fases NS/EW com amarelos e all-red; veículos param na linha de parada **antes** das faixas). @@ -63,6 +63,19 @@ O visualizador `/city` carrega o mundo completo em tempo real: e bola em arremesso, barracas de comidas, cadeiras e guarda-sóis**. O jogador pode **relaxar na areia** (tecla `E` → `POST /city/actions/beach`), recuperando energia/saúde por tick até o máximo. +- **Dois aeroportos** derivados dos bounds (pavimento + sinalização de + pista com designadores, taxiways com eixo amarelo, pátio de embarque com + posições de estacionamento de aeronaves, terminal com pontes articuladas, + hangars, torre de controle e helipista): **7 aeronaves de 7 companhias** + (jato comercial, jato regional, monomotor e helicóptero) executam o ciclo + completo *taxi → decolagem → cruíse sobre a cidade → aproximação → pouso + → taxi → gate*, com contagem de decolagens/pousos em `stats().flights`. +- **Torre de controle com IA**: o agente `atc` "Torre de Controle City10" + (modelo `AI_ATC_MODEL`, `poolside/laguna-xs-2.1:free`) gera clearances de + decolagem a cada decolagem (`POST /city/airport/dispatch`, cooldown de + 30 s) numa **cadeia de modelos** `AI_ATC_MODEL` → default → + `AI_FALLBACK_MODEL` (`xiaomi/mimo-v2.6-flash`), com texto determinístico + quando tudo falha; o clearance mais recente aparece como aviso "✈ ...". - **Agentes vivos**: prefeito itinerante, lojistas nas lojas, pedestres com rotas, mais trens (passagem de nível com barreiras), ônibus/trem embarcáveis, avião e barcos no rio. @@ -163,6 +176,14 @@ AI_MODELS=deepseek/deepseek-v4-flash-0731,nvidia/nemotron-3.5-lightning:free,liq # Override por personagem: "nome ou id=modelo" separado por vírgula. AI_CHARACTER_MODELS= + +# AI_ATC_MODEL: default model of the control-tower agent (airport +# clearances: takeoff authorizations). Must be allow-listed. +AI_ATC_MODEL=poolside/laguna-xs-2.1:free + +# AI_FALLBACK_MODEL: tried when the preferred model of a call fails +# (owner-approved paid model with near-zero cost). +AI_FALLBACK_MODEL=xiaomi/mimo-v2.6-flash ``` ### 4. Banco de dados @@ -274,6 +295,8 @@ Todas exigem login + termos aceitos. | POST/GET | `/city/chat/{agent}` (+`/stream`) | Conversa com NPC (envio + SSE de resposta) | | POST/GET | `/city/messages/{character}` | Mensagens assíncronas entre personagens | | POST | `/city/social/{verb}` · GET `/city/social/inbox` | Amizade, casamento, filho, inbox | +| GET | `/city/airport` | Sites dos aeroportos, torre (ATC) e clearance atual | +| POST | `/city/airport/dispatch` | Pede um clearance de decolagem à torre (cooldown 30 s) | | GET/POST | `/city/market` (`/buy`, `/sell`) | Mercado | | GET/PUT | `/city/customize` | Personalização do personagem | @@ -281,9 +304,11 @@ Todas exigem login + termos aceitos. ## Modelos de IA Permitidos -Somente os modelos **gratuitos** abaixo podem ser usados (validado no backend -em `config/ai.php`; os quatro últimos foram verificados ao vivo no catálogo do -OpenRouter): +Somente os modelos abaixo podem ser usados (validado no backend em +`config/ai.php`; os quatro últimos foram verificados ao vivo no catálogo do +OpenRouter). Exceção aprovada pelo responsável: `xiaomi/mimo-v2.6-flash` +(pago, custo mínimo ~1,4e-7/token, verificado ao vivo) — usado como +**fallback** de todas as cadeias de chamada: ``` poolside/laguna-m.1:free cohere/north-mini-code:free @@ -297,6 +322,7 @@ poolside/laguna-xs-2.1:free inclusionai/ling-3.0-flash-vl:free qwen/qwen3.7-flash:free deepseek/deepseek-v4-flash-0731 nvidia/nemotron-3.5-lightning:free liquid/lfm-2.5-2.6b:free openrouter/free +xiaomi/mimo-v2.6-flash (fallback — ver nota acima) ``` --- diff --git a/RULES.md b/RULES.md index 7d1118c..dd45aed 100755 --- a/RULES.md +++ b/RULES.md @@ -35,9 +35,13 @@ ao trabalhar neste repositório. ## 2. Regras para Modelos de IA (Agentes do City10) -2.1. **Somente modelos gratuitos allow-listed.** Agentes só podem usar - modelos definidos em `config/ai.php` (`allowed_models`). A validação - acontece no backend (`AiAgentRequest`) — não contorne. +2.1. **Somente modelos allow-listed.** Agentes só podem usar modelos + definidos em `config/ai.php` (`allowed_models`). A validação + acontece no backend (`AiAgentRequest`) — não contorne. Por padrão a + lista contém modelos gratuitos; **exceção aprovada pelo responsável**: + `xiaomi/mimo-v2.6-flash` (pago, custo mínimo ~1,4e-7/token, + verificado ao vivo) — atua como **modelo de fallback** das cadeias + de chamada (`AI_FALLBACK_MODEL`): preferido → default → fallback. 2.2. **Nunca envie dados pessoais ao modelo.** Ao chamar o OpenRouter, envie apenas contexto simulado da cidade (posições, metas, estado de lojas). diff --git a/app/Http/Controllers/Web/ChatController.php b/app/Http/Controllers/Web/ChatController.php index 636ab38..1fc617d 100755 --- a/app/Http/Controllers/Web/ChatController.php +++ b/app/Http/Controllers/Web/ChatController.php @@ -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; } diff --git a/app/Http/Controllers/Web/CityActionController.php b/app/Http/Controllers/Web/CityActionController.php index f48a508..04c7ba1 100755 --- a/app/Http/Controllers/Web/CityActionController.php +++ b/app/Http/Controllers/Web/CityActionController.php @@ -9,6 +9,7 @@ use App\Models\City; use App\Models\InventoryItem; use App\Models\Job; use App\Models\Transaction; +use App\Support\Airports; use App\Support\Beach; use App\Support\CityPlazas; use App\Support\MissionTracker; @@ -53,7 +54,15 @@ class CityActionController extends Controller // but never beyond the shoreline. $maxX = Beach::sandTo($city) + 2; $x = max((float) $city->bounds_min_x, min($maxX, (float) $data['pos_x'])); - $z = max((float) $city->bounds_min_z, min((float) $city->bounds_max_z, (float) $data['pos_z'])); + // Airport grounds (north/south strips) are walkable beyond the + // city bounds inside their X band. + $minZ = (float) $city->bounds_min_z; + $maxZ = (float) $city->bounds_max_z; + if (abs($x) <= Airports::VISIT_HALF_WIDTH) { + $minZ = Airports::VISIT_MIN_Z; + $maxZ = Airports::VISIT_MAX_Z; + } + $z = max($minZ, min($maxZ, (float) $data['pos_z'])); $character->forceFill([ 'pos_x' => $x, diff --git a/app/Http/Controllers/Web/CityPresenceController.php b/app/Http/Controllers/Web/CityPresenceController.php index 5b6e563..3648a5e 100755 --- a/app/Http/Controllers/Web/CityPresenceController.php +++ b/app/Http/Controllers/Web/CityPresenceController.php @@ -35,8 +35,12 @@ class CityPresenceController extends Controller ->first() ?? $city; } + // Rows are shared across users for one second, so is_me must NOT + // be baked into the cached payload — it is computed per viewer + // below (otherwise user B sees user A's is_me flags and could see + // their own character as a remote citizen). $cacheKey = 'presence.'.$city->id.'.'.now()->timestamp; - $citizens = Cache::remember($cacheKey, 1, function () use ($city, $me) { + $citizens = Cache::remember($cacheKey, 1, function () use ($city) { return Character::query() ->where('city_id', $city->id) ->where(function ($q): void { @@ -52,13 +56,18 @@ class CityPresenceController extends Controller 'status' => $c->state, 'position' => [(float) $c->pos_x, (float) $c->pos_y, (float) $c->pos_z], 'is_online' => (bool) $c->is_online, - 'is_me' => (int) $c->id === (int) $me->id, 'owner_name' => $c->user?->name, ]) ->values() ->all(); }); + $viewerId = (int) $me->id; + $citizens = array_map( + static fn (array $c): array => $c + ['is_me' => (int) $c['id'] === $viewerId], + $citizens + ); + return response()->json([ 'city_id' => $city->id, 'server_time' => now()->toIso8601String(), diff --git a/app/Http/Controllers/Web/CityViewerController.php b/app/Http/Controllers/Web/CityViewerController.php index d4eeaa9..01f206a 100755 --- a/app/Http/Controllers/Web/CityViewerController.php +++ b/app/Http/Controllers/Web/CityViewerController.php @@ -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. */ diff --git a/app/Http/Controllers/Web/PersonTalkController.php b/app/Http/Controllers/Web/PersonTalkController.php index e713bde..86bc797 100644 --- a/app/Http/Controllers/Web/PersonTalkController.php +++ b/app/Http/Controllers/Web/PersonTalkController.php @@ -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; } /** diff --git a/app/Support/Airports.php b/app/Support/Airports.php new file mode 100644 index 0000000..79fb450 --- /dev/null +++ b/app/Support/Airports.php @@ -0,0 +1,147 @@ +bounds_max_z) / 40) * 40 + 40); + } + + /** + * Layout edge south of the city. + */ + public static function layoutMinZ(City $city): float + { + return (float) (floor(((float) $city->bounds_min_z) / 40) * 40 - 40); + } + + /** + * Full airports descriptor for the world payload. + * + * @return array{visit: array, sites: list>} + */ + public static function geometry(City $city): array + { + return [ + 'visit' => [ + 'half_width' => self::VISIT_HALF_WIDTH, + 'min_z' => self::VISIT_MIN_Z, + 'max_z' => self::VISIT_MAX_Z, + ], + 'sites' => [ + self::northSite(self::layoutMaxZ($city)), + self::southSite(self::layoutMinZ($city)), + ], + ]; + } + + /** + * Large airport south of the city (+Z): east-west runway. + * + * @return array + */ + private static function northSite(float $lz): array + { + // lz = 160 for the shipped bounds: runway at 350, apron at 285. + $standZ = $lz + 135; + + return [ + 'id' => 'sul', + 'name' => 'Aeroporto City10 Sul', + 'code' => 'CTS', + 'runway' => [ + 'center' => [0.0, $lz + 190], + 'length' => 180.0, + 'width' => 18.0, + 'axis' => 'x', + 'designators' => ['09', '27'], + ], + 'taxiways' => [ + ['center' => [0.0, $lz + 160], 'length' => 170.0, 'width' => 10.0, 'axis' => 'x'], + ['center' => [-80.0, $lz + 175], 'length' => 30.0, 'width' => 8.0, 'axis' => 'z'], + ['center' => [80.0, $lz + 175], 'length' => 30.0, 'width' => 8.0, 'axis' => 'z'], + ], + 'apron' => ['center' => [0.0, $lz + 125], 'size' => [170.0, 60.0]], + 'terminal' => ['center' => [-45.0, $lz + 105], 'size' => [80.0, 18.0]], + 'hangars' => [ + ['center' => [35.0, $lz + 106], 'size' => [26.0, 20.0]], + ['center' => [65.0, $lz + 106], 'size' => [26.0, 20.0]], + ], + 'tower' => ['center' => [10.0, $lz + 118]], + 'stands' => [ + [-70.0, $standZ], + [-35.0, $standZ], + [0.0, $standZ], + [35.0, $standZ], + ], + 'helipad' => ['center' => [-100.0, $lz + 165], 'radius' => 8.0], + ]; + } + + /** + * Regional airport north of the city (-Z): north-south runway. + * + * @return array + */ + private static function southSite(float $lz): array + { + // lz = -160 for the shipped bounds: runway centre at -300. + $cZ = $lz - 140; + + return [ + 'id' => 'norte', + 'name' => 'Aeroporto Regional do Norte', + 'code' => 'CRN', + 'runway' => [ + 'center' => [-50.0, $cZ], + 'length' => 160.0, + 'width' => 14.0, + 'axis' => 'z', + 'designators' => ['18', '36'], + ], + 'taxiways' => [ + ['center' => [-30.0, $cZ], 'length' => 150.0, 'width' => 8.0, 'axis' => 'z'], + // Connectors aligned with the takeoff lineup (-240) and the + // landing rollout exit (-370) so taxi paths stay on pavement. + ['center' => [-40.0, $lz - 80], 'length' => 20.0, 'width' => 7.0, 'axis' => 'x'], + ['center' => [-40.0, $lz - 210], 'length' => 20.0, 'width' => 7.0, 'axis' => 'x'], + ], + 'apron' => ['center' => [15.0, $cZ], 'size' => [90.0, 140.0]], + 'terminal' => ['center' => [55.0, $lz - 95], 'size' => [34.0, 14.0]], + 'hangars' => [ + ['center' => [55.0, $lz - 205], 'size' => [24.0, 18.0]], + ], + 'tower' => ['center' => [40.0, $lz - 80]], + 'stands' => [ + [0.0, $lz - 95], + [0.0, $lz - 155], + [30.0, $lz - 125], + [30.0, $lz - 185], + ], + 'helipad' => ['center' => [75.0, $lz - 85], 'radius' => 7.0], + ]; + } +} diff --git a/config/ai.php b/config/ai.php index 0d12a84..93c06ec 100755 --- a/config/ai.php +++ b/config/ai.php @@ -84,6 +84,9 @@ return [ 'nvidia/nemotron-3.5-lightning:free', 'liquid/lfm-2.5-2.6b:free', 'openrouter/free', + // Owner-approved exception: paid but near-zero cost + // (1.4e-7/1k tokens), verified live on 2026-11-01. + 'xiaomi/mimo-v2.6-flash', ], /* @@ -115,6 +118,31 @@ return [ 'character_models' => $parseCharacterModels((string) env('AI_CHARACTER_MODELS', '')), + /* + |-------------------------------------------------------------------------- + | Air Traffic Control Model (AI_ATC_MODEL) + |-------------------------------------------------------------------------- + | + | Default model used by the airport control tower agent for movement + | clearances (takeoff authorizations). Must stay inside allowed_models. + | + */ + + 'atc_model' => env('AI_ATC_MODEL', 'poolside/laguna-xs-2.1:free'), + + /* + |-------------------------------------------------------------------------- + | Fallback Model (AI_FALLBACK_MODEL) + |-------------------------------------------------------------------------- + | + | Tried whenever the preferred model of a call fails (chain fallback). + | Owner-approved paid model with near-zero cost; must stay inside + | allowed_models. + | + */ + + 'fallback_model' => env('AI_FALLBACK_MODEL', 'xiaomi/mimo-v2.6-flash'), + /* |-------------------------------------------------------------------------- | Default Model diff --git a/database/seeders/CityWorldSeeder.php b/database/seeders/CityWorldSeeder.php index 491c596..9b9b555 100755 --- a/database/seeders/CityWorldSeeder.php +++ b/database/seeders/CityWorldSeeder.php @@ -2,6 +2,7 @@ namespace Database\Seeders; +use App\Models\AiAgent; use App\Models\Building; use App\Models\Character; use App\Models\City; @@ -9,6 +10,7 @@ use App\Models\Job; use App\Models\Mission; use App\Models\Store; use App\Models\User; +use App\Support\Airports; use Illuminate\Database\Seeder; /** @@ -40,6 +42,31 @@ class CityWorldSeeder extends Seeder ], ); + // --- Control tower agent (airport clearances) --------------------- + // Positioned at the north airport tower; model comes from + // AI_ATC_MODEL (poolside/laguna-xs-2.1:free by default) and is + // re-validated against the allow-list before use. + $tower = Airports::geometry($city)['sites'][0]['tower']['center']; + $atcModel = (string) config('ai.atc_model'); + if (! in_array($atcModel, config('ai.allowed_models', []), true)) { + $atcModel = 'poolside/laguna-xs-2.1:free'; + } + AiAgent::updateOrCreate( + ['name' => 'Torre de Controle City10'], + [ + 'type' => 'atc', + 'model' => $atcModel, + 'system_prompt' => 'Você é a torre de controle aéreo do Aeroporto City10 Norte. ' + .'Autorize decolagens e pousos com linguagem formal, curta e clássica de controle ' + .'de tráfego aéreo, sempre em português, citando o voo, a companhia e a pista.', + 'current_goal' => 'Gerenciar decolagens, pousos e o tráfego dos dois aeroportos com segurança', + 'status' => 'active', + 'pos_x' => (float) $tower[0], + 'pos_y' => 0, + 'pos_z' => (float) $tower[1], + ], + ); + $bakery = Store::where('type', 'bakery')->orderBy('id')->first(); $market = Store::where('type', 'market')->orderBy('id')->first(); diff --git a/lang/en.json b/lang/en.json index 1b1889c..47952ce 100755 --- a/lang/en.json +++ b/lang/en.json @@ -219,6 +219,13 @@ "city.beach_start": "Resting on the sand…", "city.beach_prompt": "Relax at the beach", "city.beach_active": "Relaxing at the beach", + "city.atc_missing": "No control tower configured.", + "city.sign_plaza": "Plaza", + "city.sign_beach": "Beach", + "city.sign_food": "Food", + "city.sign_hospital": "Hospital", + "city.sign_school": "School", + "city.sign_cityhall": "City Hall", "city.exercise_need_equipment": "Get closer to plaza gym equipment.", "city.exercise_need_awake": "You must be awake to exercise.", "city.exercise_tired": "You are too tired to exercise.", diff --git a/lang/es.json b/lang/es.json index 8e98ad1..75858e6 100755 --- a/lang/es.json +++ b/lang/es.json @@ -219,6 +219,13 @@ "city.beach_start": "Descansando en la arena…", "city.beach_prompt": "Relajar en la playa", "city.beach_active": "Relajándote en la playa", + "city.atc_missing": "Ninguna torre de control configurada.", + "city.sign_plaza": "Plaza", + "city.sign_beach": "Playa", + "city.sign_food": "Comida", + "city.sign_hospital": "Hospital", + "city.sign_school": "Escuela", + "city.sign_cityhall": "Alcaldía", "city.exercise_need_equipment": "Acérquese a un equipo de la plaza.", "city.exercise_need_awake": "Debe estar despierto para ejercitarse.", "city.exercise_tired": "Está demasiado cansado para ejercitarse.", diff --git a/lang/pt_BR.json b/lang/pt_BR.json index 993e441..dd9a958 100755 --- a/lang/pt_BR.json +++ b/lang/pt_BR.json @@ -219,6 +219,13 @@ "city.beach_start": "Descansando na areia…", "city.beach_prompt": "Relaxar na praia", "city.beach_active": "Relaxando na praia", + "city.atc_missing": "Nenhuma torre de controle configurada.", + "city.sign_plaza": "Praça", + "city.sign_beach": "Praia", + "city.sign_food": "Comidas", + "city.sign_hospital": "Hospital", + "city.sign_school": "Escola", + "city.sign_cityhall": "Prefeitura", "city.exercise_need_equipment": "Aproxime-se de um equipamento da praça.", "city.exercise_need_awake": "Você precisa estar acordado para se exercitar.", "city.exercise_tired": "Você está cansado demais para se exercitar.", diff --git a/public/js/city/app.js b/public/js/city/app.js index cd6dff9..7c38a34 100755 --- a/public/js/city/app.js +++ b/public/js/city/app.js @@ -212,6 +212,10 @@ const agentMeshes = []; const crowdMeshes = []; const facadeMaterials = []; const lampHeads = []; +// Luminous signs (letreiros): materials get a day/night glow ramp in +// updateVehicleHeadlights; counts feed stats().signs. +const signMaterials = []; +let signCounts = { stores: 0, plazas: 0, beach: 0, airports: 0, buildings: 0, filler: 0 }; let grid = null; let crosswalkCount = 0; let sidewalkSegmentCount = 0; @@ -241,6 +245,8 @@ let exercise = { let relax = { active: false, timer: 0, animPhase: 0 }; let beach = null; let beachSystem = null; +// Airports payload {visit, sites} — geometry, walk zone and flight paths. +let airportsCfg = null; let remoteCitizens = new Map(); // characterId -> mesh let presenceTimer = null; let vehicleHeadlights = []; @@ -1810,6 +1816,7 @@ function buildHumanoid(agent, showLabel = true) { lifeguard: { skin: 0xe0ac69, hair: 0x1a1a1a, shirt: 0xd92b2b, pants: 0xf2e14c, scale: 1 }, surfer: { skin: 0xf1c27d, hair: 0x3b2314, shirt: 0x3fb6c9, pants: 0x1c1c1c, scale: 1 }, basket: { skin: 0xd2956b, hair: 0x111111, shirt: 0xe8802a, pants: 0xf2f2f2, scale: 1 }, + atc: { skin: 0xe0ac69, hair: 0x1a1a1a, shirt: 0x223366, pants: 0x0f1420, scale: 1.02 }, }; const cfgc = configs[type] || configs.pedestrian; const s = cfgc.scale; @@ -2148,6 +2155,192 @@ function makeStoreSign(store, isOpen) { return mesh; } +// ------------------------------------------------------------------ +// Luminous signs (letreiros): glowing boards for plazas, the beach, +// airports, buildings and stores. Emissive glow ramps up at night via +// updateVehicleHeadlights. +// ------------------------------------------------------------------ + +/** + * Shrink a font until the text fits the given canvas width. + * + * @returns {number} the final font size in px + */ +function fitSignText(ctx, text, maxWidth, startSize, weight = 'bold') { + let size = startSize; + ctx.font = `${weight} ${size}px sans-serif`; + while (ctx.measureText(text).width > maxWidth && size > 12) { + size -= 2; + ctx.font = `${weight} ${size}px sans-serif`; + } + return size; +} + +/** + * Board rotation.y so a front facing local +Z points toward (dx, dz). + */ +function rotationToward(dx, dz) { + if (Math.abs(dx) > Math.abs(dz)) { + return dx > 0 ? Math.PI / 2 : -Math.PI / 2; + } + return dz > 0 ? 0 : Math.PI; +} + +/** + * Rotation that faces the nearest road grid line (board front +Z). + */ +function faceNearestRoad(x, z, g) { + if (!g) return 0; + let bestD = Infinity; + let dir = [0, 1]; + for (const vx of g.xs) { + const d = Math.abs(x - vx); + if (d < bestD) { + bestD = d; + dir = [vx - x, 0]; + } + } + for (const vz of g.zs) { + const d = Math.abs(z - vz); + if (d < bestD) { + bestD = d; + dir = [0, vz - z]; + } + } + return rotationToward(dir[0], dir[1]); +} + +/** + * Wall offset pushing a roof/facade sign out of the building volume + * for a given facing rotation. + * + * @return {[number, number]} [ox, oz] + */ +function facingOffset(rot, halfW, halfD) { + if (Math.abs(rot - 0) < 0.01 || Math.abs(Math.abs(rot) - Math.PI) < 0.01) { + return [0, rot === 0 ? halfD : -halfD]; + } + return [rot > 0 ? halfW : -halfW, 0]; +} + +/** + * Build a luminous text sign (or a decorative neon strip when + * opts.decor is set — no text, no i18n needed). + * + * opts: {w, h, vertical, accent, bg, posts: 'double'|'arch', postH, + * decor, y} — returns the display node (plane or group); + * callers set .position / .rotation. + */ +function buildLitSign(text, opts = {}) { + const vertical = !!opts.vertical; + const decor = !!opts.decor; + const w = opts.w ?? (vertical ? 1.4 : 6); + const h = opts.h ?? (vertical ? 4.6 : 1.6); + const accent = opts.accent ?? '#38bdf8'; + const bg = opts.bg ?? '#0c1220'; + + const c = document.createElement('canvas'); + c.width = vertical ? 160 : 512; + c.height = vertical ? 640 : 128; + const ctx = c.getContext('2d'); + + // Backplate + accent border. + const grad = ctx.createLinearGradient(0, 0, c.width, c.height); + grad.addColorStop(0, bg); + grad.addColorStop(1, '#151c2c'); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, c.width, c.height); + ctx.strokeStyle = accent; + ctx.lineWidth = 8; + ctx.strokeRect(6, 6, c.width - 12, c.height - 12); + + if (decor) { + // Abstract glowing tubes — no letters, works in every locale. + ctx.strokeStyle = accent; + ctx.lineWidth = 14; + ctx.lineCap = 'round'; + ctx.shadowColor = accent; + ctx.shadowBlur = 22; + ctx.beginPath(); + const steps = 5; + for (let i = 0; i <= steps; i++) { + const px = 40 + (i * (c.width - 80)) / steps; + const py = c.height / 2 + ((i % 2 ? -1 : 1) * (c.height - 60)) / 3; + if (i === 0) ctx.moveTo(px, py); + else ctx.lineTo(px, py); + } + ctx.stroke(); + for (const px of [34, c.width - 34]) { + ctx.fillStyle = accent; + ctx.beginPath(); + ctx.arc(px, c.height / 2, 12, 0, Math.PI * 2); + ctx.fill(); + } + ctx.shadowBlur = 0; + } else { + ctx.fillStyle = '#f8fafc'; + ctx.textAlign = 'center'; + ctx.shadowColor = accent; + ctx.shadowBlur = 18; + if (vertical) { + // Stacked upright letters (classic blade sign). + const chars = [...text]; + const size = Math.min(84, Math.floor((c.height - 40) / Math.max(1, chars.length)) + 6); + ctx.font = `bold ${size}px sans-serif`; + ctx.textBaseline = 'middle'; + const step = (c.height - 30) / Math.max(1, chars.length); + chars.forEach((ch, i) => { + ctx.fillText(ch, c.width / 2, 15 + step * (i + 0.5)); + }); + } else { + fitSignText(ctx, text, c.width - 48, 56); + ctx.textBaseline = 'middle'; + ctx.fillText(text, c.width / 2, c.height / 2 + 2, c.width - 40); + } + ctx.shadowBlur = 0; + } + + const tex = new THREE.CanvasTexture(c); + const mat = new THREE.MeshStandardMaterial({ + map: tex, + emissiveMap: tex, + emissive: 0xffffff, + emissiveIntensity: 0.6, + roughness: 0.55, + side: THREE.DoubleSide, + }); + signMaterials.push({ + mat, + base: 0.5, + nightBoost: decor ? 1.5 : 1.2, + pulse: decor ? 0.16 : 0.08, + phase: Math.random() * Math.PI * 2, + }); + + const board = new THREE.Mesh(new THREE.PlaneGeometry(w, h), mat); + + if (!opts.posts) { + return board; + } + + const group = new THREE.Group(); + const postMat = new THREE.MeshStandardMaterial({ color: 0x3a4050, roughness: 0.6, metalness: 0.4 }); + const postH = opts.postH ?? 2.4; + const postXs = opts.posts === 'arch' + ? [-w / 2, w / 2] + : [-w * 0.32, w * 0.32]; + for (const px of postXs) { + const post = new THREE.Mesh(new THREE.CylinderGeometry(0.1, 0.13, postH, 8), postMat); + post.position.set(px, postH / 2, 0); + post.castShadow = true; + group.add(post); + } + board.position.y = postH + h / 2 - 0.05; + board.castShadow = true; + group.add(board); + return group; +} + function initShops(stores, agents) { const entries = []; for (const s of stores) { @@ -2309,64 +2502,862 @@ function initMayor(agents, stores, g) { // ------------------------------------------------------------------ // Airplane system // ------------------------------------------------------------------ -function buildAirplane(colorHex) { +// ------------------------------------------------------------------ +// Airports: airline liveries, aircraft models, ground build, operations +// ------------------------------------------------------------------ +const AIRLINE_LIVERIES = [ + { name: 'City10 Air', accent: 0x1f6fb2 }, + { name: 'AzulCéu', accent: 0x2fb6c9 }, + { name: 'Bandeirante', accent: 0x3f9d4f }, + { name: 'Sol Nascente', accent: 0xe08a1e }, + { name: 'Pampa Air', accent: 0xc0392b }, + { name: 'NorteLine', accent: 0x2c3e6b }, + { name: 'City10 Heli', accent: 0x7a5aa8 }, +]; + +/** Runway marking overlay: centerline, threshold keys, designators. */ +function runwayMarkings(designators) { + return canvasTexture(1024, (ctx, s) => { + ctx.clearRect(0, 0, s, s); + ctx.fillStyle = '#f2f2f2'; + // Centerline dashes along the length (u axis). + for (let x = 110; x < s - 110; x += 64) { + ctx.fillRect(x, s / 2 - 5, 36, 10); + } + // Threshold "piano keys": bars parallel to travel at both ends. + const slot = (s - 96) / 6; + for (const end of [0, 1]) { + const baseX = end ? s - 56 : 14; + for (let i = 0; i < 6; i++) { + ctx.fillRect(baseX, 48 + i * slot, 42, slot - 16); + } + // Designator near the threshold, reading along travel. + ctx.save(); + ctx.translate(end ? s - 130 : 130, s / 2); + ctx.rotate(-Math.PI / 2); + ctx.font = 'bold 72px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(end ? designators[1] : designators[0], 0, 0); + ctx.restore(); + } + }, 1, 1); +} + +/** + * Fixed-wing aircraft: airliner, regional jet or turboprop with the + * airline livery (accent tail/cone/engine inlets). Nose points +X. + * + * @param {'airliner'|'regional'|'prop'} kind + * @param {{name: string, accent: number}} livery + */ +function buildAircraft(kind, livery) { const g = new THREE.Group(); - const mat = new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.4, metalness: 0.5 }); - const fus = new THREE.Mesh(new THREE.CylinderGeometry(0.7, 0.7, 9, 10), mat); + const dims = { + airliner: { L: 16, r: 0.95, span: 15, chord: 2.6, tail: 3.6, fin: 2.8, engines: true }, + regional: { L: 12, r: 0.75, span: 11.6, chord: 2.1, tail: 2.8, fin: 2.2, engines: true }, + prop: { L: 9.5, r: 0.65, span: 12, chord: 1.9, tail: 2.4, fin: 1.9, engines: false, prop: true }, + }; + const d = dims[kind] || dims.airliner; + const bodyMat = new THREE.MeshStandardMaterial({ color: 0xf3f5f8, roughness: 0.35, metalness: 0.45 }); + const accentMat = new THREE.MeshStandardMaterial({ color: livery.accent, roughness: 0.4, metalness: 0.4 }); + const darkMat = new THREE.MeshStandardMaterial({ color: 0x1c2026, roughness: 0.55 }); + + const fus = new THREE.Mesh(new THREE.CylinderGeometry(d.r, d.r, d.L, 14), bodyMat); fus.rotation.z = Math.PI / 2; g.add(fus); - const nose = new THREE.Mesh(new THREE.ConeGeometry(0.7, 1.8, 10), mat); + + const noseLen = d.L * 0.14; + const nose = new THREE.Mesh(new THREE.ConeGeometry(d.r, noseLen, 14), bodyMat); nose.rotation.z = -Math.PI / 2; - nose.position.x = 5.3; + nose.position.x = d.L / 2 + noseLen / 2 - 0.05; g.add(nose); - const wing = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.15, 11), mat); - wing.position.x = 0.5; + + const tailLen = d.L * 0.13; + const tailCone = new THREE.Mesh(new THREE.ConeGeometry(d.r * 0.92, tailLen, 14), accentMat); + tailCone.rotation.z = Math.PI / 2; + tailCone.position.x = -d.L / 2 - tailLen / 2 + 0.05; + g.add(tailCone); + + const wing = new THREE.Mesh(new THREE.BoxGeometry(d.chord, 0.18, d.span), bodyMat); + wing.position.set(d.L * 0.04, -d.r * 0.3, 0); g.add(wing); - const tailW = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.12, 4), mat); - tailW.position.x = -4; - g.add(tailW); - const tailF = new THREE.Mesh(new THREE.BoxGeometry(1.4, 1.6, 0.12), mat); - tailF.position.set(-4.1, 0.8, 0); - g.add(tailF); + + const tailWing = new THREE.Mesh(new THREE.BoxGeometry(d.chord * 0.6, 0.14, d.span * 0.34), bodyMat); + tailWing.position.set(-d.L / 2 + d.tail * 0.45, d.r * 0.3, 0); + g.add(tailWing); + + const fin = new THREE.Mesh(new THREE.BoxGeometry(d.tail, d.fin, 0.16), accentMat); + fin.position.set(-d.L / 2 + d.tail * 0.5, d.fin / 2 + d.r * 0.5, 0); + g.add(fin); + + for (const s of [1, -1]) { + const win = new THREE.Mesh(new THREE.BoxGeometry(d.L * 0.5, 0.16, 0.05), darkMat); + win.position.set(d.L * 0.06, d.r * 0.35, s * (d.r - 0.02)); + g.add(win); + } + + if (d.engines) { + for (const s of [1, -1]) { + const eng = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 0.55, 2.6, 12), darkMat); + eng.rotation.z = Math.PI / 2; + eng.position.set(d.L * 0.02, -d.r * 0.3 - 0.5, s * d.span * 0.3); + g.add(eng); + const inlet = new THREE.Mesh(new THREE.CylinderGeometry(0.42, 0.42, 0.25, 12), accentMat); + inlet.rotation.z = Math.PI / 2; + inlet.position.set(d.L * 0.02 + 1.35, -d.r * 0.3 - 0.5, s * d.span * 0.3); + g.add(inlet); + } + } + + if (d.prop) { + const prop = new THREE.Group(); + for (let i = 0; i < 3; i++) { + const blade = new THREE.Mesh(new THREE.BoxGeometry(0.12, 3.4, 0.16), darkMat); + blade.rotation.x = (i * Math.PI * 2) / 3; + prop.add(blade); + } + prop.position.x = d.L / 2 + noseLen; + g.add(prop); + g.userData.prop = prop; + } + + const beacon = new THREE.Mesh( + new THREE.SphereGeometry(0.2, 8, 8), + new THREE.MeshStandardMaterial({ color: 0xff3333, emissive: 0xff3333, emissiveIntensity: 2 }) + ); + beacon.position.set(d.L * 0.08, d.r + 0.3, 0); + g.add(beacon); + g.userData.beacon = beacon; + g.userData.kind = kind; + g.traverse(o => { if (o.isMesh) o.castShadow = true; }); + return g; +} + +/** + * Helicopter with spinning main/tail rotors and skids (livery accent). + * + * @param {{name: string, accent: number}} livery + */ +function buildHelicopter(livery) { + const g = new THREE.Group(); + const bodyMat = new THREE.MeshStandardMaterial({ color: livery.accent, roughness: 0.4, metalness: 0.4 }); + const trimMat = new THREE.MeshStandardMaterial({ color: 0xf2f4f7, roughness: 0.4, metalness: 0.3 }); + const darkMat = new THREE.MeshStandardMaterial({ color: 0x1c2026, roughness: 0.5 }); + + const cabin = new THREE.Mesh(new THREE.CapsuleGeometry(0.95, 2.4, 6, 12), bodyMat); + cabin.rotation.z = Math.PI / 2; + g.add(cabin); + + const bubble = new THREE.Mesh(new THREE.SphereGeometry(0.85, 14, 12), trimMat); + bubble.position.set(1.9, 0.05, 0); + g.add(bubble); + + const boom = new THREE.Mesh(new THREE.BoxGeometry(4.6, 0.34, 0.34), bodyMat); + boom.position.set(-3.4, 0.35, 0); + g.add(boom); + + const fin = new THREE.Mesh(new THREE.BoxGeometry(0.9, 1.2, 0.14), bodyMat); + fin.position.set(-5.5, 0.95, 0); + g.add(fin); + + const tailRotor = new THREE.Group(); + for (let i = 0; i < 2; i++) { + const blade = new THREE.Mesh(new THREE.BoxGeometry(1.5, 0.14, 0.08), darkMat); + blade.rotation.z = (i * Math.PI) / 2; + tailRotor.add(blade); + } + tailRotor.position.set(-5.7, 0.95, 0.22); + g.add(tailRotor); + g.userData.tailRotor = tailRotor; + + for (const s of [1, -1]) { + const skid = new THREE.Mesh(new THREE.BoxGeometry(3.8, 0.1, 0.12), darkMat); + skid.position.set(0.2, -1.35, s * 0.85); + g.add(skid); + for (const bx of [-1, 1.2]) { + const strut = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.75, 0.1), darkMat); + strut.position.set(bx, -0.95, s * 0.85); + g.add(strut); + } + } + + const mastCol = new THREE.Mesh(new THREE.CylinderGeometry(0.16, 0.2, 0.8, 8), darkMat); + mastCol.position.y = 1.0; + g.add(mastCol); + + const rotor = new THREE.Group(); + for (let i = 0; i < 4; i++) { + const blade = new THREE.Mesh(new THREE.BoxGeometry(9, 0.08, 0.5), darkMat); + blade.rotation.y = (i * Math.PI) / 2; + rotor.add(blade); + } + const hub = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.3, 0.4, 10), trimMat); + rotor.add(hub); + rotor.position.y = 1.35; + g.add(rotor); + g.userData.rotor = rotor; + const beacon = new THREE.Mesh( new THREE.SphereGeometry(0.18, 8, 8), new THREE.MeshStandardMaterial({ color: 0xff3333, emissive: 0xff3333, emissiveIntensity: 2 }) ); - beacon.position.set(0.5, 0.5, 0); + beacon.position.set(0, 2.0, 0); g.add(beacon); g.userData.beacon = beacon; + g.userData.kind = 'helicopter'; + g.traverse(o => { if (o.isMesh) o.castShadow = true; }); return g; } -function initAirplanes() { - const planes = []; - const defs = [ - { r: 260, y: 85, speed: 0.12, a0: 0, color: 0xdfe4ea }, - { r: 330, y: 110, speed: -0.09, a0: 2, color: 0xc0c8d2 }, - { r: 200, y: 70, speed: 0.15, a0: 4, color: 0xe8eef5 }, - ]; - for (const d of defs) { - const mesh = buildAirplane(d.color); - scene.add(mesh); - planes.push({ mesh, ...d, angle: d.a0 }); +/** Helipad marking (white circle + H) on transparent ground. */ +function helipadTexture() { + return canvasTexture(256, (ctx, s) => { + ctx.clearRect(0, 0, s, s); + ctx.strokeStyle = '#f2f2f2'; + ctx.lineWidth = 14; + ctx.beginPath(); + ctx.arc(s / 2, s / 2, s / 2 - 20, 0, Math.PI * 2); + ctx.stroke(); + ctx.fillStyle = '#f2f2f2'; + ctx.font = 'bold 110px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('H', s / 2, s / 2 + 6); + }, 1, 1); +} + +/** + * Build both airports (pavement, markings, terminal, hangars, tower, + * stands, helipads) from the payload geometry. Adds colliders for the + * buildings so the player cannot walk through them. + * + * @param {{visit: object, sites: Array}} cfg + */ +function buildAirports(cfg) { + if (!cfg?.sites?.length) return; + + const asphaltMat = new THREE.MeshStandardMaterial({ color: 0x2b2d31, roughness: 0.92 }); + const taxiMat = new THREE.MeshStandardMaterial({ color: 0x34373c, roughness: 0.92 }); + const apronMat = new THREE.MeshStandardMaterial({ color: 0x50555c, roughness: 0.95 }); + const centerLineMat = new THREE.MeshStandardMaterial({ color: 0xd8b12a, roughness: 0.8 }); + const whiteLineMat = new THREE.MeshStandardMaterial({ color: 0xe8e8e8, roughness: 0.85 }); + const hangarBodyMat = new THREE.MeshStandardMaterial({ color: 0xd8dade, roughness: 0.7, metalness: 0.3 }); + const hangarDoorMat = new THREE.MeshStandardMaterial({ color: 0x4a5058, roughness: 0.6, metalness: 0.4 }); + const hangarRoofMat = new THREE.MeshStandardMaterial({ color: 0x8f969e, roughness: 0.6, metalness: 0.3 }); + const towerBaseMat = new THREE.MeshStandardMaterial({ color: 0xcfd3d8, roughness: 0.7 }); + const towerGlassMat = new THREE.MeshStandardMaterial({ color: 0x1d2a38, roughness: 0.25, metalness: 0.6 }); + const bridgeMat = new THREE.MeshStandardMaterial({ color: 0xb9bec6, roughness: 0.7 }); + const helipadMat = new THREE.MeshStandardMaterial({ + map: helipadTexture(), transparent: true, alphaTest: 0.3, roughness: 0.9, + }); + + const addFlat = (cx, cz, w, h, mat, y) => { + const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), mat); + m.rotation.x = -Math.PI / 2; + m.position.set(cx, y, cz); + m.receiveShadow = true; + worldGroup.add(m); + return m; + }; + + for (const site of cfg.sites) { + // Runway + markings. + const rw = site.runway; + const rwAlongX = rw.axis === 'x'; + addFlat(rw.center[0], rw.center[1], + rwAlongX ? rw.length : rw.width, rwAlongX ? rw.width : rw.length, asphaltMat, 0.03); + const marks = new THREE.Mesh( + new THREE.PlaneGeometry(rwAlongX ? rw.length : rw.width, rwAlongX ? rw.width : rw.length), + new THREE.MeshStandardMaterial({ + map: runwayMarkings(rw.designators), transparent: true, alphaTest: 0.3, roughness: 0.85, + }) + ); + marks.rotation.x = -Math.PI / 2; + marks.position.set(rw.center[0], 0.045, rw.center[1]); + worldGroup.add(marks); + + // Taxiways + yellow centerlines. + for (const tw of site.taxiways) { + const alongX = tw.axis === 'x'; + addFlat(tw.center[0], tw.center[1], + alongX ? tw.length : tw.width, alongX ? tw.width : tw.length, taxiMat, 0.03); + const line = new THREE.Mesh( + alongX ? new THREE.BoxGeometry(tw.length, 0.02, 0.16) : new THREE.BoxGeometry(0.16, 0.02, tw.length), + centerLineMat + ); + line.position.set(tw.center[0], 0.05, tw.center[1]); + worldGroup.add(line); + } + + // Apron + stand markings (aircraft parking positions). + addFlat(site.apron.center[0], site.apron.center[1], site.apron.size[0], site.apron.size[1], apronMat, 0.03); + for (const [sx, sz] of site.stands) { + const lead = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.02, 7), whiteLineMat); + lead.position.set(sx, 0.05, sz - 5); + worldGroup.add(lead); + const stopBar = new THREE.Mesh(new THREE.BoxGeometry(4.5, 0.02, 0.25), whiteLineMat); + stopBar.position.set(sx, 0.05, sz - 8.2); + worldGroup.add(stopBar); + } + + // Helipad. + const pad = site.helipad; + const padMesh = new THREE.Mesh(new THREE.PlaneGeometry(pad.radius * 2, pad.radius * 2), helipadMat); + padMesh.rotation.x = -Math.PI / 2; + padMesh.position.set(pad.center[0], 0.05, pad.center[1]); + worldGroup.add(padMesh); + + // Terminal building (multi-window facade + roof slab). + const term = site.terminal; + const termFloors = 3; + const termPair = facadePair('#a4553f', termFloors, Math.max(3, Math.round(term.size[0] / 7))); + const termMat = new THREE.MeshStandardMaterial({ + map: termPair.map, emissiveMap: termPair.emissiveMap, + emissive: 0xffd9a0, emissiveIntensity: 0, roughness: 0.85, + }); + facadeMaterials.push(termMat); + const termH = 9; + const termMesh = new THREE.Mesh(new THREE.BoxGeometry(term.size[0], termH, term.size[1]), termMat); + termMesh.position.set(term.center[0], termH / 2, term.center[1]); + termMesh.castShadow = true; + termMesh.receiveShadow = true; + worldGroup.add(termMesh); + addCollider(term.center[0], 0, term.center[1], term.size[0], termH, term.size[1]); + const termRoof = new THREE.Mesh( + new THREE.BoxGeometry(term.size[0] + 0.6, 0.5, term.size[1] + 0.6), + new THREE.MeshStandardMaterial({ color: 0x33373d, roughness: 0.9 }) + ); + termRoof.position.set(term.center[0], termH + 0.15, term.center[1]); + worldGroup.add(termRoof); + + // Jet bridges reaching from the terminal toward the apron. + const bridgeZ = term.center[1] + term.size[1] / 2 + 3.2; + for (let i = -1; i <= 1; i++) { + const bridge = new THREE.Mesh(new THREE.BoxGeometry(1.7, 1.5, 6.4), bridgeMat); + bridge.position.set(term.center[0] + i * 18, 3.0, bridgeZ); + bridge.castShadow = true; + worldGroup.add(bridge); + } + + // Hangars: tall box, wide door toward the apron, roof slab. + for (const hg of site.hangars) { + const [sx, sz] = hg.size; + const body = new THREE.Mesh(new THREE.BoxGeometry(sx, 7, sz), hangarBodyMat); + body.position.set(hg.center[0], 3.5, hg.center[1]); + body.castShadow = true; + body.receiveShadow = true; + worldGroup.add(body); + const door = new THREE.Mesh(new THREE.BoxGeometry(sx * 0.7, 5.2, 0.4), hangarDoorMat); + door.position.set(hg.center[0], 2.7, hg.center[1] + sz / 2 + 0.15); + worldGroup.add(door); + const roof = new THREE.Mesh(new THREE.BoxGeometry(sx + 0.6, 0.6, sz + 0.6), hangarRoofMat); + roof.position.set(hg.center[0], 7.2, hg.center[1]); + worldGroup.add(roof); + addCollider(hg.center[0], 0, hg.center[1], sx, 7, sz); + } + + // Control tower. + const tw2 = site.tower; + const base = new THREE.Mesh(new THREE.CylinderGeometry(1.7, 2.1, 10, 12), towerBaseMat); + base.position.set(tw2.center[0], 5, tw2.center[1]); + base.castShadow = true; + worldGroup.add(base); + const cabin = new THREE.Mesh(new THREE.BoxGeometry(4.6, 2.6, 4.6), towerGlassMat); + cabin.position.set(tw2.center[0], 11.3, tw2.center[1]); + cabin.castShadow = true; + worldGroup.add(cabin); + const cap = new THREE.Mesh(new THREE.BoxGeometry(5.2, 0.35, 5.2), towerBaseMat); + cap.position.set(tw2.center[0], 12.8, tw2.center[1]); + worldGroup.add(cap); + const antenna = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.09, 2.6, 6), towerBaseMat); + antenna.position.set(tw2.center[0], 14.2, tw2.center[1]); + worldGroup.add(antenna); + const towerBeacon = new THREE.Mesh( + new THREE.SphereGeometry(0.22, 8, 8), + new THREE.MeshStandardMaterial({ color: 0xff3333, emissive: 0xff3333, emissiveIntensity: 2 }) + ); + towerBeacon.position.set(tw2.center[0], 15.6, tw2.center[1]); + worldGroup.add(towerBeacon); + addCollider(tw2.center[0], 0, tw2.center[1], 4.6, 12, 4.6); + + // Luminous letreiros: airport name on the terminal roofline + // (facing the apron) + entrance pylon on the city side. + const toApron = [ + site.apron.center[0] - term.center[0], + site.apron.center[1] - term.center[1], + ]; + const termFace = rotationToward(toApron[0], toApron[1]); + const [tOffX, tOffZ] = facingOffset(termFace, term.size[0] / 2 + 0.3, term.size[1] / 2 + 0.3); + const termSign = buildLitSign(site.name, { + w: Math.min(15, term.size[0] * 0.8), + h: 1.8, + accent: '#7dd3fc', + }); + termSign.position.set(term.center[0] + tOffX, termH + 1.4, term.center[1] + tOffZ); + termSign.rotation.y = termFace; + worldGroup.add(termSign); + signCounts.airports++; + + const cityDir = Math.sign(site.apron.center[1]) >= 0 ? -1 : 1; + const apronEdgeZ = site.apron.center[1] + cityDir * site.apron.size[1] / 2; + const entry = buildLitSign(site.name, { + w: 8.5, + h: 1.9, + posts: 'double', + postH: 3.0, + accent: '#a78bfa', + }); + entry.position.set(tw2.center[0] + 6, 0, apronEdgeZ + cityDir * 8); + entry.rotation.y = rotationToward(0, cityDir); + worldGroup.add(entry); + signCounts.airports++; } +} + +/** Linear interpolation between 3D points. */ +function lerp3(a, b, t) { + return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; +} + +/** Distance between two 3D points. */ +function dist3(a, b) { + return Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]); +} + +/** + * Flight operations: taxi, takeoff roll, climb, cruise circuit, approach, + * landing and taxi-in for both airports, plus vertical helipad cycles. + * + * Aircraft states: park → taxi_out → roll → climb → cruise → approach → + * land → taxi_in → park (helicopters: park → takeoff → cruise → land). + */ +function initAirplanes(cfg) { + const aircraft = []; + const flights = { takeoffs: 0, landings: 0 }; + const sites = cfg?.sites || []; + const emptyFlights = () => ({ + total: 0, parked: 0, taxiing: 0, airborne: 0, takeoffs: 0, landings: 0, + }); + + if (!sites.length) { + airplaneSystem = { + planes: [], + count: () => 0, + flights: emptyFlights, + update() {}, + }; + return; + } + + // Flight paths bind to the runway orientation, not to the labels: + // the east-west runway site uses the east-west ops, the other uses + // the north-south ops (both derive every coordinate from the site). + const ewSite = sites.find(s => s.runway?.axis === 'x') || sites[0]; + const nsSite = sites.find(s => s.runway?.axis === 'z') || sites[1] || sites[0]; + const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); + const V = (x, y, z) => [x, y, z]; + + // ---- Per-airport flight paths (shared, stand-specific starts) ---- + function ewOps(stand) { + const rz = ewSite.runway.center[1]; + const tz = ewSite.taxiways[0].center[1]; + const j = clamp(stand[0] - 40, -80, 80); + return { + heli: false, + taxiOut: [V(stand[0], 0, stand[1]), V(j, 0, 310), V(-80, 0, tz), V(-80, 0, rz)], + roll: { from: V(-80, 0, rz), to: V(85, 0, rz) }, + climb: { from: V(85, 0, rz), to: V(175, 40, rz) }, + cruise: [ + V(300, 85, 60), V(40, 85, -150), V(-280, 80, -90), V(-300, 80, 240), + V(-140, 75, 400), V(60, 70, 400), V(240, 65, rz), + ], + approach: [V(140, 15, rz), V(85, 0, rz)], + landRoll: { from: V(85, 0, rz), to: V(-80, 0, rz) }, + taxiIn: [V(-80, 0, tz), V(j, 0, 310), V(stand[0], 0, stand[1])], + }; + } + + function nsOps(stand) { + const rx = nsSite.runway.center[0]; + const tx = nsSite.taxiways[0].center[0]; + return { + heli: false, + taxiOut: [V(stand[0], 0, stand[1]), V(tx, 0, stand[1]), V(tx, 0, -240), V(rx, 0, -240)], + roll: { from: V(rx, 0, -240), to: V(rx, 0, -370) }, + climb: { from: V(rx, 0, -370), to: V(rx, 40, -460) }, + cruise: [ + V(-280, 85, -420), V(-340, 85, -40), V(-60, 85, 240), V(240, 80, 200), + V(320, 80, -160), V(200, 75, -360), V(rx, 65, -60), + ], + approach: [V(rx, 20, -140), V(rx, 0, -220)], + landRoll: { from: V(rx, 0, -220), to: V(rx, 0, -370) }, + taxiIn: [V(-40, 0, -370), V(tx, 0, -370), V(tx, 0, clamp(stand[1] + 40, -365, -235)), V(stand[0], 0, stand[1])], + }; + } + + function heliOps(site) { + const pad = site.helipad.center; + const far = Math.sign(site.runway.center[1] || 1) >= 0 ? [40, 430] : [-320, -430]; + return { + heli: true, + pad: [pad[0], 0, pad[1]], + cruise: [ + V(far[0], 60, far[1]), + V(-far[0] * 0.8, 58, far[1] * 0.6), + V(-far[0], 60, -far[1] * 0.4), + V(pad[0], 60, pad[1] - 40), + V(pad[0], 60, pad[1]), + ], + }; + } + + // ---- State machine helpers ---- + const poseForLegs = (a, legIdx, t) => { + const start = legIdx === 0 ? a.stateFrom : a.legs[legIdx - 1]; + return lerp3(start, a.legs[legIdx], t); + }; + + function legsFor(a, state) { + const ops = a.ops; + if (a.ops.heli) { + return state === 'cruise' ? ops.cruise : null; + } + return { + taxi_out: ops.taxiOut, + cruise: ops.cruise, + approach: ops.approach, + taxi_in: ops.taxiIn, + }[state] || null; + } + + function enterState(a, state) { + a.state = state; + a.stateFrom = [a.p[0], a.p[1], a.p[2]]; + a.legs = legsFor(a, state); + a.leg = 0; + a.t = 0; + a.done = false; + a.speed = state === 'taxi_out' || state === 'taxi_in' ? 6 + : state === 'cruise' ? 55 + : state === 'approach' ? 24 + : state === 'roll' ? 3 : a.speed; + a.dist = 0; + + if (state === 'roll' || state === 'takeoff') { + flights.takeoffs++; + // Ask the AI control tower for a clearance (fire and forget). + postJson('/city/airport/dispatch', {}).catch(() => { /* throttled */ }); + } else if (state === 'land') { + flights.landings++; + } else if (state === 'park') { + a.wait = 8 + Math.random() * 6; + } + + // Ground taxi and fixed-heading states get an immediate yaw. + if (state === 'takeoff') { + a.yaw = a.yaw ?? 0; + } else if (!a.heli && (state === 'roll' || state === 'land')) { + const seg = state === 'roll' ? a.ops.roll : a.ops.landRoll; + a.yaw = Math.atan2(-(seg.to[2] - seg.from[2]), seg.to[0] - seg.from[0]); + } + } + + /** Place an aircraft exactly at a mid-flight spawn state. */ + function poseSpawn(a, state, leg, t) { + const ops = a.ops; + a.state = state; + a.legs = legsFor(a, state); + a.leg = leg || 0; + a.t = t || 0; + a.dist = 0; + a.speed = 6; + + if (state === 'park') { + a.p = ops.heli ? [...ops.pad] : [...a.stand3]; + a.wait = a.spawnWait; + a.yaw = 0; + } else if (state === 'taxi_out') { + a.stateFrom = [...a.stand3]; + a.p = poseForLegs(a, a.leg, a.t); + a.yaw = Math.atan2(-(a.legs[0][2] - a.stand3[2]), a.legs[0][0] - a.stand3[0]); + } else if (state === 'roll') { + a.stateFrom = [...ops.roll.from]; + a.p = lerp3(ops.roll.from, ops.roll.to, a.t); + a.yaw = Math.atan2(-(ops.roll.to[2] - ops.roll.from[2]), ops.roll.to[0] - ops.roll.from[0]); + a.speed = 12; + } else if (state === 'climb') { + a.stateFrom = [...ops.climb.from]; + a.p = lerp3(ops.climb.from, ops.climb.to, a.t); + a.yaw = Math.atan2(-(ops.climb.to[2] - ops.climb.from[2]), ops.climb.to[0] - ops.climb.from[0]); + } else if (state === 'cruise') { + a.stateFrom = ops.heli ? [ops.pad[0], 45, ops.pad[2]] : [...ops.climb.to]; + a.p = poseForLegs(a, a.leg, a.t); + const from = a.leg === 0 ? a.stateFrom : a.legs[a.leg - 1]; + a.yaw = Math.atan2(-(a.legs[a.leg][2] - from[2]), a.legs[a.leg][0] - from[0]); + a.speed = ops.heli ? 32 : 55; + } else if (state === 'approach') { + a.stateFrom = [...ops.cruise[ops.cruise.length - 1]]; + a.p = poseForLegs(a, a.leg, a.t); + a.yaw = Math.atan2(-(ops.approach[0][2] - a.stateFrom[2]), ops.approach[0][0] - a.stateFrom[0]); + a.speed = 24; + } else if (state === 'land') { + a.stateFrom = [...ops.approach[ops.approach.length - 1]]; + a.p = lerp3(ops.landRoll.from, ops.landRoll.to, a.t); + a.yaw = Math.atan2(-(ops.landRoll.to[2] - ops.landRoll.from[2]), ops.landRoll.to[0] - ops.landRoll.from[0]); + a.speed = 14; + } else if (state === 'taxi_in') { + a.stateFrom = [...ops.landRoll.to]; + a.p = poseForLegs(a, a.leg, a.t); + a.yaw = Math.atan2(-(a.legs[0][2] - a.stateFrom[2]), a.legs[0][0] - a.stateFrom[0]); + } + } + + /** Advance along the current leg targets; returns true when finished. */ + function stepLegs(a, speed, dt) { + if (!a.legs || a.leg >= a.legs.length) return true; + let guard = 0; + while (guard++ < 4) { + const target = a.legs[a.leg]; + const dx = target[0] - a.p[0]; + const dy = target[1] - a.p[1]; + const dz = target[2] - a.p[2]; + const len = Math.hypot(dx, dy, dz); + if (len < 1e-3) { + a.leg++; + if (a.leg >= a.legs.length) return true; + continue; + } + const step = speed * dt; + if (step >= len) { + a.p = [...target]; + a.leg++; + if (a.leg >= a.legs.length) return true; + continue; + } + a.p = [a.p[0] + (dx / len) * step, a.p[1] + (dy / len) * step, a.p[2] + (dz / len) * step]; + a.yaw = Math.atan2(-dz, dx); + return false; + } + return true; + } + + // ---- Fleet ---- + function spawnFixed(site, opsFn, stand, kind, airlineIdx, state, leg, t, wait) { + const livery = AIRLINE_LIVERIES[airlineIdx % AIRLINE_LIVERIES.length]; + const mesh = kind === 'helicopter' ? buildHelicopter(livery) : buildAircraft(kind, livery); + worldGroup.add(mesh); + const a = { + mesh, + kind, + livery, + callsign: 'C10-'.concat(101 + aircraft.length), + ops: opsFn(stand), + stand3: [stand[0], 0, stand[1]], + p: [stand[0], 0, stand[1]], + yaw: 0, + state: 'park', + legs: null, + leg: 0, + t: 0, + speed: 6, + dist: 0, + wait: wait ?? 6, + spawnWait: wait ?? 6, + heli: false, + }; + poseSpawn(a, state, leg, t); + aircraft.push(a); + return a; + } + + function spawnHeli(site, airlineIdx, state, wait) { + const ops = heliOps(site); + const livery = AIRLINE_LIVERIES[airlineIdx % AIRLINE_LIVERIES.length]; + const mesh = buildHelicopter(livery); + worldGroup.add(mesh); + const a = { + mesh, + kind: 'helicopter', + livery, + callsign: 'C10-'.concat(101 + aircraft.length), + ops, + stand3: [...ops.pad], + p: [...ops.pad], + yaw: 0, + state: 'park', + legs: null, + leg: 0, + t: 0, + speed: 6, + dist: 0, + wait: wait ?? 10, + spawnWait: wait ?? 10, + heli: true, + }; + poseSpawn(a, state, 0, 0); + aircraft.push(a); + return a; + } + + // Staggered initial states so operations are already in motion. + spawnFixed(ewSite, ewOps, ewSite.stands[0], 'airliner', 0, 'park', 0, 0, 7); + spawnFixed(ewSite, ewOps, ewSite.stands[1], 'airliner', 1, 'taxi_out', 1, 0.25, 0); + spawnFixed(ewSite, ewOps, ewSite.stands[2], 'regional', 2, 'cruise', 2, 0.4, 0); + spawnHeli(ewSite, 3, 'park', 14); + spawnFixed(nsSite, nsOps, nsSite.stands[0], 'regional', 4, 'approach', 0, 0.5, 0); + spawnFixed(nsSite, nsOps, nsSite.stands[1], 'prop', 5, 'park', 0, 0, 20); + spawnHeli(nsSite, 6, 'park', 12); + + let atcPoll = 6; + let clearanceShown = null; + airplaneSystem = { - planes, - count() { return planes.length; }, + planes: aircraft, + count: () => aircraft.length, + flights: () => { + let parked = 0; + let taxiing = 0; + let airborne = 0; + for (const a of aircraft) { + if (a.state === 'park') parked++; + else if (a.state === 'taxi_out' || a.state === 'taxi_in') taxiing++; + else if (a.state === 'climb' || a.state === 'cruise' || a.state === 'approach') airborne++; + } + return { + total: aircraft.length, + parked, + taxiing, + airborne, + takeoffs: flights.takeoffs, + landings: flights.landings, + states: aircraft.map(a => a.callsign + ':' + a.state), + }; + }, update(dt) { - for (const p of planes) { - p.angle += p.speed * dt; - const x = Math.cos(p.angle) * p.r; - const z = Math.sin(p.angle) * p.r; - p.mesh.position.set(x, p.y, z); - const tx = -Math.sin(p.angle) * Math.sign(p.speed); - const tz = Math.cos(p.angle) * Math.sign(p.speed); - p.mesh.rotation.y = Math.atan2(-tz, tx); - p.mesh.rotation.z = -0.3 * Math.sign(p.speed); - if (p.mesh.userData.beacon) { - p.mesh.userData.beacon.material.emissiveIntensity = - (Math.sin(performance.now() * 0.01) > 0) ? 2.5 : 0.2; + const time = performance.now() / 1000; + + for (const a of aircraft) { + switch (a.state) { + case 'park': { + a.wait -= dt; + if (a.wait <= 0) enterState(a, a.heli ? 'takeoff' : 'taxi_out'); + break; } + case 'taxi_out': + if (stepLegs(a, 6, dt)) enterState(a, 'roll'); + break; + case 'roll': { + a.speed = Math.min(18, a.speed + 5.5 * dt); + const len = dist3(a.ops.roll.from, a.ops.roll.to); + a.dist += a.speed * dt; + const s = Math.min(1, a.dist / len); + a.p = lerp3(a.ops.roll.from, a.ops.roll.to, s); + if (s >= 1) enterState(a, 'climb'); + break; + } + case 'climb': { + const len = dist3(a.ops.climb.from, a.ops.climb.to); + a.t += (16 * dt) / len; + if (a.t >= 1) { + a.p = [...a.ops.climb.to]; + enterState(a, 'cruise'); + } else { + a.p = lerp3(a.ops.climb.from, a.ops.climb.to, a.t); + } + break; + } + case 'cruise': + if (stepLegs(a, a.ops.heli ? 32 : 55, dt)) { + enterState(a, a.ops.heli ? 'land' : 'approach'); + } + break; + case 'approach': + if (stepLegs(a, 24, dt)) enterState(a, 'land'); + break; + case 'land': { + // Helicopter landing (vertical descent) is handled below. + if (a.heli) break; + a.speed = Math.max(4, a.speed - 3.2 * dt); + const len = dist3(a.ops.landRoll.from, a.ops.landRoll.to); + a.dist += a.speed * dt; + const s = Math.min(1, a.dist / len); + a.p = lerp3(a.ops.landRoll.from, a.ops.landRoll.to, s); + if (s >= 1) enterState(a, 'taxi_in'); + break; + } + case 'taxi_in': + if (stepLegs(a, 6, dt)) { + enterState(a, 'park'); + a.wait = 8 + Math.random() * 6; + } + break; + case 'takeoff': { + // Vertical departure from the helipad. + a.t += dt / 5; + if (a.t >= 1) { + a.p = [a.ops.pad[0], 45, a.ops.pad[2]]; + enterState(a, 'cruise'); + } else { + a.p = [a.ops.pad[0], 45 * a.t, a.ops.pad[2]]; + } + break; + } + default: break; + } + + // Helicopter landing: descend back onto the pad. + if (a.state === 'land' && a.heli) { + a.p = [a.ops.pad[0], Math.max(0, a.p[1] - 9 * dt), a.ops.pad[2]]; + if (a.p[1] <= 0.01) { + a.p = [...a.ops.pad]; + enterState(a, 'park'); + a.wait = 8 + Math.random() * 6; + } + } + + // Pose the mesh. + a.mesh.position.set(a.p[0], a.p[1], a.p[2]); + a.mesh.rotation.y = a.yaw || 0; + let pitch = 0; + if (a.state === 'climb') pitch = 0.16; + else if (a.state === 'approach') pitch = -0.1; + else if (a.state === 'land' && !a.heli) pitch = 0.06; + a.mesh.rotation.z = a.heli ? 0 : pitch; + + if (a.mesh.userData.beacon) { + a.mesh.userData.beacon.material.emissiveIntensity = + (Math.sin(time * 6) > 0) ? 2.5 : 0.2; + } + const spinning = a.state !== 'park'; + if (a.mesh.userData.rotor) { + a.mesh.userData.rotor.rotation.y += (spinning ? 26 : 5) * dt; + } + if (a.mesh.userData.tailRotor) { + a.mesh.userData.tailRotor.rotation.z += (spinning ? 30 : 4) * dt; + } + if (a.mesh.userData.prop) { + a.mesh.userData.prop.rotation.x += (spinning ? 28 : 0) * dt; + } + } + + // Poll the AI tower clearance and flash new authorizations. + atcPoll -= dt; + if (atcPoll <= 0) { + atcPoll = 10; + fetch('/city/airport', { + headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, + cache: 'no-store', + }) + .then(r => (r.ok ? r.json() : null)) + .then((data) => { + const c = data?.clearance; + if (c?.at && c.at !== clearanceShown) { + clearanceShown = c.at; + flashCharMsg('✈ '.concat(c.text)); + } + }) + .catch(() => { /* offline-safe */ }); } }, }; @@ -2763,6 +3754,25 @@ function initPlazas(list) { water.position.set(px, 0.42, pz); worldGroup.add(water); + // Luminous plaza letreiro on the perimeter, facing the nearest road. + const plazaFace = faceNearestRoad(px, pz, grid); + const [pOffX, pOffZ] = facingOffset(plazaFace, pw / 2 + 1.4, pd / 2 + 1.4); + const plazaNum = (p.id || '').split('-')[1] || ''; + const plazaLabel = plazaNum + ? `${i18n.signPlaza || 'Praça'} ${plazaNum}` + : (i18n.signPlaza || 'Praça'); + const plazaSign = buildLitSign(plazaLabel, { + w: 5.4, + h: 1.5, + posts: 'double', + postH: 2.2, + accent: '#facc15', + }); + plazaSign.position.set(px + pOffX, 0, pz + pOffZ); + plazaSign.rotation.y = plazaFace; + worldGroup.add(plazaSign); + signCounts.plazas++; + for (const eq of p.equipment || []) { buildGymEquipment(eq.kind, eq.position[0], eq.position[1]); } @@ -2838,6 +3848,31 @@ function initBeach(cfg) { foam.position.set(sandTo + 1.2, 0.1, midZ); group.add(foam); + // Luminous letreiros: entrance arch over the road end (faces the + // city/west) and a food board between the stalls. + const entrance = buildLitSign(i18n.signBeach || 'Praia', { + w: 9, + h: 1.8, + posts: 'arch', + postH: 3.6, + accent: '#22d3ee', + }); + entrance.position.set(sandFrom - 2, 0, 0); + entrance.rotation.y = -Math.PI / 2; // face the city (west) + group.add(entrance); + + const foodSign = buildLitSign(i18n.signFood || 'Comidas', { + w: 4.4, + h: 1.2, + posts: 'double', + postH: 2.0, + accent: '#fb923c', + }); + foodSign.position.set(sandFrom + 4, 0, 59); + foodSign.rotation.y = -Math.PI / 2; + group.add(foodSign); + signCounts.beach += 2; + // ---- Basketball court: painted slab + two hoops ---- const courtTex = canvasTexture(512, (ctx, s) => { ctx.fillStyle = '#c9a06a'; @@ -3433,6 +4468,8 @@ function buildCity(data) { crowdMeshes.length = 0; facadeMaterials.length = 0; lampHeads.length = 0; + signMaterials.length = 0; + signCounts = { stores: 0, plazas: 0, beach: 0, airports: 0, buildings: 0, filler: 0 }; trafficSystem = null; vehicleSystem = null; pedestrianSystem = null; @@ -3451,6 +4488,7 @@ function buildCity(data) { relax = { active: false, timer: 0, animPhase: 0 }; beach = null; beachSystem = null; + airportsCfg = null; const layout = data.layout || { roads: [], blocks: [] }; const stores = data.stores || []; @@ -3753,6 +4791,22 @@ function buildCity(data) { storeMeshes.push(mesh); addCollider(x, y, z, w, h, d); + // Vertical neon blade letreiro over the roofline, facing the + // nearest street (the front OPEN/CLOSED board stays as is). + const bladeFace = faceNearestRoad(x, z, grid); + const [bOffX, bOffZ] = facingOffset(bladeFace, w / 2 + 0.25, d / 2 + 0.25); + const bladeH = Math.min(4.6, h * 0.7 + 1.4); + const blade = buildLitSign(s.name, { + vertical: true, + w: 1.5, + h: bladeH, + accent: ['#38bdf8', '#f472b6', '#facc15', '#4ade80'][idx % 4], + }); + blade.position.set(x + bOffX, y + h + bladeH / 2 - 0.6, z + bOffZ); + blade.rotation.y = bladeFace; + worldGroup.add(blade); + signCounts.stores++; + const roof = new THREE.Mesh( new THREE.BoxGeometry(w + 0.4, 0.35, d + 0.4), new THREE.MeshStandardMaterial({ color: 0x1e1e1e, roughness: 0.9 }) @@ -3781,6 +4835,10 @@ function buildCity(data) { beach = data.beach || null; beachSystem = beach ? initBeach(beach) : null; + // ---- Airports (north/south) + flight operations ---- + airportsCfg = data.airports || null; + buildAirports(airportsCfg); + // ---- Filler buildings ---- // Fillers only exist on empty ground: skip cells near stores AND near // any placed building (hospital, school, city hall, player houses) so @@ -3829,6 +4887,24 @@ function buildCity(data) { roof.position.set(cx, bh + 0.15, cz); roof.updateMatrix(); roofBatch.add(roof.geometry, fillerRoofMat, roof.matrix); + + // Decorative neon letreiro on every third block (abstract glow, + // no text — needs no translation). + if (bi % 3 === 0) { + const neonAccent = ['#22d3ee', '#f472b6', '#a3e635', '#fb923c'][(Math.floor(bi / 3)) % 4]; + const neonFace = faceNearestRoad(cx, cz, grid); + const [nOffX, nOffZ] = facingOffset(neonFace, bw / 2 + 0.3, bd / 2 + 0.3); + const neon = buildLitSign('', { + w: 4.8, + h: 1.1, + decor: true, + accent: neonAccent, + }); + neon.position.set(cx + nOffX, bh * 0.72, cz + nOffZ); + neon.rotation.y = neonFace; + worldGroup.add(neon); + signCounts.filler++; + } }); roofBatch.flush(worldGroup); @@ -3878,6 +4954,27 @@ function buildCity(data) { v.position.set(bx, bh * 0.55, bz + bd / 2 + 0.15); worldGroup.add(v); } + + // Luminous roofline letreiro for the landmark buildings. + const landmarkLabels = { + hospital: i18n.signHospital || 'Hospital', + school: i18n.signSchool || 'Escola', + city_hall: i18n.signCityhall || 'Prefeitura', + }; + if (b.is_complete && landmarkLabels[b.type]) { + const lFace = faceNearestRoad(bx, bz, grid); + const [lOffX, lOffZ] = facingOffset(lFace, bw / 2 + 0.35, bd / 2 + 0.35); + const landmarkSign = buildLitSign(landmarkLabels[b.type], { + w: Math.min(11, Math.max(bw, bd) * 0.8), + h: 1.6, + accent: b.type === 'hospital' ? '#f87171' + : (b.type === 'school' ? '#4ade80' : '#7dd3fc'), + }); + landmarkSign.position.set(bx + lOffX, bh + 1.35, bz + lOffZ); + landmarkSign.rotation.y = lFace; + worldGroup.add(landmarkSign); + signCounts.buildings++; + } } } @@ -3909,8 +5006,7 @@ function buildCity(data) { // ---- Simulation systems ---- initVehicles(grid); - if (!airplaneSystem) initAirplanes(); - else for (const p of airplaneSystem.planes) scene.add(p.mesh); + initAirplanes(airportsCfg); initTrain(grid); statsEl.textContent = buildStatsText(); @@ -4332,9 +5428,15 @@ function updatePlayer(dt) { player.pos.x = cityBounds ? Math.max(cityBounds.min_x, Math.min(maxX, player.pos.x)) : Math.max(-400, Math.min(maxX, player.pos.x)); - player.pos.z = cityBounds - ? Math.max(cityBounds.min_z, Math.min(cityBounds.max_z, player.pos.z)) - : Math.max(-400, Math.min(400, player.pos.z)); + // Z bounds: the airport strips (north/south) are walkable beyond the + // city while the player stays inside their X band. + let minZ = cityBounds ? cityBounds.min_z : -400; + let maxZ = cityBounds ? cityBounds.max_z : 400; + if (airportsCfg?.visit && Math.abs(player.pos.x) <= airportsCfg.visit.half_width) { + minZ = airportsCfg.visit.min_z; + maxZ = airportsCfg.visit.max_z; + } + player.pos.z = Math.max(minZ, Math.min(maxZ, player.pos.z)); } function updateCameraFromPlayer() { @@ -5206,6 +6308,12 @@ function updateVehicleHeadlights() { for (const m of vehicleHeadlights) m.emissiveIntensity = head; for (const m of vehicleTailLights) m.emissiveIntensity = tail; for (const m of vehicleBeams) m.opacity = beam; + // Luminous letreiros: visible glow by day, brighter with a subtle + // shimmer once night falls. + const t = performance.now() * 0.0025; + for (const s of signMaterials) { + s.mat.emissiveIntensity = s.base + night * s.nightBoost + Math.sin(t + s.phase) * s.pulse; + } } // ------------------------------------------------------------------ @@ -5530,6 +6638,19 @@ window.__city10 = { crosswalks: crosswalkCount, sidewalkSegments: sidewalkSegmentCount, beach: beachSystem ? beachSystem.counts() : null, + flights: airplaneSystem ? airplaneSystem.flights() : null, + signs: { + stores: signCounts.stores, + plazas: signCounts.plazas, + beach: signCounts.beach, + airports: signCounts.airports, + buildings: signCounts.buildings, + filler: signCounts.filler, + lit: signMaterials.length, + avgIntensity: signMaterials.length + ? +(signMaterials.reduce((sum, s) => sum + s.mat.emissiveIntensity, 0) / signMaterials.length).toFixed(3) + : 0, + }, remoteIds: [...remoteCitizens.keys()], render: { calls: renderer.info.render.calls, diff --git a/resources/views/city/viewer.blade.php b/resources/views/city/viewer.blade.php index d30452a..26d639c 100755 --- a/resources/views/city/viewer.blade.php +++ b/resources/views/city/viewer.blade.php @@ -368,6 +368,12 @@ beachStart: @json(__('city.beach_start')), beachPrompt: @json(__('city.beach_prompt')), beachActive: @json(__('city.beach_active')), + signPlaza: @json(__('city.sign_plaza')), + signBeach: @json(__('city.sign_beach')), + signFood: @json(__('city.sign_food')), + signHospital: @json(__('city.sign_hospital')), + signSchool: @json(__('city.sign_school')), + signCityhall: @json(__('city.sign_cityhall')), exerciseStopHint: @json(__('city.exercise_stopHint')), exerciseStart: @json(__('city.exerciseStart')), exerciseStop: @json(__('city.exerciseStop')), diff --git a/routes/web.php b/routes/web.php index ee5d87d..d4dfab7 100755 --- a/routes/web.php +++ b/routes/web.php @@ -129,6 +129,14 @@ Route::middleware('auth')->group(function (): void { Route::post('/talk/person', [PersonTalkController::class, 'send']) ->middleware('throttle:30,1') ->name('talk.person.send'); + + // Airport ops: site info + ATC clearances (AI_ATC_MODEL). + Route::get('/city/airport', [CityViewerController::class, 'airport']) + ->middleware('throttle:60,1') + ->name('city.airport'); + Route::post('/city/airport/dispatch', [CityViewerController::class, 'dispatch']) + ->middleware('throttle:60,1') + ->name('city.airport.dispatch'); }); }); diff --git a/tests/Feature/AirportTest.php b/tests/Feature/AirportTest.php new file mode 100644 index 0000000..6fee5d5 --- /dev/null +++ b/tests/Feature/AirportTest.php @@ -0,0 +1,234 @@ +seed(CityWorldSeeder::class); + $this->user = User::factory()->create(['status' => User::STATUS_ACTIVE, 'is_active' => true]); + $this->character = Character::ensureForUser($this->user); + $this->actingAs($this->user); + } + + /** + * The world payload carries both airports with their core geometry. + */ + public function test_city_data_includes_two_airports(): void + { + $payload = $this->getJson('/city/data')->assertOk()->json(); + + $this->assertArrayHasKey('airports', $payload); + $sites = $payload['airports']['sites']; + $this->assertCount(2, $sites); + // Sites follow the compass: +Z is south (noon sun points +Z). + $this->assertSame(['sul', 'norte'], array_column($sites, 'id')); + $this->assertSame('Aeroporto City10 Sul', $sites[0]['name']); + $this->assertSame('Aeroporto Regional do Norte', $sites[1]['name']); + $this->assertSame('x', $sites[0]['runway']['axis']); + $this->assertSame('z', $sites[1]['runway']['axis']); + + foreach ($sites as $site) { + $this->assertNotEmpty($site['runway']); + $this->assertNotEmpty($site['taxiways']); + $this->assertNotEmpty($site['apron']); + $this->assertNotEmpty($site['terminal']); + $this->assertNotEmpty($site['hangars']); + $this->assertNotEmpty($site['stands']); + $this->assertNotEmpty($site['helipad']); + $this->assertNotEmpty($site['tower']); + } + + $this->assertEquals(Airports::VISIT_HALF_WIDTH, $payload['airports']['visit']['half_width']); + $this->assertEquals(Airports::VISIT_MAX_Z, $payload['airports']['visit']['max_z']); + } + + /** + * The control tower agent exists with the configured ATC model and + * stands at the north tower. + */ + public function test_control_tower_agent_uses_atc_model(): void + { + $atc = AiAgent::query()->where('type', 'atc')->firstOrFail(); + + $this->assertSame('Torre de Controle City10', $atc->name); + $this->assertSame(config('ai.atc_model'), $atc->model); + $this->assertSame('poolside/laguna-xs-2.1:free', $atc->model); + $this->assertContains($atc->model, config('ai.allowed_models')); + + // Tower of the north site: layoutMaxZ(160) + 118 = 278, x = 10. + $this->assertEquals(10.0, (float) $atc->pos_x); + $this->assertEquals(278.0, (float) $atc->pos_z); + } + + /** + * The airport endpoint exposes sites, tower identity and clearance. + */ + public function test_airport_endpoint_exposes_atc_info(): void + { + $this->getJson('/city/airport') + ->assertOk() + ->assertJsonPath('atc.name', 'Torre de Controle City10') + ->assertJsonPath('atc.model', 'poolside/laguna-xs-2.1:free') + ->assertJsonPath('clearance', null); + + $payload = $this->getJson('/city/airport')->json(); + $this->assertCount(2, $payload['sites']); + } + + /** + * Dispatch asks the tower model for a clearance and stores it. + */ + public function test_dispatch_generates_model_clearance(): void + { + config(['ai.openrouter.api_key' => 'test-key']); + Http::fake([ + 'openrouter.ai/*' => Http::response([ + 'choices' => [['message' => ['content' => 'C10-123, AzulCéu, autorizado decolar pista 09.']]], + ]), + ]); + + $this->postJson('/city/airport/dispatch') + ->assertStatus(202) + ->assertJsonPath('accepted', true); + + // The after-response job ran with the request lifecycle. + $clearance = cache()->get('airport.clearance'); + $this->assertNotNull($clearance); + $this->assertSame('model', $clearance['source']); + $this->assertSame('C10-123, AzulCéu, autorizado decolar pista 09.', $clearance['text']); + + Http::assertSent(function ($request): bool { + return $request['model'] === config('ai.atc_model') + && str_contains((string) $request['messages'][0]['content'], 'torre de controle'); + }); + + $atc = AiAgent::query()->where('type', 'atc')->firstOrFail(); + $this->assertSame(0, (int) $atc->error_count); + $this->assertSame('active', $atc->status); + $decision = collect($atc->last_decision)->last(); + $this->assertSame('atc', $decision['role'] ?? null); + } + + /** + * Without an API key the deterministic fallback clearance is stored + * and the failure counts toward backoff. + */ + public function test_dispatch_without_key_stores_fallback(): void + { + config(['ai.openrouter.api_key' => null]); + Http::preventStrayRequests(); + + $this->postJson('/city/airport/dispatch') + ->assertStatus(202) + ->assertJsonPath('accepted', true); + + $clearance = cache()->get('airport.clearance'); + $this->assertNotNull($clearance); + $this->assertSame('fallback', $clearance['source']); + $this->assertStringContainsString('autorizado para decolagem', $clearance['text']); + + $atc = AiAgent::query()->where('type', 'atc')->firstOrFail(); + $this->assertSame(1, (int) $atc->error_count); + } + + /** + * Internal cooldown: only one dispatch per 30 seconds. + */ + public function test_dispatch_has_internal_cooldown(): void + { + config(['ai.openrouter.api_key' => null]); + Http::preventStrayRequests(); + + $this->postJson('/city/airport/dispatch')->assertJsonPath('accepted', true); + $this->postJson('/city/airport/dispatch') + ->assertStatus(202) + ->assertJsonPath('accepted', false) + ->assertJsonPath('reason', 'cooldown'); + + $this->assertNotNull(cache()->get('airport.dispatch.lock')); + } + + /** + * Position sync reaches both airport grounds inside their X band. + */ + public function test_position_allows_airport_grounds(): void + { + $this->postJson('/city/actions/position', ['pos_x' => 0, 'pos_z' => 300]) + ->assertOk(); + $this->character->refresh(); + $this->assertEquals(300.0, (float) $this->character->pos_z); + + $this->postJson('/city/actions/position', ['pos_x' => 0, 'pos_z' => -320]) + ->assertOk(); + $this->character->refresh(); + $this->assertEquals(-320.0, (float) $this->character->pos_z); + + // Outside the airport band the old bounds still apply. + $this->postJson('/city/actions/position', ['pos_x' => 150, 'pos_z' => 300]) + ->assertOk(); + $this->character->refresh(); + $this->assertEquals((float) $this->character->city->bounds_max_z, (float) $this->character->pos_z); + } + + /** + * The clearance chain walks ATC model → default → fallback model + * (AI_FALLBACK_MODEL) until one answers. + */ + public function test_dispatch_falls_back_through_model_chain(): void + { + config(['ai.openrouter.api_key' => 'test-key']); + + Http::fake([ + 'openrouter.ai/*' => Http::sequence() + ->push(['message' => 'No such model'], 404) + ->push(['message' => 'Upstream error'], 500) + ->push(['choices' => [['message' => ['content' => 'C10-310 autorizado, pista 09.']]]], 200), + ]); + + $this->postJson('/city/airport/dispatch') + ->assertStatus(202) + ->assertJsonPath('accepted', true); + + $clearance = cache()->get('airport.clearance'); + $this->assertNotNull($clearance); + $this->assertSame('model', $clearance['source']); + $this->assertSame('C10-310 autorizado, pista 09.', $clearance['text']); + + $models = []; + Http::assertSentCount(3); + Http::assertSent(function ($request) use (&$models): bool { + $models[] = $request['model']; + + return true; + }); + $this->assertSame(config('ai.atc_model'), $models[0]); + $this->assertSame(config('ai.default_model'), $models[1]); + $this->assertSame('xiaomi/mimo-v2.6-flash', $models[2]); + $this->assertSame('xiaomi/mimo-v2.6-flash', config('ai.fallback_model')); + } +} diff --git a/tests/Feature/CityPresenceTest.php b/tests/Feature/CityPresenceTest.php new file mode 100644 index 0000000..b9d029d --- /dev/null +++ b/tests/Feature/CityPresenceTest.php @@ -0,0 +1,88 @@ +seed(CityWorldSeeder::class); + } + + /** + * Two viewers polling within the same second each get their own + * is_me flags from the shared cache. + */ + public function test_is_me_is_per_viewer_with_shared_cache(): void + { + [$userA, $charA] = $this->makeCitizen(); + [$userB, $charB] = $this->makeCitizen(); + + // Viewer A seeds the shared per-second cache first. + $first = $this->actingAs($userA) + ->getJson('/city/presence') + ->assertOk() + ->json('citizens'); + + $this->assertTrue($this->flagFor($first, $charA->id), 'A must see itself as me'); + $this->assertFalse($this->flagFor($first, $charB->id), 'A must not see B as me'); + + // Viewer B hits the SAME cache entry moments later. + $second = $this->actingAs($userB) + ->getJson('/city/presence') + ->assertOk() + ->json('citizens'); + + $this->assertTrue($this->flagFor($second, $charB->id), 'B must see itself as me'); + $this->assertFalse($this->flagFor($second, $charA->id), 'B must not see A as me'); + } + + /** + * Create an active user paired with its in-world character. + * + * @return array{0: User, 1: Character} + */ + private function makeCitizen(): array + { + $user = User::factory()->create([ + 'status' => User::STATUS_ACTIVE, + 'is_active' => true, + ]); + + return [$user, Character::ensureForUser($user)]; + } + + /** + * Extract the is_me flag for a character id from a presence payload. + * + * @param list> $citizens + */ + private function flagFor(array $citizens, int $characterId): bool + { + foreach ($citizens as $c) { + if ((int) $c['id'] === $characterId) { + return (bool) $c['is_me']; + } + } + + $this->fail("Character {$characterId} missing from presence payload"); + } +} diff --git a/tests/Feature/PersonTalkTest.php b/tests/Feature/PersonTalkTest.php index c30cb18..9f16f33 100644 --- a/tests/Feature/PersonTalkTest.php +++ b/tests/Feature/PersonTalkTest.php @@ -639,4 +639,50 @@ class PersonTalkTest extends TestCase $this->assertLessThanOrEqual(68, $character->ai_age); $this->assertContains($character->ai_personality, Character::AI_PERSONALITIES); } + + /** + * When the resolved primary model fails, the call retries with + * AI_FALLBACK_MODEL (owner-approved xiaomi/mimo-v2.6-flash). + */ + public function test_person_talk_retries_with_fallback_model(): void + { + config([ + 'ai.openrouter.api_key' => 'test-key', + 'ai.models' => ['openrouter/free'], + 'ai.character_models' => [], + ]); + + Http::fake([ + 'openrouter.ai/*' => Http::sequence() + ->push(['message' => 'Model overloaded'], 503) + ->push(['choices' => [['message' => ['content' => 'Tudo bem por aqui!']]]], 200), + ]); + + [$userA, $me] = $this->makeCitizen(); + [, $them] = $this->makeCitizen(); // silent peer → AI reply path + + $this->actingAs($userA) + ->postJson('/talk/person', [ + 'kind' => 'character', + 'peer' => $them->id, + 'message' => 'Alô?', + ]) + ->assertCreated(); + + $reply = PersonTalk::query() + ->where('from_character_id', $them->id) + ->where('is_fallback', false) + ->orderByDesc('id') + ->first(); + $this->assertNotNull($reply); + $this->assertSame('Tudo bem por aqui!', $reply->body); + + $models = []; + Http::assertSent(function ($request) use (&$models): bool { + $models[] = $request['model']; + + return true; + }); + $this->assertSame(['openrouter/free', 'xiaomi/mimo-v2.6-flash'], $models); + } } diff --git a/tests/Feature/SignLabelsTest.php b/tests/Feature/SignLabelsTest.php new file mode 100644 index 0000000..4ec0abf --- /dev/null +++ b/tests/Feature/SignLabelsTest.php @@ -0,0 +1,76 @@ + + */ + private const LOCALES = ['pt_BR', 'en', 'es']; + + /** + * Sign label keys rendered by the 3D letreiros. + * + * @var list + */ + private const KEYS = [ + 'city.sign_plaza', + 'city.sign_beach', + 'city.sign_food', + 'city.sign_hospital', + 'city.sign_school', + 'city.sign_cityhall', + ]; + + /** + * Every sign label exists (non-empty) in all three languages. + */ + public function test_sign_labels_exist_in_all_locales(): void + { + foreach (self::LOCALES as $locale) { + $path = base_path("lang/{$locale}.json"); + $this->assertFileExists($path); + + $labels = json_decode((string) file_get_contents($path), true); + $this->assertIsArray($labels); + + foreach (self::KEYS as $key) { + $this->assertArrayHasKey($key, $labels, "Missing {$key} in {$locale}"); + $this->assertNotSame('', trim((string) $labels[$key]), "Empty {$key} in {$locale}"); + } + } + } + + /** + * The labels reach the viewer page for the active locale. + */ + public function test_viewer_page_carries_sign_labels(): void + { + $this->seed(CityWorldSeeder::class); + $user = User::factory()->create([ + 'status' => User::STATUS_ACTIVE, + 'is_active' => true, + ]); + $this->actingAs($user); + + $html = $this->get('/city')->assertOk()->getContent(); + + foreach (['signPlaza', 'signBeach', 'signFood', 'signHospital', 'signSchool', 'signCityhall'] as $jsKey) { + $this->assertStringContainsString($jsKey.':', $html, "Missing i18n entry {$jsKey}"); + } + } +}