feat(city): add airports, ATC agent, luminous signs, and AI model fallback chain
- Add two airports (north/south) with full geometry: runways, taxiways,
aprons, terminals, hangars, stands, and helipads; walkable airport
grounds extend beyond city bounds.
- Introduce ATC AI agent ("Torre de Controle City10") using AI_ATC_MODEL
to generate takeoff clearances; includes dispatch endpoint with
30-second cooldown and deterministic fallback on failure.
- Implement AI model fallback chain: preferred model → AI_FALLBACK_MODEL
before failing, applied in ChatController and PersonTalkController.
- Add luminous sign labels (letreiros) for plazas, beach, food, hospital,
school, city hall, and airports with day/night glow.
- Fix city presence cache: compute is_me per viewer instead of baking
into shared per-second cache to prevent cross-user flag leakage.
- Add tests: AirportTest, CityPresenceTest, SignLabelsTest, and
PersonTalkTest fallback model retry scenario.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\City;
|
||||
|
||||
/**
|
||||
* Airport grounds north and south of the city grid.
|
||||
*
|
||||
* Geometry derives from the layout edges so both airports always sit on
|
||||
* open land beyond the roads. The same descriptor drives the client-side
|
||||
* build (runways, taxiways, aprons, terminals, hangars, stands, helipads)
|
||||
* and the walkable-visit zone used by the position clamp.
|
||||
*/
|
||||
class Airports
|
||||
{
|
||||
/** Half width (X) of the walkable airport strips. */
|
||||
public const VISIT_HALF_WIDTH = 110.0;
|
||||
|
||||
/** Walkable Z range inside the airport strips (both airports). */
|
||||
public const VISIT_MIN_Z = -400.0;
|
||||
|
||||
public const VISIT_MAX_Z = 400.0;
|
||||
|
||||
/**
|
||||
* Layout edge north of the city (same derivation the road grid uses).
|
||||
*/
|
||||
public static function layoutMaxZ(City $city): float
|
||||
{
|
||||
return (float) (ceil(((float) $city->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<string, float>, sites: list<array<string, mixed>>}
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user