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:
@@ -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
|
# separated), e.g. Maria=liquid/lfm-2.5-2.6b:free,7=openrouter/free
|
||||||
AI_CHARACTER_MODELS=
|
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_ACCESS_KEY_ID=
|
||||||
AWS_SECRET_ACCESS_KEY=
|
AWS_SECRET_ACCESS_KEY=
|
||||||
AWS_DEFAULT_REGION=us-east-1
|
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 |
@@ -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
|
- **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
|
`−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
|
- **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
|
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).
|
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**.
|
e bola em arremesso, barracas de comidas, cadeiras e guarda-sóis**.
|
||||||
O jogador pode **relaxar na areia** (tecla `E` → `POST /city/actions/beach`),
|
O jogador pode **relaxar na areia** (tecla `E` → `POST /city/actions/beach`),
|
||||||
recuperando energia/saúde por tick até o máximo.
|
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
|
- **Agentes vivos**: prefeito itinerante, lojistas nas lojas, pedestres com
|
||||||
rotas, mais trens (passagem de nível com barreiras), ônibus/trem
|
rotas, mais trens (passagem de nível com barreiras), ônibus/trem
|
||||||
embarcáveis, avião e barcos no rio.
|
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.
|
# Override por personagem: "nome ou id=modelo" separado por vírgula.
|
||||||
AI_CHARACTER_MODELS=
|
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
|
### 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/chat/{agent}` (+`/stream`) | Conversa com NPC (envio + SSE de resposta) |
|
||||||
| POST/GET | `/city/messages/{character}` | Mensagens assíncronas entre personagens |
|
| POST/GET | `/city/messages/{character}` | Mensagens assíncronas entre personagens |
|
||||||
| POST | `/city/social/{verb}` · GET `/city/social/inbox` | Amizade, casamento, filho, inbox |
|
| 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/POST | `/city/market` (`/buy`, `/sell`) | Mercado |
|
||||||
| GET/PUT | `/city/customize` | Personalização do personagem |
|
| GET/PUT | `/city/customize` | Personalização do personagem |
|
||||||
|
|
||||||
@@ -281,9 +304,11 @@ Todas exigem login + termos aceitos.
|
|||||||
|
|
||||||
## Modelos de IA Permitidos
|
## Modelos de IA Permitidos
|
||||||
|
|
||||||
Somente os modelos **gratuitos** abaixo podem ser usados (validado no backend
|
Somente os modelos abaixo podem ser usados (validado no backend em
|
||||||
em `config/ai.php`; os quatro últimos foram verificados ao vivo no catálogo do
|
`config/ai.php`; os quatro últimos foram verificados ao vivo no catálogo do
|
||||||
OpenRouter):
|
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
|
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
|
qwen/qwen3.7-flash:free
|
||||||
deepseek/deepseek-v4-flash-0731 nvidia/nemotron-3.5-lightning:free
|
deepseek/deepseek-v4-flash-0731 nvidia/nemotron-3.5-lightning:free
|
||||||
liquid/lfm-2.5-2.6b:free openrouter/free
|
liquid/lfm-2.5-2.6b:free openrouter/free
|
||||||
|
xiaomi/mimo-v2.6-flash (fallback — ver nota acima)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -35,9 +35,13 @@ ao trabalhar neste repositório.
|
|||||||
|
|
||||||
## 2. Regras para Modelos de IA (Agentes do City10)
|
## 2. Regras para Modelos de IA (Agentes do City10)
|
||||||
|
|
||||||
2.1. **Somente modelos gratuitos allow-listed.** Agentes só podem usar
|
2.1. **Somente modelos allow-listed.** Agentes só podem usar modelos
|
||||||
modelos definidos em `config/ai.php` (`allowed_models`). A validação
|
definidos em `config/ai.php` (`allowed_models`). A validação
|
||||||
acontece no backend (`AiAgentRequest`) — não contorne.
|
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
|
2.2. **Nunca envie dados pessoais ao modelo.** Ao chamar o OpenRouter, envie
|
||||||
apenas contexto simulado da cidade (posições, metas, estado de lojas).
|
apenas contexto simulado da cidade (posições, metas, estado de lojas).
|
||||||
|
|||||||
@@ -127,22 +127,51 @@ class ChatController extends Controller
|
|||||||
."\n\nContexto simulado da cidade: ".json_encode($cityContext, JSON_UNESCAPED_UNICODE)
|
."\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.";
|
."\nResponda de forma curta e natural, em português. Não invente dados pessoais de usuários.";
|
||||||
|
|
||||||
$response = Http::withHeaders([
|
// Preferred agent model first, then the configured fallback
|
||||||
'Authorization' => 'Bearer '.$apiKey,
|
// chain (AI_FALLBACK_MODEL) before failing the turn.
|
||||||
'HTTP-Referer' => config('ai.openrouter.app_url'),
|
$models = [$agent->model];
|
||||||
'X-Title' => config('ai.openrouter.app_name'),
|
$fallback = config('ai.fallback_model');
|
||||||
])->timeout((int) config('ai.openrouter.timeout', 30))
|
if (is_string($fallback) && $fallback !== ''
|
||||||
->post(config('ai.openrouter.base_url').'/chat/completions', [
|
&& $fallback !== $agent->model
|
||||||
'model' => $agent->model,
|
&& in_array($fallback, config('ai.allowed_models', []), true)) {
|
||||||
'messages' => [
|
$models[] = $fallback;
|
||||||
['role' => 'system', 'content' => $system],
|
}
|
||||||
['role' => 'user', 'content' => $userMessage],
|
|
||||||
],
|
|
||||||
'max_tokens' => 300,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($response->failed()) {
|
$response = null;
|
||||||
self::fail($agent, 'HTTP '.$response->status());
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Models\City;
|
|||||||
use App\Models\InventoryItem;
|
use App\Models\InventoryItem;
|
||||||
use App\Models\Job;
|
use App\Models\Job;
|
||||||
use App\Models\Transaction;
|
use App\Models\Transaction;
|
||||||
|
use App\Support\Airports;
|
||||||
use App\Support\Beach;
|
use App\Support\Beach;
|
||||||
use App\Support\CityPlazas;
|
use App\Support\CityPlazas;
|
||||||
use App\Support\MissionTracker;
|
use App\Support\MissionTracker;
|
||||||
@@ -53,7 +54,15 @@ class CityActionController extends Controller
|
|||||||
// but never beyond the shoreline.
|
// but never beyond the shoreline.
|
||||||
$maxX = Beach::sandTo($city) + 2;
|
$maxX = Beach::sandTo($city) + 2;
|
||||||
$x = max((float) $city->bounds_min_x, min($maxX, (float) $data['pos_x']));
|
$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([
|
$character->forceFill([
|
||||||
'pos_x' => $x,
|
'pos_x' => $x,
|
||||||
|
|||||||
@@ -35,8 +35,12 @@ class CityPresenceController extends Controller
|
|||||||
->first() ?? $city;
|
->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;
|
$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()
|
return Character::query()
|
||||||
->where('city_id', $city->id)
|
->where('city_id', $city->id)
|
||||||
->where(function ($q): void {
|
->where(function ($q): void {
|
||||||
@@ -52,13 +56,18 @@ class CityPresenceController extends Controller
|
|||||||
'status' => $c->state,
|
'status' => $c->state,
|
||||||
'position' => [(float) $c->pos_x, (float) $c->pos_y, (float) $c->pos_z],
|
'position' => [(float) $c->pos_x, (float) $c->pos_y, (float) $c->pos_z],
|
||||||
'is_online' => (bool) $c->is_online,
|
'is_online' => (bool) $c->is_online,
|
||||||
'is_me' => (int) $c->id === (int) $me->id,
|
|
||||||
'owner_name' => $c->user?->name,
|
'owner_name' => $c->user?->name,
|
||||||
])
|
])
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$viewerId = (int) $me->id;
|
||||||
|
$citizens = array_map(
|
||||||
|
static fn (array $c): array => $c + ['is_me' => (int) $c['id'] === $viewerId],
|
||||||
|
$citizens
|
||||||
|
);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'city_id' => $city->id,
|
'city_id' => $city->id,
|
||||||
'server_time' => now()->toIso8601String(),
|
'server_time' => now()->toIso8601String(),
|
||||||
|
|||||||
@@ -9,11 +9,14 @@ use App\Models\Character;
|
|||||||
use App\Models\City;
|
use App\Models\City;
|
||||||
use App\Models\Job;
|
use App\Models\Job;
|
||||||
use App\Models\Store;
|
use App\Models\Store;
|
||||||
|
use App\Support\Airports;
|
||||||
use App\Support\Beach;
|
use App\Support\Beach;
|
||||||
use App\Support\CityPlazas;
|
use App\Support\CityPlazas;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -143,6 +146,7 @@ class CityViewerController extends Controller
|
|||||||
'layout' => $this->cityLayout($city, $stores->all()),
|
'layout' => $this->cityLayout($city, $stores->all()),
|
||||||
'plazas' => CityPlazas::forCity($city),
|
'plazas' => CityPlazas::forCity($city),
|
||||||
'beach' => Beach::geometry($city),
|
'beach' => Beach::geometry($city),
|
||||||
|
'airports' => Airports::geometry($city),
|
||||||
'agents' => AiAgent::orderBy('id')->get()->map(fn ($a): array => [
|
'agents' => AiAgent::orderBy('id')->get()->map(fn ($a): array => [
|
||||||
'id' => $a->id,
|
'id' => $a->id,
|
||||||
'name' => $a->name,
|
'name' => $a->name,
|
||||||
@@ -158,6 +162,216 @@ class CityViewerController extends Controller
|
|||||||
return response()->json($payload)->header('Cache-Control', 'no-store');
|
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.
|
* 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.
|
// the after-response job is never killed mid-flight.
|
||||||
$timeout = min((int) config('ai.openrouter.timeout', 30), 20);
|
$timeout = min((int) config('ai.openrouter.timeout', 30), 20);
|
||||||
|
|
||||||
try {
|
// Preferred model first, then the configured fallback chain
|
||||||
$response = Http::withHeaders([
|
// (AI_FALLBACK_MODEL) before giving up to the caller.
|
||||||
'Authorization' => 'Bearer '.config('ai.openrouter.api_key'),
|
$models = [$model];
|
||||||
'HTTP-Referer' => config('ai.openrouter.app_url'),
|
$fallback = config('ai.fallback_model');
|
||||||
'X-Title' => config('ai.openrouter.app_name'),
|
if (is_string($fallback) && $fallback !== ''
|
||||||
])->timeout($timeout)
|
&& $fallback !== $model
|
||||||
->post(config('ai.openrouter.base_url').'/chat/completions', [
|
&& in_array($fallback, config('ai.allowed_models', []), true)) {
|
||||||
'model' => $model,
|
$models[] = $fallback;
|
||||||
'messages' => $messages,
|
}
|
||||||
// Room for models that reason before answering — too low
|
|
||||||
// a budget starves the visible content (e.g. "Ah").
|
foreach ($models as $candidate) {
|
||||||
'max_tokens' => 700,
|
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()) {
|
return null;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,6 +84,9 @@ return [
|
|||||||
'nvidia/nemotron-3.5-lightning:free',
|
'nvidia/nemotron-3.5-lightning:free',
|
||||||
'liquid/lfm-2.5-2.6b:free',
|
'liquid/lfm-2.5-2.6b:free',
|
||||||
'openrouter/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', '')),
|
'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
|
| Default Model
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\AiAgent;
|
||||||
use App\Models\Building;
|
use App\Models\Building;
|
||||||
use App\Models\Character;
|
use App\Models\Character;
|
||||||
use App\Models\City;
|
use App\Models\City;
|
||||||
@@ -9,6 +10,7 @@ use App\Models\Job;
|
|||||||
use App\Models\Mission;
|
use App\Models\Mission;
|
||||||
use App\Models\Store;
|
use App\Models\Store;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Support\Airports;
|
||||||
use Illuminate\Database\Seeder;
|
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();
|
$bakery = Store::where('type', 'bakery')->orderBy('id')->first();
|
||||||
$market = Store::where('type', 'market')->orderBy('id')->first();
|
$market = Store::where('type', 'market')->orderBy('id')->first();
|
||||||
|
|
||||||
|
|||||||
@@ -219,6 +219,13 @@
|
|||||||
"city.beach_start": "Resting on the sand…",
|
"city.beach_start": "Resting on the sand…",
|
||||||
"city.beach_prompt": "Relax at the beach",
|
"city.beach_prompt": "Relax at the beach",
|
||||||
"city.beach_active": "Relaxing 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_equipment": "Get closer to plaza gym equipment.",
|
||||||
"city.exercise_need_awake": "You must be awake to exercise.",
|
"city.exercise_need_awake": "You must be awake to exercise.",
|
||||||
"city.exercise_tired": "You are too tired to exercise.",
|
"city.exercise_tired": "You are too tired to exercise.",
|
||||||
|
|||||||
@@ -219,6 +219,13 @@
|
|||||||
"city.beach_start": "Descansando en la arena…",
|
"city.beach_start": "Descansando en la arena…",
|
||||||
"city.beach_prompt": "Relajar en la playa",
|
"city.beach_prompt": "Relajar en la playa",
|
||||||
"city.beach_active": "Relajándote 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_equipment": "Acérquese a un equipo de la plaza.",
|
||||||
"city.exercise_need_awake": "Debe estar despierto para ejercitarse.",
|
"city.exercise_need_awake": "Debe estar despierto para ejercitarse.",
|
||||||
"city.exercise_tired": "Está demasiado cansado para ejercitarse.",
|
"city.exercise_tired": "Está demasiado cansado para ejercitarse.",
|
||||||
|
|||||||
@@ -219,6 +219,13 @@
|
|||||||
"city.beach_start": "Descansando na areia…",
|
"city.beach_start": "Descansando na areia…",
|
||||||
"city.beach_prompt": "Relaxar na praia",
|
"city.beach_prompt": "Relaxar na praia",
|
||||||
"city.beach_active": "Relaxando 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_equipment": "Aproxime-se de um equipamento da praça.",
|
||||||
"city.exercise_need_awake": "Você precisa estar acordado para se exercitar.",
|
"city.exercise_need_awake": "Você precisa estar acordado para se exercitar.",
|
||||||
"city.exercise_tired": "Você está cansado demais para se exercitar.",
|
"city.exercise_tired": "Você está cansado demais para se exercitar.",
|
||||||
|
|||||||
+1165
-44
File diff suppressed because it is too large
Load Diff
@@ -368,6 +368,12 @@
|
|||||||
beachStart: @json(__('city.beach_start')),
|
beachStart: @json(__('city.beach_start')),
|
||||||
beachPrompt: @json(__('city.beach_prompt')),
|
beachPrompt: @json(__('city.beach_prompt')),
|
||||||
beachActive: @json(__('city.beach_active')),
|
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')),
|
exerciseStopHint: @json(__('city.exercise_stopHint')),
|
||||||
exerciseStart: @json(__('city.exerciseStart')),
|
exerciseStart: @json(__('city.exerciseStart')),
|
||||||
exerciseStop: @json(__('city.exerciseStop')),
|
exerciseStop: @json(__('city.exerciseStop')),
|
||||||
|
|||||||
@@ -129,6 +129,14 @@ Route::middleware('auth')->group(function (): void {
|
|||||||
Route::post('/talk/person', [PersonTalkController::class, 'send'])
|
Route::post('/talk/person', [PersonTalkController::class, 'send'])
|
||||||
->middleware('throttle:30,1')
|
->middleware('throttle:30,1')
|
||||||
->name('talk.person.send');
|
->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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -639,4 +639,50 @@ class PersonTalkTest extends TestCase
|
|||||||
$this->assertLessThanOrEqual(68, $character->ai_age);
|
$this->assertLessThanOrEqual(68, $character->ai_age);
|
||||||
$this->assertContains($character->ai_personality, Character::AI_PERSONALITIES);
|
$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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user