feat(city): add airports, ATC agent, luminous signs, and AI model fallback chain

- Add two airports (north/south) with full geometry: runways, taxiways,
  aprons, terminals, hangars, stands, and helipads; walkable airport
  grounds extend beyond city bounds.
- Introduce ATC AI agent ("Torre de Controle City10") using AI_ATC_MODEL
  to generate takeoff clearances; includes dispatch endpoint with
  30-second cooldown and deterministic fallback on failure.
- Implement AI model fallback chain: preferred model → AI_FALLBACK_MODEL
  before failing, applied in ChatController and PersonTalkController.
- Add luminous sign labels (letreiros) for plazas, beach, food, hospital,
  school, city hall, and airports with day/night glow.
- Fix city presence cache: compute is_me per viewer instead of baking
  into shared per-second cache to prevent cross-user flag leakage.
- Add tests: AirportTest, CityPresenceTest, SignLabelsTest, and
  PersonTalkTest fallback model retry scenario.
This commit is contained in:
2026-10-31 22:06:18 -03:00
parent a12807492f
commit e7498e1566
26 changed files with 2226 additions and 108 deletions
+8
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

+30 -4
View File
@@ -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)
```
---
+7 -3
View File
@@ -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).
+44 -15
View File
@@ -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;
}
/**
+147
View File
@@ -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],
];
}
}
+28
View File
@@ -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
+27
View File
@@ -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();
+7
View File
@@ -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.",
+7
View File
@@ -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.",
+7
View File
@@ -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.",
+1165 -44
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -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')),
+8
View File
@@ -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');
});
});
+234
View File
@@ -0,0 +1,234 @@
<?php
namespace Tests\Feature;
use App\Models\AiAgent;
use App\Models\Character;
use App\Models\User;
use App\Support\Airports;
use Database\Seeders\CityWorldSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
/**
* Airports payload, control-tower agent (AI_ATC_MODEL) and the AI
* clearance dispatch used by the flight operations.
*/
class AirportTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Character $character;
/**
* Boot world fixtures.
*/
protected function setUp(): void
{
parent::setUp();
$this->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'));
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
namespace Tests\Feature;
use App\Models\Character;
use App\Models\User;
use Database\Seeders\CityWorldSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Presence feed correctness: the per-second rows are shared, but the
* is_me flag must always reflect the viewer of the current request —
* otherwise a citizen sees their own character as a remote player and
* the bump-to-talk chat opens with themselves.
*/
class CityPresenceTest extends TestCase
{
use RefreshDatabase;
/**
* Boot world fixtures.
*/
protected function setUp(): void
{
parent::setUp();
$this->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<array<string, mixed>> $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");
}
}
+46
View File
@@ -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);
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Database\Seeders\CityWorldSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Luminous-sign labels must exist in every locale (RULES.md 1.4:
* every user-visible string ships in pt_BR, en and es).
*/
class SignLabelsTest extends TestCase
{
use RefreshDatabase;
/**
* Locales covered by the platform.
*
* @var list<string>
*/
private const LOCALES = ['pt_BR', 'en', 'es'];
/**
* Sign label keys rendered by the 3D letreiros.
*
* @var list<string>
*/
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}");
}
}
}