Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a43176a68e | ||
|
|
68cb4eb9ad | ||
|
|
580c830ffb | ||
|
|
e230c391ad | ||
|
|
ecf5291027 | ||
|
|
46b15ec7be | ||
|
|
e7498e1566 | ||
|
|
a12807492f | ||
|
|
b8c31e7be4 | ||
|
|
4b71a9d0ff | ||
|
|
b4f80cee24 | ||
|
|
81be63b4c3 | ||
|
|
59963de995 | ||
|
|
50a6d40e8e | ||
|
|
4f497844bc | ||
|
|
bd6801994a |
@@ -60,6 +60,21 @@ MAIL_FROM_NAME="${APP_NAME}"
|
|||||||
OPENROUTER_API_KEY=
|
OPENROUTER_API_KEY=
|
||||||
AI_DEFAULT_MODEL=deepseek/deepseek-v4-flash-0731
|
AI_DEFAULT_MODEL=deepseek/deepseek-v4-flash-0731
|
||||||
|
|
||||||
|
# AI_MODELS: pool of allow-listed free OpenRouter models distributed
|
||||||
|
# deterministically across characters (each character keeps its own model).
|
||||||
|
AI_MODELS=deepseek/deepseek-v4-flash-0731,nvidia/nemotron-3.5-lightning:free,liquid/lfm-2.5-2.6b:free,openrouter/free,nvidia/nemotron-3-ultra-550b-a55b:free
|
||||||
|
# AI_CHARACTER_MODELS: per-character override "name or id=model" (comma
|
||||||
|
# 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_ACCESS_KEY_ID=
|
||||||
AWS_SECRET_ACCESS_KEY=
|
AWS_SECRET_ACCESS_KEY=
|
||||||
AWS_DEFAULT_REGION=us-east-1
|
AWS_DEFAULT_REGION=us-east-1
|
||||||
|
|||||||
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 247 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 269 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 258 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 245 KiB |
@@ -1,10 +1,12 @@
|
|||||||
# City10 — Cidade 3D Virtual com Agentes de IA
|
# City10 — Cidade 3D Virtual com Agentes de IA
|
||||||
|
|
||||||
**City10** é uma plataforma full-stack de simulação urbana 3D com agentes de
|
**City10** é uma plataforma full-stack de simulação urbana 3D com agentes de
|
||||||
inteligência artificial autônomos, construída sobre **Laravel 13** e **Three.js**.
|
inteligência artificial, construída sobre **Laravel 13** e **Three.js**. O
|
||||||
Agentes (Prefeito, pedestres, lojistas) tomam decisões estratégicas em tempo real
|
visualizador3D é jogável no navegador: trânsito semafórico, pedestres, agentes
|
||||||
através da API [OpenRouter.ai](https://openrouter.ai), restrita a uma lista de
|
(Prefeito, lojistas), conversas de rua com IA, praças com academia e uma praia
|
||||||
modelos gratuitos.
|
com lazer. O raciocínio dos NPCs acontece através da API
|
||||||
|
[OpenRouter.ai](https://openrouter.ai), restrito a uma lista de modelos
|
||||||
|
gratuitos.
|
||||||
|
|
||||||
> Plataforma operada pela **Syspanel** — Santa Rita do Sapucaí/MG, Brasil.
|
> Plataforma operada pela **Syspanel** — Santa Rita do Sapucaí/MG, Brasil.
|
||||||
> Contato: [contato@syspanel.com.br](mailto:contato@syspanel.com.br) ·
|
> Contato: [contato@syspanel.com.br](mailto:contato@syspanel.com.br) ·
|
||||||
@@ -17,15 +19,15 @@ modelos gratuitos.
|
|||||||
| Camada | Tecnologia | Responsabilidade |
|
| Camada | Tecnologia | Responsabilidade |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Backend / Painel Admin | Laravel 13 (PHP ≥ 8.3) | Autenticação, API REST, regras de negócio, consentimento LGPD |
|
| Backend / Painel Admin | Laravel 13 (PHP ≥ 8.3) | Autenticação, API REST, regras de negócio, consentimento LGPD |
|
||||||
| Banco de Dados | MariaDB (MySQL) | Persistência de usuários, lojas, agentes e consentimentos |
|
| Banco de Dados | MariaDB (MySQL) | Persistência de usuários, lojas, agentes, conversas e consentimentos |
|
||||||
| Cache / Fila | Redis / Database | Sessões, cache e jobs |
|
| Cache / Fila | Database (sessões/cache/jobs) | Sessões, cache, agendamentos e throttle |
|
||||||
| Frontend 3D | Three.js (Fase 2+) | Renderização da cidade no navegador |
|
| Frontend 3D | Three.js | Mundo jogável no navegador (cidade, trânsito, praia, clima) |
|
||||||
| Tempo Real | Laravel Reverb (planejado) | Sincronização de estado entre clientes |
|
| Tempo Real | Polling HTTP + SSE | Presence (~2,5 s), chat (~2,5 s) e stream de resposta de NPC (SSE) |
|
||||||
| Cérebro dos Agentes | OpenRouter.ai | Raciocínio dos NPCs com modelos gratuitos |
|
| Cérebro dos Agentes | OpenRouter.ai | Raciocínio dos NPCs e conversas com modelos gratuitos |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Fase 1 (Atual) — Entregue
|
## Fase 1 — Plataforma (Entregue)
|
||||||
|
|
||||||
- Estrutura Laravel 13 com autenticação diferenciada (**Admin** / **Usuário Comum**).
|
- Estrutura Laravel 13 com autenticação diferenciada (**Admin** / **Usuário Comum**).
|
||||||
- **Migrations**: usuários com `role`, lojas com coordenadas 3D (`pos_x`, `pos_y`, `pos_z`), agentes de IA e trilha de consentimento legal (LGPD).
|
- **Migrations**: usuários com `role`, lojas com coordenadas 3D (`pos_x`, `pos_y`, `pos_z`), agentes de IA e trilha de consentimento legal (LGPD).
|
||||||
@@ -38,6 +40,96 @@ modelos gratuitos.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Fase 2 — Mundo 3D Jogável (Entregue)
|
||||||
|
|
||||||
|
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
|
||||||
|
sul, casas de jogadores e um conjunto de lugares públicos com letreiros
|
||||||
|
luminosos: **hospital, escola, prefeitura, universidade, corpo de
|
||||||
|
bombeiros** (prédios cívicos) e **supermercado, drogaria, restaurante,
|
||||||
|
cabeleireiro, banco, hotel e shopping** (11 lojas no total, todas com
|
||||||
|
placa frontal + letreiro vertical blade).
|
||||||
|
- **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).
|
||||||
|
- **Faixas zebradas reais** (barras longitudinais paralelas ao fluxo, design
|
||||||
|
MUTCD) com calçadas e meio-fios interrompidos nas travessias — 323 faixas no
|
||||||
|
layout atual.
|
||||||
|
- **Pedestres** com travessia sincronizada ao semáforo; bloqueio de travessia
|
||||||
|
do jogador no vermelho.
|
||||||
|
- **Praças** (1 a cada 4 células) com academia externa: exercícios com pose,
|
||||||
|
efeito em saúde/energia e missão “Shape em dia”.
|
||||||
|
- **Praia a leste** (`/city` + `App\Support\Beach`): areia, mar com ondas
|
||||||
|
animadas (deslocamento de vértices), espuma na orla, **banhistas, nadadores,
|
||||||
|
surfistas, salva-vidas (torre + patrulha), quadra de basquete com jogadores
|
||||||
|
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.
|
||||||
|
- **Dia/noite e clima** com ciclo de horas, névoa, chuva e sombras que seguem
|
||||||
|
o jogador.
|
||||||
|
- **Conversas de rua** com outros jogadores e NPCs (ver próxima seção).
|
||||||
|
- **Performance**: *batching* estático por material, sombra seguindo o
|
||||||
|
jogador (janela ±120), lâmpadas/rodas com LOD por distância e fachadas a
|
||||||
|
256 px — medições ~30–60 fps numa GTX 770.
|
||||||
|
|
||||||
|
### Controles
|
||||||
|
|
||||||
|
| Controle | Ação |
|
||||||
|
|---|---|
|
||||||
|
| `W A S D` | Andar (A/D = lateral) |
|
||||||
|
| Mouse (pointer lock) | Olhar; **arrastar na cena** quando sem lock |
|
||||||
|
| `←` `→` | **Viram a câmera** (não andam de lado) |
|
||||||
|
| Joystick virtual (mobile) | Vertical = andar, horizontal = **virar a câmera** |
|
||||||
|
| `E` | Contexto: exercício na praça / relaxar na praia / parar |
|
||||||
|
| `Espaço` | Pular |
|
||||||
|
| Clique num NPC | Abrir chat |
|
||||||
|
| Chegar perto de alguém | Chat abre sozinho; sai de perto ele fecha |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conversas de Rua (`/talk/person`) e IA por Personagem
|
||||||
|
|
||||||
|
- **Registro por dia**: cada fala gravada em `person_talks`
|
||||||
|
(`from/to` de character ou agent + `talked_on` + `is_fallback`), consultável
|
||||||
|
via `GET /talk/person?kind=agent|character&peer=ID`.
|
||||||
|
- **Fio da meada**: o histórico completo (dias anteriores + hoje) é enviado
|
||||||
|
como contexto nas respostas de IA — placeholders de erro (`is_fallback`)
|
||||||
|
ficam visíveis na UI mas **nunca** entram no contexto do modelo.
|
||||||
|
- **Quem responde**:
|
||||||
|
- Par com **humano vivo** (sessão tocada nos últimos 5 min) → relay puro,
|
||||||
|
nunca é substituído por IA;
|
||||||
|
- Par **sem humano** (cidadãos demo, NPCs) → persona anônima de morador
|
||||||
|
responde via OpenRouter, sem dados cadastrais no payload.
|
||||||
|
- **Resposta de IA** é gerada *after-response* (não depende de worker de
|
||||||
|
fila), com espaçamento por par, allow-list de modelos, fallback localizado
|
||||||
|
em falha e auditoria em `last_decision`.
|
||||||
|
- **Identidade por personagem**: colunas em `characters` — `ai_model`,
|
||||||
|
`ai_age`, `ai_gender`, `ai_personality`, `ai_bio` (persona injetada no
|
||||||
|
prompt; recém-criados já nascem com personalidade/idade).
|
||||||
|
- **Modelo por personagem** (ordem de resolução, sempre validado em
|
||||||
|
`allowed_models`): coluna `ai_model` → mapa `AI_CHARACTER_MODELS` (id ou
|
||||||
|
nome) → slot determinístico do pool `AI_MODELS` → `AI_DEFAULT_MODEL`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Requisitos
|
## Requisitos
|
||||||
|
|
||||||
- PHP **≥ 8.3** (com `pdo_mysql`, `mbstring`, `curl`, `zip`, `gd`, `bcmath`)
|
- PHP **≥ 8.3** (com `pdo_mysql`, `mbstring`, `curl`, `zip`, `gd`, `bcmath`)
|
||||||
@@ -74,13 +166,28 @@ DB_USERNAME=root
|
|||||||
DB_PASSWORD=<sua_senha>
|
DB_PASSWORD=<sua_senha>
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Chave do OpenRouter.ai
|
### 3. Chave do OpenRouter.ai e modelos
|
||||||
|
|
||||||
A chave **não** é versionada. Insira-a apenas no `.env` local:
|
A chave **não** é versionada. Insira-a apenas no `.env` local:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
OPENROUTER_API_KEY=<sua_chave>
|
OPENROUTER_API_KEY=<sua_chave>
|
||||||
AI_DEFAULT_MODEL=deepseek/deepseek-v4-flash-0731
|
AI_DEFAULT_MODEL=deepseek/deepseek-v4-flash-0731
|
||||||
|
|
||||||
|
# Pool de modelos gratuitos distribuídos deterministicamente entre os
|
||||||
|
# personagens (cada um mantém o próprio modelo estável).
|
||||||
|
AI_MODELS=deepseek/deepseek-v4-flash-0731,nvidia/nemotron-3.5-lightning:free,liquid/lfm-2.5-2.6b:free,openrouter/free,nvidia/nemotron-3-ultra-550b-a55b:free
|
||||||
|
|
||||||
|
# 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
|
### 4. Banco de dados
|
||||||
@@ -122,9 +229,24 @@ docker exec -it dockercity10 bash -c "cd /app && php artisan migrate --seed"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Testes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan test # suíte completa (PHPUnit)
|
||||||
|
./vendor/bin/pint # formatação de código
|
||||||
|
```
|
||||||
|
|
||||||
|
Destaques da suíte (testes de recurso): autenticação/LGPD, ações da cidade
|
||||||
|
(exercício, posição com clamp até a orla da praia), social, mercado,
|
||||||
|
missões, **conversas de rua** (`PersonTalkTest` — histórico multidiário,
|
||||||
|
resolução de modelo, ausência de PII no payload) e **lazer na praia**
|
||||||
|
(`BeachLeisureTest` — geometria no payload, efeitos e zona).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Estrutura de Endpoints da API
|
## Estrutura de Endpoints da API
|
||||||
|
|
||||||
Base URL: `http://localhost:16980/api`
|
Base URL: `http://localhost:16980/api` (Bearer Sanctum)
|
||||||
|
|
||||||
### Públicos
|
### Públicos
|
||||||
|
|
||||||
@@ -145,37 +267,66 @@ Base URL: `http://localhost:16980/api`
|
|||||||
| GET | `/city/world` | Snapshot da cidade (lojas + agentes com coordenadas 3D) |
|
| GET | `/city/world` | Snapshot da cidade (lojas + agentes com coordenadas 3D) |
|
||||||
| GET/POST | `/stores` | Lista/cria lojas |
|
| GET/POST | `/stores` | Lista/cria lojas |
|
||||||
| GET/PUT/DELETE | `/stores/{id}` | Gerencia uma loja (dono ou admin) |
|
| GET/PUT/DELETE | `/stores/{id}` | Gerencia uma loja (dono ou admin) |
|
||||||
|
| GET | `/stores-world/seed` | Mundo de lojas (seed 3D) |
|
||||||
| GET | `/legal/status` | Status de aceite dos documentos |
|
| GET | `/legal/status` | Status de aceite dos documentos |
|
||||||
| POST | `/legal/accept` | Registra o aceite (auditoria LGPD) |
|
| POST | `/legal/accept` | Registra o aceite (auditoria LGPD) |
|
||||||
| GET | `/legal/history` | Histórico de consentimentos do usuário |
|
| GET | `/legal/history` | Histórico de consentimentos do usuário |
|
||||||
|
|
||||||
### Somente Administrador (`/admin`)
|
### Somente Administrador
|
||||||
|
|
||||||
| Método | Rota | Descrição |
|
| Método | Rota | Descrição |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET/POST | `/admin/users` | Lista/cria usuários |
|
| CRUD | `/api/users` | Gestão de usuários |
|
||||||
| GET/PUT/DELETE | `/admin/users/{id}` | Gerencia usuários |
|
| CRUD | `/api/agents` | Gestão de agentes de IA |
|
||||||
| GET/POST | `/admin/agents` | Lista/cria agentes de IA |
|
| GET | `/api/agents-meta/models` | Lista os modelos gratuitos permitidos |
|
||||||
| GET/PUT/DELETE | `/admin/agents/{id}` | Gerencia agentes |
|
| GET | `/api/agents-world/seed` | Mundo de agentes (seed 3D) |
|
||||||
| GET | `/admin/agents-meta/models` | Lista os modelos gratuitos permitidos |
|
| GET/POST | `/stores`, `/citizens`, `/cities`, `/world` | Admin web (aprovação de cidadãos, cidades, mundo) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rotas Web da Cidade (sessão de navegador)
|
||||||
|
|
||||||
|
Todas exigem login + termos aceitos.
|
||||||
|
|
||||||
|
| Método | Rota | Descrição |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/city` | Visualizador 3D (HTML + bundle Three.js) |
|
||||||
|
| GET | `/city/data` | Payload do mundo: layout, lojas, agentes, praças, prédios, `beach` |
|
||||||
|
| GET | `/city/presence` | Cidadãos online (posições, `is_me`) — throttle 60/min |
|
||||||
|
| GET/POST | `/talk/person` | Conversas de rua: histórico/contexto e envio (throttle 120 e 30/min) |
|
||||||
|
| POST | `/city/actions/position` | Sincroniza posição (clamp até a orla da praia) |
|
||||||
|
| POST | `/city/actions/{action}` | Ações: `eat`, `drink`, `sleep`, `wake`, `work`, `study`, `buy`, `sell`, `build`, `apply-job`, `exercise`, `stop-exercise`, `beach`, `ride`, `accident`… |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Modelos de IA Permitidos
|
## Modelos de IA Permitidos
|
||||||
|
|
||||||
Somente os modelos **gratuitos** abaixo podem ser atribuídos a agentes
|
Somente os modelos abaixo podem ser usados (validado no backend em
|
||||||
(validado no backend em `config/ai.php`):
|
`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
|
poolside/laguna-m.1:free cohere/north-mini-code:free
|
||||||
deepseek/deepseek-v4-flash-0731:free qwen/qwen3.8-27b:free
|
deepseek/deepseek-v4-flash-0731:free qwen/qwen3.8-27b:free
|
||||||
nvidia/nemotron-3-ultra-550b-a55b:free nvidia/nemotron-3-super-120b-a12b:free
|
nvidia/nemotron-3-ultra-550b-a55b:free nvidia/nemotron-3-super-120b-a12b:free
|
||||||
google/gemma-4-31b-it:free google/gemma-4-26b-a4b-it:free
|
google/gemma-4-31b-it:free google/gemma-4-26b-a4b-it:free
|
||||||
openai/gpt-oss-20b:free nvidia/nemotron-nano-12b-v2-vl:free
|
openai/gpt-oss-20b:free nvidia/nemotron-nano-12b-v2-vl:free
|
||||||
nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free
|
nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free
|
||||||
nvidia/nemotron-3-nano-30b-a3b:free nvidia/nemotron-nano-9b-v2:free
|
nvidia/nemotron-3-nano-30b-a3b:free nvidia/nemotron-nano-9b-v2:free
|
||||||
poolside/laguna-xs-2.1:free inclusionai/ling-3.0-flash-vl:free
|
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
|
||||||
|
liquid/lfm-2.5-2.6b:free openrouter/free
|
||||||
|
xiaomi/mimo-v2.6-flash (fallback — ver nota acima)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -191,6 +342,9 @@ qwen/qwen3.7-flash:free
|
|||||||
- **Banner de cookies** com opção de aceitar/recusar cookies não essenciais.
|
- **Banner de cookies** com opção de aceitar/recusar cookies não essenciais.
|
||||||
- **Direitos do titular** (art. 18 LGPD): histórico de consentimentos disponível
|
- **Direitos do titular** (art. 18 LGPD): histórico de consentimentos disponível
|
||||||
ao usuário; contato para exercer direitos: `contato@syspanel.com.br`.
|
ao usuário; contato para exercer direitos: `contato@syspanel.com.br`.
|
||||||
|
- **IA sem PII**: ao chamar o OpenRouter envia-se apenas contexto simulado da
|
||||||
|
cidade e o diálogo/identidade in-world — nunca e-mail, senha ou dados de
|
||||||
|
conta (regras em [`RULES.md`](RULES.md), seção 2).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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,8 +9,11 @@ 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\CityPlazas;
|
use App\Support\CityPlazas;
|
||||||
use App\Support\MissionTracker;
|
use App\Support\MissionTracker;
|
||||||
|
use App\Support\Port;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -48,8 +51,24 @@ class CityActionController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$city = $character->city;
|
$city = $character->city;
|
||||||
$x = max((float) $city->bounds_min_x, min((float) $city->bounds_max_x, (float) $data['pos_x']));
|
// Players may persist onto the east beach sand (past the road grid)
|
||||||
$z = max((float) $city->bounds_min_z, min((float) $city->bounds_max_z, (float) $data['pos_z']));
|
// but never beyond the shoreline.
|
||||||
|
$maxX = Beach::sandTo($city) + 2;
|
||||||
|
$x = max((float) $city->bounds_min_x, min($maxX, (float) $data['pos_x']));
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
// Port strip (north-east coast): walking east of the bounds opens
|
||||||
|
// the Z range down to the port grounds/quay.
|
||||||
|
if ($x >= Port::VISIT_MIN_X) {
|
||||||
|
$minZ = Port::VISIT_MIN_Z;
|
||||||
|
}
|
||||||
|
$z = max($minZ, min($maxZ, (float) $data['pos_z']));
|
||||||
|
|
||||||
$character->forceFill([
|
$character->forceFill([
|
||||||
'pos_x' => $x,
|
'pos_x' => $x,
|
||||||
@@ -100,12 +119,63 @@ class CityActionController extends Controller
|
|||||||
'build' => $this->build($request, $character),
|
'build' => $this->build($request, $character),
|
||||||
'apply-job' => $this->applyJob($request, $character),
|
'apply-job' => $this->applyJob($request, $character),
|
||||||
'exercise' => $this->exercise($request, $character),
|
'exercise' => $this->exercise($request, $character),
|
||||||
|
'beach' => $this->beach($request, $character),
|
||||||
'stop-exercise' => $this->stopExercise($character),
|
'stop-exercise' => $this->stopExercise($character),
|
||||||
'ride' => $this->ride($request, $character),
|
'ride' => $this->ride($request, $character),
|
||||||
default => response()->json(['message' => __('city.action_unknown')], 422),
|
default => response()->json(['message' => __('city.action_unknown')], 422),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relax on the east beach while standing on the sand.
|
||||||
|
*
|
||||||
|
* The client posts this on a slow timer during a relax session (the
|
||||||
|
* same pattern as exercise): each tick recovers a bit of energy and
|
||||||
|
* health, capped at 100, and only inside the beach zone.
|
||||||
|
*/
|
||||||
|
private function beach(Request $request, Character $character): JsonResponse
|
||||||
|
{
|
||||||
|
if ($character->state === Character::STATE_SLEEPING) {
|
||||||
|
return response()->json(['message' => __('city.exercise_need_awake')], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($character->state === Character::STATE_EXERCISING) {
|
||||||
|
return response()->json(['message' => __('city.beach_busy')], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'pos_x' => ['nullable', 'numeric'],
|
||||||
|
'pos_z' => ['nullable', 'numeric'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$x = array_key_exists('pos_x', $data) ? (float) $data['pos_x'] : (float) $character->pos_x;
|
||||||
|
$z = array_key_exists('pos_z', $data) ? (float) $data['pos_z'] : (float) $character->pos_z;
|
||||||
|
|
||||||
|
if (! Beach::isOnBeach($character->city, $x, $z)) {
|
||||||
|
return response()->json(['message' => __('city.beach_need')], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $character->energy >= 100 && (int) $character->health >= 100) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => __('city.beach_full'),
|
||||||
|
'full' => true,
|
||||||
|
'character' => $this->characterData($character),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$character->forceFill([
|
||||||
|
'health' => min(100, (int) $character->health + 1),
|
||||||
|
'energy' => min(100, (int) $character->energy + 1),
|
||||||
|
'pos_x' => $x,
|
||||||
|
'pos_z' => $z,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => __('city.beach_ok'),
|
||||||
|
'character' => $this->characterData($character),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Board a nearby bus stop or train for a short ride.
|
* Board a nearby bus stop or train for a short ride.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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,10 +9,15 @@ 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\CityPlazas;
|
use App\Support\CityPlazas;
|
||||||
|
use App\Support\Port;
|
||||||
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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -141,6 +146,9 @@ class CityViewerController extends Controller
|
|||||||
] : null,
|
] : null,
|
||||||
'layout' => $this->cityLayout($city, $stores->all()),
|
'layout' => $this->cityLayout($city, $stores->all()),
|
||||||
'plazas' => CityPlazas::forCity($city),
|
'plazas' => CityPlazas::forCity($city),
|
||||||
|
'beach' => Beach::geometry($city),
|
||||||
|
'airports' => Airports::geometry($city),
|
||||||
|
'port' => Port::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,
|
||||||
@@ -156,6 +164,446 @@ 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)],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt-echo markers shared by every clearance guard: replies that
|
||||||
|
* restate the instruction instead of speaking are rejected.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const PROMPT_META = [
|
||||||
|
'the user wants',
|
||||||
|
'respond only',
|
||||||
|
'no quotes',
|
||||||
|
'atc style',
|
||||||
|
'short sentence',
|
||||||
|
'for example',
|
||||||
|
'instructions',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a clearance prompt across a model chain (extra models first —
|
||||||
|
* e.g. AI_ATC_MODEL — then the agent model, the platform default and
|
||||||
|
* AI_FALLBACK_MODEL), returning the first reply accepted by $accept.
|
||||||
|
*
|
||||||
|
* Without an API key nothing is attempted (no try consumed).
|
||||||
|
*
|
||||||
|
* @param callable(string): bool|null $accept per-model shape guard
|
||||||
|
* @param list<string|null>|null $extraModels models tried first
|
||||||
|
*/
|
||||||
|
private static function askWithChain(AiAgent $agent, string $prompt, ?callable $accept = null, ?array $extraModels = null): ?string
|
||||||
|
{
|
||||||
|
if (! filled(config('ai.openrouter.api_key'))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowed = config('ai.allowed_models', []);
|
||||||
|
$candidates = [];
|
||||||
|
$chain = array_merge($extraModels ?? [], [
|
||||||
|
$agent->model,
|
||||||
|
config('ai.default_model'),
|
||||||
|
config('ai.fallback_model'),
|
||||||
|
]);
|
||||||
|
foreach ($chain as $model) {
|
||||||
|
if (is_string($model) && $model !== '' && in_array($model, $allowed, true)) {
|
||||||
|
$candidates[$model] = $model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$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('Clearance model exception', ['model' => $model, 'error' => $e->getMessage()]);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
Log::warning('Clearance model failed', ['model' => $model, 'status' => $response->status()]);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = $response->json('choices.0.message.content');
|
||||||
|
if (is_array($text)) {
|
||||||
|
$text = implode('', array_map(
|
||||||
|
static fn ($part): string => is_array($part) ? (string) ($part['text'] ?? '') : (string) $part,
|
||||||
|
$text
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($text) && trim($text) !== '') {
|
||||||
|
$text = mb_substr(trim($text), 0, 240);
|
||||||
|
if ($accept === null || $accept($text)) {
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
// Echoed the instruction instead of the phrase: reject and
|
||||||
|
// continue down the chain (next model, then the caller's
|
||||||
|
// deterministic fallback).
|
||||||
|
Log::warning('Clearance rejected (not a clearance)', ['model' => $model]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One ATC takeoff clearance (chain tries AI_ATC_MODEL first).
|
||||||
|
*
|
||||||
|
* @param array{number: string, airline: string, runway: string} $flight
|
||||||
|
*/
|
||||||
|
private static function askTower(AiAgent $atc, array $flight): ?string
|
||||||
|
{
|
||||||
|
$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.';
|
||||||
|
|
||||||
|
return self::askWithChain(
|
||||||
|
$atc,
|
||||||
|
$prompt,
|
||||||
|
static fn (string $text): bool => self::looksLikeClearance($text, $flight),
|
||||||
|
[config('ai.atc_model')]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One port-movement clearance from the Capitania (maritime phrasing).
|
||||||
|
*
|
||||||
|
* @param array{ship: string, company: string, action: string, action_text: string, berth: int} $maneuver
|
||||||
|
*/
|
||||||
|
private static function askPort(AiAgent $captain, array $maneuver): ?string
|
||||||
|
{
|
||||||
|
$prompt = 'Você é a Capitania do Porto City10. Autorize em UMA frase curta, em português, '
|
||||||
|
.'estilo marítimo de controle portuário, a '.$maneuver['action_text'].' do navio '
|
||||||
|
.$maneuver['ship'].' ('.$maneuver['company'].') no berço '.$maneuver['berth']
|
||||||
|
.'. Responda apenas com a frase de autorização, sem aspas nem explicações.';
|
||||||
|
|
||||||
|
return self::askWithChain($captain, $prompt, static function (string $text) use ($maneuver): bool {
|
||||||
|
$lower = mb_strtolower($text);
|
||||||
|
foreach (self::PROMPT_META as $needle) {
|
||||||
|
if (str_contains($lower, $needle)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return str_contains($text, $maneuver['ship'])
|
||||||
|
|| preg_match('/autoriz|atrac|cais|berço|berco|manobr|liberad|deixar|partida/iu', $text) === 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heuristic guard: a clearance is short, speech-like and about the
|
||||||
|
* requested flight — never a restatement of the prompt itself.
|
||||||
|
*
|
||||||
|
* @param array{number: string, airline: string, runway: string} $flight
|
||||||
|
*/
|
||||||
|
private static function looksLikeClearance(string $text, array $flight): bool
|
||||||
|
{
|
||||||
|
$lower = mb_strtolower($text);
|
||||||
|
|
||||||
|
foreach (self::PROMPT_META as $needle) {
|
||||||
|
if (str_contains($lower, $needle)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return str_contains($text, $flight['number'])
|
||||||
|
|| preg_match('/autoriz|pista|runway|cleared|descolagem|decolag/iu', $text) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port summary: geometry, Capitania agent and latest maneuver
|
||||||
|
* clearance (generated by the port AI agent on dispatch).
|
||||||
|
*/
|
||||||
|
public function port(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$city = $this->resolveCity($request);
|
||||||
|
$captain = AiAgent::query()->where('type', 'port_master')->first();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'geometry' => Port::geometry($city),
|
||||||
|
'captain' => $captain !== null ? [
|
||||||
|
'id' => (int) $captain->id,
|
||||||
|
'name' => $captain->name,
|
||||||
|
'model' => $captain->model,
|
||||||
|
'status' => $captain->status,
|
||||||
|
'error_count' => (int) $captain->error_count,
|
||||||
|
] : null,
|
||||||
|
'clearance' => cache()->get('port.clearance'),
|
||||||
|
])->header('Cache-Control', 'no-store');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the Capitania for the next port-movement clearance (fire and
|
||||||
|
* forget; one generation per 30 seconds internally).
|
||||||
|
*/
|
||||||
|
public function dispatchPort(): JsonResponse
|
||||||
|
{
|
||||||
|
if (! cache()->add('port.dispatch.lock', 1, 30)) {
|
||||||
|
return response()->json(['accepted' => false, 'reason' => 'cooldown'], 202);
|
||||||
|
}
|
||||||
|
|
||||||
|
$captain = AiAgent::query()->where('type', 'port_master')->first();
|
||||||
|
if ($captain === null) {
|
||||||
|
return response()->json(['message' => __('city.port_missing')], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(function (): void {
|
||||||
|
self::requestPortClearance();
|
||||||
|
})->afterResponse();
|
||||||
|
|
||||||
|
return response()->json(['accepted' => true], 202);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate one port-movement clearance with the Capitania model.
|
||||||
|
*
|
||||||
|
* Runs after the response (no queue worker). The structured fields
|
||||||
|
* (action/ship/berth) are stored with or without AI, so the client
|
||||||
|
* can couple real ship maneuvers to the authorisation; text uses
|
||||||
|
* simulated vessel data only — no user PII (RULES.md 2.2). Failures
|
||||||
|
* are counted for backoff (RULES.md 2.4) and logged to the agent's
|
||||||
|
* decision history (RULES.md 2.5).
|
||||||
|
*/
|
||||||
|
public static function requestPortClearance(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$captain = AiAgent::query()->where('type', 'port_master')->first();
|
||||||
|
if ($captain === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$maneuver = self::portManeuver();
|
||||||
|
|
||||||
|
$mayTry = false;
|
||||||
|
if (filled(config('ai.openrouter.api_key'))) {
|
||||||
|
$lastTry = (int) cache()->get('port.last_try', 0);
|
||||||
|
$mayTry = (int) $captain->error_count < 5 || $lastTry < time() - 300;
|
||||||
|
if ($mayTry) {
|
||||||
|
cache()->put('port.last_try', time(), now()->addHour());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = $mayTry ? self::askPort($captain, $maneuver) : null;
|
||||||
|
|
||||||
|
if ($text === null) {
|
||||||
|
$attempted = $mayTry || ! filled(config('ai.openrouter.api_key'));
|
||||||
|
$errors = (int) $captain->error_count + ($attempted ? 1 : 0);
|
||||||
|
$captain->update([
|
||||||
|
'status' => $errors >= 5 ? 'error' : 'active',
|
||||||
|
'error_count' => $errors,
|
||||||
|
]);
|
||||||
|
$text = sprintf(
|
||||||
|
'Capitania: %s (%s), %s no berço %s, autorizado.',
|
||||||
|
$maneuver['ship'],
|
||||||
|
$maneuver['company'],
|
||||||
|
$maneuver['action_text'],
|
||||||
|
$maneuver['berth']
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$captain->update([
|
||||||
|
'status' => 'active',
|
||||||
|
'error_count' => 0,
|
||||||
|
'last_think_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
cache()->put('port.clearance', [
|
||||||
|
'text' => $text,
|
||||||
|
'action' => $maneuver['action'],
|
||||||
|
'action_text' => $maneuver['action_text'],
|
||||||
|
'ship' => $maneuver['ship'],
|
||||||
|
'company' => $maneuver['company'],
|
||||||
|
'berth' => $maneuver['berth'],
|
||||||
|
'source' => $captain->error_count > 0 ? 'fallback' : 'model',
|
||||||
|
'at' => now()->toIso8601String(),
|
||||||
|
], now()->addHours(6));
|
||||||
|
|
||||||
|
$log = $captain->last_decision ?? [];
|
||||||
|
$log[] = ['role' => 'port', 'content' => $text, 'at' => now()->toIso8601String()];
|
||||||
|
$captain->update(['last_decision' => array_slice($log, -20)]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('Port clearance generation failed', ['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Random simulated maneuver for the next clearance (no real traffic).
|
||||||
|
*
|
||||||
|
* @return array{ship: string, company: string, action: string, action_text: string, berth: int}
|
||||||
|
*/
|
||||||
|
private static function portManeuver(): array
|
||||||
|
{
|
||||||
|
$ships = [
|
||||||
|
['CITY VOYAGER', 'City10 Cargo'],
|
||||||
|
['AZUL MAR', 'AzulCéu Cargo'],
|
||||||
|
['ATLÂNTICO CELESTE', 'NorteLine'],
|
||||||
|
];
|
||||||
|
[$ship, $company] = $ships[array_rand($ships)];
|
||||||
|
$actions = [
|
||||||
|
['berth', 'atracação'],
|
||||||
|
['depart', 'partida'],
|
||||||
|
];
|
||||||
|
[$action, $actionText] = $actions[array_rand($actions)];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ship' => $ship,
|
||||||
|
'company' => $company,
|
||||||
|
'action' => $action,
|
||||||
|
'action_text' => $actionText,
|
||||||
|
'berth' => random_int(1, 2),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the city from the query string or the active default.
|
* Resolve the city from the query string or the active default.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,620 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\AiAgent;
|
||||||
|
use App\Models\Character;
|
||||||
|
use App\Models\PersonTalk;
|
||||||
|
use App\Models\Store;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Db;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daily street conversations between the local character and another
|
||||||
|
* character (human peer) or an agentic NPC.
|
||||||
|
*
|
||||||
|
* GET /talk/person — conversation history (all days) used both by the
|
||||||
|
* chat UI and as the persistent context that keeps the thread of previous
|
||||||
|
* conversations. POST /talk/person — registers one message per day; for
|
||||||
|
* agent peers it also triggers the OpenRouter reply asynchronously.
|
||||||
|
*
|
||||||
|
* Peers without a live human session (seeded citizens, NPCs, idle demo
|
||||||
|
* accounts) answer through a generic city-inhabitant persona so chats
|
||||||
|
* never dead-end; a peer with a recent session is relayed as-is and is
|
||||||
|
* never impersonated.
|
||||||
|
*
|
||||||
|
* Only simulated city context and the dialogue itself are sent upstream,
|
||||||
|
* never registration data (RULES.md 2.2). Failures are retried with
|
||||||
|
* backoff bookkeeping (RULES.md 2.4) and decisions are persisted for
|
||||||
|
* audit (RULES.md 2.5).
|
||||||
|
*/
|
||||||
|
class PersonTalkController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Maximum messages returned for the UI and model context.
|
||||||
|
*
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
private const HISTORY_LIMIT = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-peer spacing between two reasoning calls, in seconds.
|
||||||
|
*
|
||||||
|
* Conversational replies are user-driven (one call per message), and
|
||||||
|
* this damper keeps rapid bursts from hammering the upstream API.
|
||||||
|
*
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
private const THINK_SPACING_SECONDS = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How recent a peer session must be to count as a live human.
|
||||||
|
*
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
private const LIVE_PEER_SECONDS = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conversation history (newest last) plus the peer summary.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$me = Character::ensureForUser($request->user());
|
||||||
|
[$kind, $peer] = $this->resolvePeer($request);
|
||||||
|
|
||||||
|
$rows = PersonTalk::conversation($me->id, $kind, (int) $peer->id)
|
||||||
|
->orderBy('id')
|
||||||
|
->limit(self::HISTORY_LIMIT)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$messages = $rows->map(fn (PersonTalk $talk): array => [
|
||||||
|
'id' => (int) $talk->id,
|
||||||
|
'from' => (int) $talk->from_character_id === (int) $me->id ? 'me' : 'them',
|
||||||
|
'body' => $talk->body,
|
||||||
|
'at' => $talk->created_at?->toIso8601String(),
|
||||||
|
'talked_on' => $talk->talked_on?->toDateString(),
|
||||||
|
'fallback' => (bool) $talk->is_fallback,
|
||||||
|
])->values();
|
||||||
|
|
||||||
|
$last = $messages->last();
|
||||||
|
$awaitingMe = $last !== null && $last['from'] === 'me';
|
||||||
|
$thinking = $kind === PersonTalk::KIND_AGENT
|
||||||
|
? $awaitingMe
|
||||||
|
: ($awaitingMe && cache()->has(self::pendingKey((int) $peer->id)));
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'me' => ['character_id' => (int) $me->id, 'name' => $me->name],
|
||||||
|
'peer' => [
|
||||||
|
'kind' => $kind,
|
||||||
|
'id' => (int) $peer->id,
|
||||||
|
'name' => $peer->name,
|
||||||
|
'status' => $kind === PersonTalk::KIND_AGENT ? $peer->status : 'active',
|
||||||
|
],
|
||||||
|
'messages' => $messages,
|
||||||
|
'thinking' => $thinking,
|
||||||
|
])->header('Cache-Control', 'no-store');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register one message of the day and reply when nobody human is
|
||||||
|
* manning the other side.
|
||||||
|
*/
|
||||||
|
public function send(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$me = Character::ensureForUser($request->user());
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'message' => ['required', 'string', 'max:1000'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
[$kind, $peer] = $this->resolvePeer($request);
|
||||||
|
|
||||||
|
if ($kind === PersonTalk::KIND_CHARACTER && (int) $peer->id === (int) $me->id) {
|
||||||
|
return response()->json(['message' => __('city.social_self')], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$talk = PersonTalk::create([
|
||||||
|
'from_character_id' => $me->id,
|
||||||
|
'to_character_id' => $kind === PersonTalk::KIND_CHARACTER ? $peer->id : null,
|
||||||
|
'to_agent_id' => $kind === PersonTalk::KIND_AGENT ? $peer->id : null,
|
||||||
|
'body' => $data['message'],
|
||||||
|
'talked_on' => now()->toDateString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$talkId = (int) $talk->id;
|
||||||
|
|
||||||
|
if ($kind === PersonTalk::KIND_AGENT) {
|
||||||
|
/** @var AiAgent $peer */
|
||||||
|
$peer->update(['status' => 'thinking']);
|
||||||
|
|
||||||
|
// Answer after the HTTP response so the chat stays snappy;
|
||||||
|
// runs in-process (no queue worker required).
|
||||||
|
dispatch(function () use ($talkId): void {
|
||||||
|
self::think($talkId);
|
||||||
|
})->afterResponse();
|
||||||
|
} elseif (! self::characterPeerIsLive($peer)) {
|
||||||
|
// No live human on the other side (seeded citizen, NPC child,
|
||||||
|
// demo account): answer through the generic persona so the
|
||||||
|
// conversation keeps flowing.
|
||||||
|
/** @var Character $peer */
|
||||||
|
cache()->put(self::pendingKey((int) $peer->id), 1, now()->addSeconds(60));
|
||||||
|
|
||||||
|
dispatch(function () use ($talkId): void {
|
||||||
|
self::thinkAsCitizen($talkId);
|
||||||
|
})->afterResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'id' => (int) $talk->id,
|
||||||
|
'from' => 'me',
|
||||||
|
'body' => $talk->body,
|
||||||
|
'at' => $talk->created_at?->toIso8601String(),
|
||||||
|
'talked_on' => $talk->talked_on->toDateString(),
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the agent reply for a pending talk message.
|
||||||
|
*
|
||||||
|
* Public static so it can be exercised directly in tests with a
|
||||||
|
* faked HTTP client. Reads the whole daily conversation from the
|
||||||
|
* database, which keeps the thread of previous days intact.
|
||||||
|
*/
|
||||||
|
public static function think(int $talkId): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$talk = PersonTalk::query()->find($talkId);
|
||||||
|
if ($talk === null || $talk->to_agent_id === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$agent = AiAgent::query()->find($talk->to_agent_id);
|
||||||
|
$me = Character::query()->find($talk->from_character_id);
|
||||||
|
if ($agent === null || $me === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! self::acquireThinkLock('agent.'.$agent->id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Another reply already landed after this message (serialised
|
||||||
|
// bursts) — nothing left to answer.
|
||||||
|
$answered = PersonTalk::conversation($me->id, PersonTalk::KIND_AGENT, (int) $agent->id)
|
||||||
|
->where('from_agent_id', $agent->id)
|
||||||
|
->where('id', '>', $talk->id)
|
||||||
|
->exists();
|
||||||
|
if ($answered) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! filled(config('ai.openrouter.api_key'))) {
|
||||||
|
self::recordAgentFailure($agent);
|
||||||
|
self::storeReply(
|
||||||
|
['from_agent_id' => $agent->id, 'to_character_id' => $me->id],
|
||||||
|
__('city.chat_error'),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$system = ($agent->system_prompt ?: "Você é {$agent->name}, um habitante da cidade virtual City10.")
|
||||||
|
."\n\nContexto simulado da cidade: ".json_encode(self::simulatedCityContext(), JSON_UNESCAPED_UNICODE)
|
||||||
|
."\nAs mensagens abaixo são o histórico da conversa de rua com este cidadão, incluindo dias anteriores."
|
||||||
|
.' Mantenha o fio da meada: referencie o que já foi dito quando fizer sentido.'
|
||||||
|
."\nResponda de forma curta e natural, em português. Não invente dados pessoais de usuários.";
|
||||||
|
|
||||||
|
$model = self::resolveModel($agent->model, $agent->name, (int) $agent->id);
|
||||||
|
$reply = self::askModel($model, $system, $me->id, $talk);
|
||||||
|
if ($reply === null) {
|
||||||
|
self::recordAgentFailure($agent);
|
||||||
|
self::storeReply(
|
||||||
|
['from_agent_id' => $agent->id, 'to_character_id' => $me->id],
|
||||||
|
__('city.chat_error'),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::storeReply(
|
||||||
|
['from_agent_id' => $agent->id, 'to_character_id' => $me->id],
|
||||||
|
$reply
|
||||||
|
);
|
||||||
|
|
||||||
|
$historyLog = $agent->last_decision ?? [];
|
||||||
|
$historyLog[] = ['role' => 'user', 'content' => $talk->body, 'at' => $talk->created_at?->toIso8601String()];
|
||||||
|
$historyLog[] = ['role' => 'assistant', 'content' => $reply, 'at' => now()->toIso8601String()];
|
||||||
|
|
||||||
|
$agent->update([
|
||||||
|
'last_decision' => array_slice($historyLog, -20),
|
||||||
|
'status' => 'active',
|
||||||
|
'error_count' => 0,
|
||||||
|
'last_think_at' => now(),
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Person talk think failed', ['talk' => $talkId, 'error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a reply on behalf of a character peer that has no live
|
||||||
|
* human behind it, using an anonymous city-inhabitant persona.
|
||||||
|
*
|
||||||
|
* The payload never includes registration data — no names, e-mails
|
||||||
|
* or any other PII (RULES.md 2.2); only simulated city context and
|
||||||
|
* the dialogue itself travel upstream.
|
||||||
|
*/
|
||||||
|
public static function thinkAsCitizen(int $talkId): void
|
||||||
|
{
|
||||||
|
$pendingKey = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$talk = PersonTalk::query()->find($talkId);
|
||||||
|
if ($talk === null || $talk->to_character_id === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$citizen = Character::query()->find($talk->to_character_id);
|
||||||
|
$me = Character::query()->find($talk->from_character_id);
|
||||||
|
if ($citizen === null || $me === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pendingKey = self::pendingKey((int) $citizen->id);
|
||||||
|
$replySides = [
|
||||||
|
'from_character_id' => $citizen->id,
|
||||||
|
'to_character_id' => $me->id,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (! self::acquireThinkLock('character.'.$citizen->id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A human (or a serialised sibling job) already answered.
|
||||||
|
$answered = PersonTalk::conversation($me->id, PersonTalk::KIND_CHARACTER, (int) $citizen->id)
|
||||||
|
->where('from_character_id', $citizen->id)
|
||||||
|
->where('id', '>', $talk->id)
|
||||||
|
->exists();
|
||||||
|
if ($answered) {
|
||||||
|
cache()->forget($pendingKey);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! filled(config('ai.openrouter.api_key'))) {
|
||||||
|
self::storeReply($replySides, __('city.chat_error'), true);
|
||||||
|
cache()->forget($pendingKey);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persona plays the citizen in-world: no registration data
|
||||||
|
// travels upstream (RULES.md 2.2) — only simulated city state,
|
||||||
|
// the in-world identity broadcast to every client, and the
|
||||||
|
// dialogue itself.
|
||||||
|
$system = 'Você é um morador da cidade virtual City10, conversando na rua com alguém que chegou perto de você.'
|
||||||
|
."\n\n".self::identityBlock($citizen)
|
||||||
|
."\n\nContexto simulado da cidade: ".json_encode(self::simulatedCityContext(), JSON_UNESCAPED_UNICODE)
|
||||||
|
."\nAs mensagens abaixo são o histórico real da conversa, inclusive de dias anteriores:"
|
||||||
|
.' mantenha o fio da meada e referencie o que já foi dito quando couber.'
|
||||||
|
."\nSe te chamarem pelo nome na conversa, responda naturalmente — ele é seu; não se negue a responder."
|
||||||
|
."\nFrases curtas e naturais, tom de vizinho conversando na rua; nada de linguagem de atendimento, como 'como posso ajudar'."
|
||||||
|
."\nNunca forneça nem invente e-mail, senha, endereço, telefone ou dados de conta de ninguém."
|
||||||
|
."\nResponda em português.";
|
||||||
|
|
||||||
|
$model = self::resolveModel($citizen->ai_model, $citizen->name, (int) $citizen->id);
|
||||||
|
$reply = self::askModel($model, $system, $me->id, $talk);
|
||||||
|
if ($reply === null) {
|
||||||
|
self::storeReply($replySides, __('city.chat_error'), true);
|
||||||
|
cache()->forget($pendingKey);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::storeReply($replySides, $reply);
|
||||||
|
cache()->forget($pendingKey);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Citizen talk think failed', ['talk' => $talkId, 'error' => $e->getMessage()]);
|
||||||
|
if ($pendingKey !== null) {
|
||||||
|
cache()->forget($pendingKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and resolve the peer query parameters (kind + id).
|
||||||
|
*
|
||||||
|
* @return array{0: string, 1: AiAgent|Character}
|
||||||
|
*/
|
||||||
|
private function resolvePeer(Request $request): array
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'kind' => ['required', 'in:agent,character'],
|
||||||
|
'peer' => ['required', 'integer', 'min:1'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$peer = $data['kind'] === PersonTalk::KIND_AGENT
|
||||||
|
? AiAgent::query()->findOrFail($data['peer'])
|
||||||
|
: Character::query()->findOrFail($data['peer']);
|
||||||
|
|
||||||
|
return [$data['kind'], $peer];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a live human session currently drives this character.
|
||||||
|
*
|
||||||
|
* Based on the sessions table: a peer with a session touched within
|
||||||
|
* LIVE_PEER_SECONDS answers for itself and is never impersonated.
|
||||||
|
* Everything else (seeded citizens, NPC children, demo accounts) is
|
||||||
|
* answered by the generic persona.
|
||||||
|
*/
|
||||||
|
private static function characterPeerIsLive(Character $peer): bool
|
||||||
|
{
|
||||||
|
if ($peer->user_id === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$last = Db::table('sessions')
|
||||||
|
->where('user_id', $peer->user_id)
|
||||||
|
->max('last_activity');
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $last !== null
|
||||||
|
&& (int) $last >= now()->getTimestamp() - self::LIVE_PEER_SECONDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache key of a pending auto-reply for a character peer.
|
||||||
|
*/
|
||||||
|
private static function pendingKey(int $characterId): string
|
||||||
|
{
|
||||||
|
return 'talk.pending.c.'.$characterId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Space out reasoning calls per peer (RULES.md 2.3 in spirit: never
|
||||||
|
* repeat API calls at high frequency for the same counterpart).
|
||||||
|
*/
|
||||||
|
private static function acquireThinkLock(string $scope): bool
|
||||||
|
{
|
||||||
|
$lockKey = 'talk.think.'.$scope;
|
||||||
|
$deadline = microtime(true) + (self::THINK_SPACING_SECONDS * 2);
|
||||||
|
while (! cache()->add($lockKey, 1, self::THINK_SPACING_SECONDS) && microtime(true) < $deadline) {
|
||||||
|
usleep(400000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulated city snapshot sent upstream — no PII (RULES.md 2.2).
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function simulatedCityContext(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'stores' => Store::query()
|
||||||
|
->get(['name', 'type', 'is_open'])
|
||||||
|
->map(fn ($s): array => ['name' => $s->name, 'type' => $s->type, 'open' => $s->is_open])
|
||||||
|
->all(),
|
||||||
|
'clock' => now()->toTimeString(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the OpenRouter model for a character or agent.
|
||||||
|
*
|
||||||
|
* Priority: entity column (characters.ai_model / agents.model) →
|
||||||
|
* environment map (AI_CHARACTER_MODELS by id then by name) →
|
||||||
|
* deterministic pool slot (AI_MODELS, stable per id) → AI_DEFAULT_MODEL
|
||||||
|
* → first allow-listed model. Everything is validated against
|
||||||
|
* config('ai.allowed_models') (RULES.md 2.1).
|
||||||
|
*/
|
||||||
|
public static function resolveModel(?string $columnModel, string $name, int $id): string
|
||||||
|
{
|
||||||
|
$allowed = config('ai.allowed_models', []);
|
||||||
|
|
||||||
|
$map = config('ai.character_models', []);
|
||||||
|
$candidates = [
|
||||||
|
$columnModel,
|
||||||
|
$map[(string) $id] ?? null,
|
||||||
|
$map[strtolower($name)] ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($candidates as $candidate) {
|
||||||
|
if (is_string($candidate) && in_array($candidate, $allowed, true)) {
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$pool = array_values(array_filter(
|
||||||
|
(array) config('ai.models', []),
|
||||||
|
static fn ($model): bool => is_string($model) && in_array($model, $allowed, true)
|
||||||
|
));
|
||||||
|
if ($pool !== []) {
|
||||||
|
return $pool[$id % count($pool)];
|
||||||
|
}
|
||||||
|
|
||||||
|
$default = config('ai.default_model');
|
||||||
|
if (is_string($default) && in_array($default, $allowed, true)) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) ($allowed[0] ?? $default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identity/personality block injected into the persona prompt.
|
||||||
|
*
|
||||||
|
* Only in-world data goes upstream: the display name already shown
|
||||||
|
* above heads and broadcast by the presence API, plus the profile
|
||||||
|
* fields below. Registration data (e-mail, password, account data)
|
||||||
|
* is never included (RULES.md 2.2).
|
||||||
|
*/
|
||||||
|
private static function identityBlock(Character $character): string
|
||||||
|
{
|
||||||
|
$lines = [
|
||||||
|
'Identidade do seu personagem — aja sempre de acordo com ela:',
|
||||||
|
'- Nome: '.$character->name,
|
||||||
|
'- Idade: '.($character->ai_age ?? 'não informada'),
|
||||||
|
'- Sexo: '.($character->ai_gender ?? 'não informado'),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (filled($character->ai_bio)) {
|
||||||
|
$lines[] = '- Bio: '.$character->ai_bio;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filled($character->ai_personality)) {
|
||||||
|
$lines[] = '- Personalidade: '.$character->ai_personality;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n", $lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call OpenRouter with the given system prompt and the full daily
|
||||||
|
* conversation of the pair. Returns null on any failure.
|
||||||
|
*/
|
||||||
|
private static function askModel(string $model, string $system, int $meCharacterId, PersonTalk $talk): ?string
|
||||||
|
{
|
||||||
|
$peerKind = $talk->to_agent_id !== null ? PersonTalk::KIND_AGENT : PersonTalk::KIND_CHARACTER;
|
||||||
|
$peerId = (int) ($talk->to_agent_id ?? $talk->to_character_id);
|
||||||
|
|
||||||
|
$history = PersonTalk::conversation($meCharacterId, $peerKind, $peerId)
|
||||||
|
// Error placeholders are UI notes, never part of the dialogue.
|
||||||
|
// Legacy rows (stored before the flag existed) are matched by
|
||||||
|
// their localized body so old apologies stay excluded too.
|
||||||
|
->where('is_fallback', false)
|
||||||
|
->whereNotIn('body', self::fallbackBodies())
|
||||||
|
->orderBy('id')
|
||||||
|
->limit(80)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$messages = [['role' => 'system', 'content' => $system]];
|
||||||
|
foreach ($history as $row) {
|
||||||
|
$role = (int) $row->from_character_id === $meCharacterId ? 'user' : 'assistant';
|
||||||
|
$lastIdx = count($messages) - 1;
|
||||||
|
if ($lastIdx > 0 && $messages[$lastIdx]['role'] === $role) {
|
||||||
|
$messages[$lastIdx]['content'] .= "\n".$row->body;
|
||||||
|
} else {
|
||||||
|
$messages[] = ['role' => $role, 'content' => $row->body];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap the upstream wait below PHP's default execution limit so
|
||||||
|
// the after-response job is never killed mid-flight.
|
||||||
|
$timeout = min((int) config('ai.openrouter.timeout', 30), 20);
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($reply) && trim($reply) !== '') {
|
||||||
|
return $reply;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Localized error-fallback bodies (current and past locales).
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private static function fallbackBodies(): array
|
||||||
|
{
|
||||||
|
return array_values(array_unique(array_filter([
|
||||||
|
__('city.chat_error'),
|
||||||
|
__('city.chat_error', [], 'en'),
|
||||||
|
__('city.chat_error', [], 'es'),
|
||||||
|
])));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist one side of the conversation.
|
||||||
|
*
|
||||||
|
* @param array<string, int|null> $sides from/to identifiers
|
||||||
|
* @param bool $isFallback stored error placeholder (kept out of model context)
|
||||||
|
*/
|
||||||
|
private static function storeReply(array $sides, string $body, bool $isFallback = false): PersonTalk
|
||||||
|
{
|
||||||
|
return PersonTalk::create($sides + [
|
||||||
|
'body' => $body,
|
||||||
|
'talked_on' => now()->toDateString(),
|
||||||
|
'is_fallback' => $isFallback,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count a failed attempt against the agent (RULES.md 2.4).
|
||||||
|
*/
|
||||||
|
private static function recordAgentFailure(AiAgent $agent): void
|
||||||
|
{
|
||||||
|
$errors = (int) $agent->error_count + 1;
|
||||||
|
$agent->update([
|
||||||
|
'status' => $errors >= 5 ? 'error' : 'active',
|
||||||
|
'error_count' => $errors,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
* ticker mutates needs and job progress continuously while nobody is online.
|
* ticker mutates needs and job progress continuously while nobody is online.
|
||||||
*/
|
*/
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'user_id', 'city_id', 'name', 'skin', 'hair', 'shirt',
|
'user_id', 'city_id', 'name', 'ai_model', 'ai_age', 'ai_gender',
|
||||||
|
'ai_personality', 'ai_bio', 'skin', 'hair', 'shirt',
|
||||||
'pos_x', 'pos_y', 'pos_z', 'money', 'energy', 'hunger', 'thirst', 'health',
|
'pos_x', 'pos_y', 'pos_z', 'money', 'energy', 'hunger', 'thirst', 'health',
|
||||||
'state', 'job_id', 'house_id', 'education', 'exercise_count', 'is_online', 'last_tick_at',
|
'state', 'job_id', 'house_id', 'education', 'exercise_count', 'is_online', 'last_tick_at',
|
||||||
'hospitalized_until', 'face', 'beard', 'accessory', 'ride_kind', 'ride_until',
|
'hospitalized_until', 'face', 'beard', 'accessory', 'ride_kind', 'ride_until',
|
||||||
@@ -27,6 +28,20 @@ class Character extends Model
|
|||||||
/** @use HasFactory<CharacterFactory, $this> */
|
/** @use HasFactory<CharacterFactory, $this> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default personality pool for characters created without one.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
public const AI_PERSONALITIES = [
|
||||||
|
'Extrovertido e curioso, adora puxar conversa com quem passa.',
|
||||||
|
'Tranquilo e observador, fala pouco mas escuta tudo com atenção.',
|
||||||
|
'Bem-humorado e espirituoso, brinca com todo mundo por perto.',
|
||||||
|
'Sério e direto, vai ao ponto sem rodeios.',
|
||||||
|
'Gentil e prestativo, sempre oferece ajuda antes de perguntarem.',
|
||||||
|
'Aventureiro e barulhento, vive contando histórias da cidade.',
|
||||||
|
];
|
||||||
|
|
||||||
public const STATE_AWAKE = 'awake';
|
public const STATE_AWAKE = 'awake';
|
||||||
|
|
||||||
public const STATE_SLEEPING = 'sleeping';
|
public const STATE_SLEEPING = 'sleeping';
|
||||||
@@ -176,6 +191,8 @@ class Character extends Model
|
|||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'city_id' => $city->id,
|
'city_id' => $city->id,
|
||||||
'name' => $firstName,
|
'name' => $firstName,
|
||||||
|
'ai_age' => random_int(18, 68),
|
||||||
|
'ai_personality' => self::AI_PERSONALITIES[array_rand(self::AI_PERSONALITIES)],
|
||||||
'skin' => random_int(1, 4),
|
'skin' => random_int(1, 4),
|
||||||
'hair' => random_int(1, 4),
|
'hair' => random_int(1, 4),
|
||||||
'shirt' => random_int(1, 5),
|
'shirt' => random_int(1, 5),
|
||||||
@@ -220,6 +237,7 @@ class Character extends Model
|
|||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'ai_age' => 'integer',
|
||||||
'pos_x' => 'float',
|
'pos_x' => 'float',
|
||||||
'pos_y' => 'float',
|
'pos_y' => 'float',
|
||||||
'pos_z' => 'float',
|
'pos_z' => 'float',
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One entry of a daily street conversation.
|
||||||
|
*
|
||||||
|
* Exactly one "from" side (character or agent) and one "to" side are set
|
||||||
|
* per row. The full ordered set of rows between a pair is the persistent
|
||||||
|
* context used to resume the conversation on later days.
|
||||||
|
*/
|
||||||
|
#[Fillable([
|
||||||
|
'from_character_id', 'from_agent_id',
|
||||||
|
'to_character_id', 'to_agent_id',
|
||||||
|
'body', 'talked_on', 'is_fallback',
|
||||||
|
])]
|
||||||
|
class PersonTalk extends Model
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Peer kind addressing an agentic NPC.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
public const KIND_AGENT = 'agent';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Peer kind addressing another user's character.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
public const KIND_CHARACTER = 'character';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attributes that should be cast to native types.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'talked_on' => 'date',
|
||||||
|
'is_fallback' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Character that sent the message.
|
||||||
|
*
|
||||||
|
* @return BelongsTo<Character, $this>
|
||||||
|
*/
|
||||||
|
public function fromCharacter(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Character::class, 'from_character_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent that sent the message.
|
||||||
|
*
|
||||||
|
* @return BelongsTo<AiAgent, $this>
|
||||||
|
*/
|
||||||
|
public function fromAgent(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(AiAgent::class, 'from_agent_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Character that received the message.
|
||||||
|
*
|
||||||
|
* @return BelongsTo<Character, $this>
|
||||||
|
*/
|
||||||
|
public function toCharacter(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Character::class, 'to_character_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent that received the message.
|
||||||
|
*
|
||||||
|
* @return BelongsTo<AiAgent, $this>
|
||||||
|
*/
|
||||||
|
public function toAgent(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(AiAgent::class, 'to_agent_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordered conversation rows between a character and a peer.
|
||||||
|
*
|
||||||
|
* @param int $characterId the local character
|
||||||
|
* @param string $peerKind PersonTalk::KIND_AGENT or PersonTalk::KIND_CHARACTER
|
||||||
|
* @param int $peerId agent id or character id of the peer
|
||||||
|
* @return Builder<self>
|
||||||
|
*/
|
||||||
|
public static function conversation(int $characterId, string $peerKind, int $peerId): Builder
|
||||||
|
{
|
||||||
|
return self::query()->where(function (Builder $outer) use ($characterId, $peerKind, $peerId): void {
|
||||||
|
if ($peerKind === self::KIND_AGENT) {
|
||||||
|
$outer->where(function (Builder $q) use ($characterId, $peerId): void {
|
||||||
|
$q->where('from_character_id', $characterId)->where('to_agent_id', $peerId);
|
||||||
|
})->orWhere(function (Builder $q) use ($characterId, $peerId): void {
|
||||||
|
$q->where('from_agent_id', $peerId)->where('to_character_id', $characterId);
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$outer->where(function (Builder $q) use ($characterId, $peerId): void {
|
||||||
|
$q->where('from_character_id', $characterId)->where('to_character_id', $peerId);
|
||||||
|
})->orWhere(function (Builder $q) use ($characterId, $peerId): void {
|
||||||
|
$q->where('from_character_id', $peerId)->where('to_character_id', $characterId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Models\City;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* East-shore beach geometry shared by the world payload, the leisure
|
||||||
|
* action validation and the position clamp.
|
||||||
|
*
|
||||||
|
* Every value derives from the city bounds so the sand always starts just
|
||||||
|
* past the eastern road grid, whatever the city size is.
|
||||||
|
*/
|
||||||
|
class Beach
|
||||||
|
{
|
||||||
|
/** Gap between the last grid road and the sand. */
|
||||||
|
public const APPROACH = 4.0;
|
||||||
|
|
||||||
|
/** Sand depth from the approach line to the shoreline. */
|
||||||
|
public const SAND_DEPTH = 60.0;
|
||||||
|
|
||||||
|
/** Sea depth beyond the shoreline. */
|
||||||
|
public const SEA_DEPTH = 90.0;
|
||||||
|
|
||||||
|
/** Half of the beach span along Z (covers the whole layout). */
|
||||||
|
public const Z_HALF = 160.0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eastern edge of the road grid (same derivation the layout uses).
|
||||||
|
*/
|
||||||
|
public static function layoutMaxX(City $city): float
|
||||||
|
{
|
||||||
|
return (float) (ceil(((float) $city->bounds_max_x) / 40) * 40 + 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the sand begins (east of the grid).
|
||||||
|
*/
|
||||||
|
public static function sandFrom(City $city): float
|
||||||
|
{
|
||||||
|
return self::layoutMaxX($city) + self::APPROACH;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shoreline (sand meets sea).
|
||||||
|
*/
|
||||||
|
public static function sandTo(City $city): float
|
||||||
|
{
|
||||||
|
return self::sandFrom($city) + self::SAND_DEPTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Far edge of the sea.
|
||||||
|
*/
|
||||||
|
public static function seaTo(City $city): float
|
||||||
|
{
|
||||||
|
return self::sandTo($city) + self::SEA_DEPTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geometry descriptor sent to the world payload (single source of
|
||||||
|
* truth for the client-side beach build).
|
||||||
|
*
|
||||||
|
* @return array{sand_from: float, sand_to: float, sea_to: float, z_min: float, z_max: float}
|
||||||
|
*/
|
||||||
|
public static function geometry(City $city): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'sand_from' => self::sandFrom($city),
|
||||||
|
'sand_to' => self::sandTo($city),
|
||||||
|
'sea_to' => self::seaTo($city),
|
||||||
|
'z_min' => -self::Z_HALF,
|
||||||
|
'z_max' => self::Z_HALF,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a point lies on the leisure beach (sand up to the shoreline).
|
||||||
|
*/
|
||||||
|
public static function isOnBeach(City $city, float $x, float $z): bool
|
||||||
|
{
|
||||||
|
return $x >= self::sandFrom($city) - 1
|
||||||
|
&& $x <= self::sandTo($city) + 2
|
||||||
|
&& $z >= -self::Z_HALF
|
||||||
|
&& $z <= self::Z_HALF;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Models\City;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sea port on the north-east coast (past the beach, on the same east
|
||||||
|
* shoreline).
|
||||||
|
*
|
||||||
|
* The port occupies only the FAR HALF of that side (z −430..−290): the
|
||||||
|
* near half (z −290..−150) is intentionally left empty so the city can
|
||||||
|
* keep growing north/east without ever colliding with the port lands.
|
||||||
|
*
|
||||||
|
* Geometry derives from the Beach waterline so the quay always meets
|
||||||
|
* the sea, and feeds the world payload: quay, container yard, terminal,
|
||||||
|
* gantry cranes, berths and the fleet descriptions. Ships' movements are
|
||||||
|
* authorised by the port AI agent (Capitão do Porto) through
|
||||||
|
* /city/port/dispatch — same pattern as the airports.
|
||||||
|
*/
|
||||||
|
class Port
|
||||||
|
{
|
||||||
|
/** West edge of the walkable port strip (east of the city bounds). */
|
||||||
|
public const VISIT_MIN_X = 150.0;
|
||||||
|
|
||||||
|
/** Visit Z range: from the port up to the start of the beach sand. */
|
||||||
|
public const VISIT_MIN_Z = -430.0;
|
||||||
|
|
||||||
|
public const VISIT_MAX_Z = -290.0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full port descriptor for the world payload.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function geometry(City $city): array
|
||||||
|
{
|
||||||
|
$waterline = Beach::sandTo($city); // 264 for the shipped bounds
|
||||||
|
$gridEast = (float) (ceil(((float) $city->bounds_max_x) / 40) * 40 + 40); // 200
|
||||||
|
|
||||||
|
$quayX = $waterline + 2; // quay straddles the shore
|
||||||
|
$yardWest = $gridEast + 4;
|
||||||
|
$yardEast = $waterline - 6;
|
||||||
|
$yardCenterX = ($yardWest + $yardEast) / 2;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'visit' => [
|
||||||
|
'min_x' => self::VISIT_MIN_X,
|
||||||
|
'max_x' => $quayX + 2, // walk to the quay edge (266)
|
||||||
|
'min_z' => self::VISIT_MIN_Z,
|
||||||
|
'max_z' => self::VISIT_MAX_Z,
|
||||||
|
],
|
||||||
|
'water' => [
|
||||||
|
// The east sea extends north to cover the port basin
|
||||||
|
// (initBeach widens its plane down to this Z).
|
||||||
|
'z_min' => self::VISIT_MIN_Z - 10,
|
||||||
|
],
|
||||||
|
// Far-half layout (zone z −430..−290): terminal at the city
|
||||||
|
// (south) gate, yard behind it, quay along the shore.
|
||||||
|
'quay' => ['center' => [$quayX, -360.0], 'size' => [8.0, 130.0]],
|
||||||
|
'yard' => ['center' => [$yardCenterX, -362.0], 'size' => [$yardEast - $yardWest, 116.0]],
|
||||||
|
'terminal' => ['center' => [$yardCenterX, -297.0], 'size' => [46.0, 14.0]],
|
||||||
|
'cranes' => [
|
||||||
|
['center' => [$quayX, -330.0]],
|
||||||
|
['center' => [$quayX, -395.0]],
|
||||||
|
],
|
||||||
|
// Berth centres spaced so full hulls never touch: 60-unit
|
||||||
|
// container ship + 88-unit liner need >=82 between centres.
|
||||||
|
'berths' => [
|
||||||
|
['id' => 1, 'center' => [$quayX + 16, -310.0]],
|
||||||
|
['id' => 2, 'center' => [$quayX + 20, -393.0]],
|
||||||
|
],
|
||||||
|
'ships' => [
|
||||||
|
['id' => 'c1', 'name' => 'CITY VOYAGER', 'company' => 'City10 Cargo', 'kind' => 'container', 'berth' => 1],
|
||||||
|
['id' => 'c2', 'name' => 'AZUL MAR', 'company' => 'AzulCéu Cargo', 'kind' => 'container', 'berth' => null],
|
||||||
|
['id' => 'l1', 'name' => 'ATLÂNTICO CELESTE', 'company' => 'NorteLine', 'kind' => 'liner', 'berth' => 2],
|
||||||
|
],
|
||||||
|
'boats' => [
|
||||||
|
['name' => 'Lancha V1'],
|
||||||
|
['name' => 'Lancha V2'],
|
||||||
|
['name' => 'Lancha V3'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a point is on the walkable port grounds/quay.
|
||||||
|
*/
|
||||||
|
public static function isOnPort(City $city, float $x, float $z): bool
|
||||||
|
{
|
||||||
|
$visit = self::geometry($city)['visit'];
|
||||||
|
|
||||||
|
return $x >= $visit['min_x']
|
||||||
|
&& $x <= $visit['max_x']
|
||||||
|
&& $z >= $visit['min_z']
|
||||||
|
&& $z <= $visit['max_z'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,33 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Character model map parser
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Turns AI_CHARACTER_MODELS entries such as "Maria=qwen/qwen3.7-flash:free"
|
||||||
|
| or "3=deepseek/deepseek-v4-flash-0731" into a lookup array keyed by
|
||||||
|
| lowercased character id or name. Invalid pairs are ignored.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
$parseCharacterModels = static function (string $raw): array {
|
||||||
|
$map = [];
|
||||||
|
|
||||||
|
foreach (explode(',', $raw) as $pair) {
|
||||||
|
$pair = trim($pair);
|
||||||
|
if ($pair === '' || ! str_contains($pair, '=')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$key, $model] = array_map('trim', explode('=', $pair, 2));
|
||||||
|
if ($key !== '' && $model !== '') {
|
||||||
|
$map[strtolower($key)] = $model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
};
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -50,8 +78,71 @@ return [
|
|||||||
'poolside/laguna-xs-2.1:free',
|
'poolside/laguna-xs-2.1:free',
|
||||||
'inclusionai/ling-3.0-flash-vl:free',
|
'inclusionai/ling-3.0-flash-vl:free',
|
||||||
'qwen/qwen3.7-flash:free',
|
'qwen/qwen3.7-flash:free',
|
||||||
|
// Verified working free chat models (live probe of the OpenRouter
|
||||||
|
// catalogue). Safe defaults for per-character assignment.
|
||||||
|
'deepseek/deepseek-v4-flash-0731',
|
||||||
|
'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',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Per-Character Model Pool (AI_MODELS)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Comma-separated allow-listed models distributed deterministically
|
||||||
|
| across characters (each character keeps a stable own model). Falls
|
||||||
|
| back to AI_DEFAULT_MODEL when empty.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'models' => array_values(array_filter(array_map(
|
||||||
|
'trim',
|
||||||
|
explode(',', (string) env('AI_MODELS', ''))
|
||||||
|
))),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Per-Character Model Map (AI_CHARACTER_MODELS)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Explicit "id or name = model" overrides from the environment, e.g.
|
||||||
|
| "Maria=liquid/lfm-2.5-2.6b:free,7=openrouter/free". Takes priority
|
||||||
|
| over the pool; entries outside the allow-list are ignored.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'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
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the "person_talks" table.
|
||||||
|
*
|
||||||
|
* Daily-registered street conversations between a player character and
|
||||||
|
* either another character (human peer) or an agentic NPC (AiAgent).
|
||||||
|
* Rows are the persistent context used to keep the thread of previous
|
||||||
|
* days when the pair talks again.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('person_talks', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('from_character_id')->nullable()->constrained('characters')->cascadeOnDelete();
|
||||||
|
$table->foreignId('from_agent_id')->nullable()->constrained('ai_agents')->cascadeOnDelete();
|
||||||
|
$table->foreignId('to_character_id')->nullable()->constrained('characters')->cascadeOnDelete();
|
||||||
|
$table->foreignId('to_agent_id')->nullable()->constrained('ai_agents')->cascadeOnDelete();
|
||||||
|
$table->string('body', 1000);
|
||||||
|
$table->date('talked_on');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['talked_on']);
|
||||||
|
$table->index(['from_character_id', 'to_character_id']);
|
||||||
|
$table->index(['from_character_id', 'to_agent_id']);
|
||||||
|
$table->index(['from_agent_id', 'to_character_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('person_talks');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the "is_fallback" flag to "person_talks".
|
||||||
|
*
|
||||||
|
* Error-fallback rows keep the chat from hanging, but they must never be
|
||||||
|
* fed back to the model as part of the conversation (an apology would
|
||||||
|
* pollute the persona's context and skew future replies).
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('person_talks', function (Blueprint $table): void {
|
||||||
|
$table->boolean('is_fallback')->default(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('person_talks', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('is_fallback');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the per-character AI profile to "characters".
|
||||||
|
*
|
||||||
|
* Every citizen can now carry its own model override plus the identity
|
||||||
|
* used by the conversational persona: age, gender, short biography and
|
||||||
|
* personality. Existing rows are backfilled with a deterministic default
|
||||||
|
* persona so no character speaks without one.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Personality pool used for the backfill (kept inline so this
|
||||||
|
* migration stays a self-contained snapshot).
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const PERSONALITIES = [
|
||||||
|
'Extrovertido e curioso, adora puxar conversa com quem passa.',
|
||||||
|
'Tranquilo e observador, fala pouco mas escuta tudo com atenção.',
|
||||||
|
'Bem-humorado e espirituoso, brinca com todo mundo por perto.',
|
||||||
|
'Sério e direto, vai ao ponto sem rodeios.',
|
||||||
|
'Gentil e prestativo, sempre oferece ajuda antes de perguntarem.',
|
||||||
|
'Aventureiro e barulhento, vive contando histórias da cidade.',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('characters', function (Blueprint $table): void {
|
||||||
|
$table->string('ai_model', 120)->nullable()->after('name');
|
||||||
|
$table->unsignedSmallInteger('ai_age')->nullable();
|
||||||
|
$table->string('ai_gender', 24)->nullable();
|
||||||
|
$table->string('ai_personality', 255)->nullable();
|
||||||
|
$table->string('ai_bio', 500)->nullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('characters')
|
||||||
|
->whereNull('ai_personality')
|
||||||
|
->orderBy('id')
|
||||||
|
->get()
|
||||||
|
->each(function (object $row): void {
|
||||||
|
DB::table('characters')->where('id', $row->id)->update([
|
||||||
|
'ai_age' => 18 + (7 * (int) $row->id) % 50,
|
||||||
|
'ai_personality' => self::PERSONALITIES[(int) $row->id % count(self::PERSONALITIES)],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('characters', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn(['ai_model', 'ai_age', 'ai_gender', 'ai_personality', 'ai_bio']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -119,7 +119,7 @@ class CityContentSeeder extends Seeder
|
|||||||
'description' => 'Material de construção e ferramentas.',
|
'description' => 'Material de construção e ferramentas.',
|
||||||
'type' => 'hardware',
|
'type' => 'hardware',
|
||||||
'owner_id' => $shopkeeper?->id,
|
'owner_id' => $shopkeeper?->id,
|
||||||
'pos_x' => 8, 'pos_y' => 0, 'pos_z' => 6,
|
'pos_x' => 11, 'pos_y' => 0, 'pos_z' => 10,
|
||||||
'width' => 10, 'height' => 7, 'depth' => 10,
|
'width' => 10, 'height' => 7, 'depth' => 10,
|
||||||
'is_open' => true,
|
'is_open' => true,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,15 +30,113 @@ class CityWorldSeeder extends Seeder
|
|||||||
['slug' => 'city10'],
|
['slug' => 'city10'],
|
||||||
[
|
[
|
||||||
'name' => 'City10',
|
'name' => 'City10',
|
||||||
'bounds_min_x' => -80,
|
// Performance-tuned size: 72 blocks (9 × 8) after the
|
||||||
'bounds_max_x' => 80,
|
// 40-unit road grid and its outer margin are derived from
|
||||||
'bounds_min_z' => -80,
|
// these bounds (layout −160..200 × −160..160).
|
||||||
'bounds_max_z' => 80,
|
'bounds_min_x' => -120,
|
||||||
|
'bounds_max_x' => 160,
|
||||||
|
'bounds_min_z' => -120,
|
||||||
|
'bounds_max_z' => 120,
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
'expansions' => 0,
|
'expansions' => 0,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- 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],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Humanoid worker robots (walk the city like pedestrians) ---
|
||||||
|
$robotSpawns = [
|
||||||
|
['Robô TR-01', 12, -16],
|
||||||
|
['Robô TR-02', -18, 10],
|
||||||
|
['Robô TR-03', 100, -14],
|
||||||
|
['Robô TR-04', -60, 14],
|
||||||
|
['Robô TR-05', 60, 26],
|
||||||
|
];
|
||||||
|
$robotModel = (string) config('ai.default_model');
|
||||||
|
if (! in_array($robotModel, config('ai.allowed_models', []), true)) {
|
||||||
|
$robotModel = 'deepseek/deepseek-v4-flash-0731';
|
||||||
|
}
|
||||||
|
foreach ($robotSpawns as [$name, $rx, $rz]) {
|
||||||
|
AiAgent::updateOrCreate(
|
||||||
|
['name' => $name],
|
||||||
|
[
|
||||||
|
'type' => 'worker_robot',
|
||||||
|
'model' => $robotModel,
|
||||||
|
'system_prompt' => 'Você é '.$name.', um robô humanoide trabalhador da cidade '
|
||||||
|
.'virtual City10. Você circula pelas ruas transportando materiais e ajudando '
|
||||||
|
.'nas lojas. Fale de forma curta, educada e um pouco robótica, sempre em '
|
||||||
|
.'português, sem inventar dados pessoais de usuários.',
|
||||||
|
'current_goal' => 'Circular entre as lojas auxiliando nas tarefas do dia',
|
||||||
|
'status' => 'active',
|
||||||
|
'pos_x' => $rx,
|
||||||
|
'pos_y' => 0,
|
||||||
|
'pos_z' => $rz,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Port staff: the Capitania (AI clearances) + dock workers ----
|
||||||
|
$portModel = (string) config('ai.default_model');
|
||||||
|
if (! in_array($portModel, config('ai.allowed_models', []), true)) {
|
||||||
|
$portModel = 'deepseek/deepseek-v4-flash-0731';
|
||||||
|
}
|
||||||
|
AiAgent::updateOrCreate(
|
||||||
|
['name' => 'Capitão do Porto'],
|
||||||
|
[
|
||||||
|
'type' => 'port_master',
|
||||||
|
'model' => $portModel,
|
||||||
|
'system_prompt' => 'Você é o Capitão do Porto da cidade virtual City10. Autorize '
|
||||||
|
.'atracações, partidas e manobras de navios com linguagem marítima formal e curta, '
|
||||||
|
.'sempre em português, citando o navio, a companhia e o berço. Nunca forneça '
|
||||||
|
.'e-mail, senha ou dados de conta de ninguém.',
|
||||||
|
'current_goal' => 'Coordenar as movimentações dos navios nos berços com segurança',
|
||||||
|
'status' => 'active',
|
||||||
|
'pos_x' => 256,
|
||||||
|
'pos_y' => 0,
|
||||||
|
'pos_z' => -298,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
foreach ([['Estivador 01', 264, -340], ['Estivador 02', 240, -360]] as [$name, $px, $pz]) {
|
||||||
|
AiAgent::updateOrCreate(
|
||||||
|
['name' => $name],
|
||||||
|
[
|
||||||
|
'type' => 'dock_worker',
|
||||||
|
'model' => $portModel,
|
||||||
|
'system_prompt' => 'Você é '.$name.', estivador do Porto City10 que trabalha no cais '
|
||||||
|
.'movimentando containers. Fale de forma direta e trabalhadora, em português, '
|
||||||
|
.'sem inventar dados pessoais de usuários.',
|
||||||
|
'current_goal' => 'Apoiar a movimentação de containers no pátio e no cais',
|
||||||
|
'status' => 'active',
|
||||||
|
'pos_x' => $px,
|
||||||
|
'pos_y' => 0,
|
||||||
|
'pos_z' => $pz,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$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();
|
||||||
|
|
||||||
@@ -106,6 +206,65 @@ class CityWorldSeeder extends Seeder
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// More civic landmarks (rendered with luminous letreiros).
|
||||||
|
Building::updateOrCreate(
|
||||||
|
['city_id' => $city->id, 'type' => 'university'],
|
||||||
|
[
|
||||||
|
'owner_character_id' => null,
|
||||||
|
'name' => 'Universidade City10',
|
||||||
|
'pos_x' => 100,
|
||||||
|
'pos_z' => 140,
|
||||||
|
'width' => 26,
|
||||||
|
'depth' => 18,
|
||||||
|
'height' => 12,
|
||||||
|
'is_complete' => true,
|
||||||
|
'build_progress' => 100,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
Building::updateOrCreate(
|
||||||
|
['city_id' => $city->id, 'type' => 'fire_station'],
|
||||||
|
[
|
||||||
|
'owner_character_id' => null,
|
||||||
|
'name' => 'Corpo de Bombeiros',
|
||||||
|
'pos_x' => -100,
|
||||||
|
'pos_z' => -20,
|
||||||
|
'width' => 20,
|
||||||
|
'depth' => 14,
|
||||||
|
'height' => 9,
|
||||||
|
'is_complete' => true,
|
||||||
|
'build_progress' => 100,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- City places (all get facade + front board + blade letreiro)
|
||||||
|
// Each sits on its own free block zone (not on a plaza row), so
|
||||||
|
// the plaza pattern and the AABB audit both stay clean.
|
||||||
|
$places = [
|
||||||
|
['Supermercado Bom Preço', 'supermarket', 100, -20, 22, 9, 16, 'Grande sortimento, hortifrúti e padaria interna.'],
|
||||||
|
['Drogaria Saúde Total', 'pharmacy', 60, 20, 14, 7, 12, 'Remédios, cosméticos e atendimento 24 horas.'],
|
||||||
|
['Restaurante Sabor da City', 'restaurant', 100, 100, 16, 8, 14, 'Comida caseira, churrasco e culinária regional.'],
|
||||||
|
['Cabeleireiro Estilo', 'salon', -60, 20, 12, 6, 10, 'Cortes, colorações e escovas com hora marcada.'],
|
||||||
|
['Banco Central City10', 'bank', 140, 20, 16, 10, 14, 'Conta corrente, crédito e cooperativa da cidade.'],
|
||||||
|
['Hotel City10 Palace', 'hotel', 60, 100, 20, 14, 16, 'Suítes com vista para a praça e Wi-Fi gratuito.'],
|
||||||
|
['Shopping City10', 'mall', -60, 100, 26, 16, 22, 'Lojas, praça de alimentação e cinema.'],
|
||||||
|
];
|
||||||
|
foreach ($places as [$name, $type, $px, $pz, $width, $height, $depth, $description]) {
|
||||||
|
Store::updateOrCreate(
|
||||||
|
['name' => $name],
|
||||||
|
[
|
||||||
|
'description' => $description,
|
||||||
|
'type' => $type,
|
||||||
|
'pos_x' => $px,
|
||||||
|
'pos_y' => 0,
|
||||||
|
'pos_z' => $pz,
|
||||||
|
'width' => $width,
|
||||||
|
'height' => $height,
|
||||||
|
'depth' => $depth,
|
||||||
|
'is_open' => true,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Mission::updateOrCreate(
|
Mission::updateOrCreate(
|
||||||
['code' => 'exercise-5'],
|
['code' => 'exercise-5'],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -88,7 +88,7 @@
|
|||||||
"city.error": "Failed to load city data.",
|
"city.error": "Failed to load city data.",
|
||||||
"city.stores_count": "stores",
|
"city.stores_count": "stores",
|
||||||
"city.agents_count": "agents",
|
"city.agents_count": "agents",
|
||||||
"city.hint": "Click the scene to control · WASD to walk · Mouse to look · Esc to exit",
|
"city.hint": "Click the scene to control · WASD to walk · Mouse to look · Esc to exit · Get close to someone to talk",
|
||||||
"city.refresh": "Reload",
|
"city.refresh": "Reload",
|
||||||
"city.selected": "Selected",
|
"city.selected": "Selected",
|
||||||
"city.open": "Open",
|
"city.open": "Open",
|
||||||
@@ -102,6 +102,7 @@
|
|||||||
"city.chat_responding": "responding…",
|
"city.chat_responding": "responding…",
|
||||||
"city.chat_accepted": "Message sent to agent.",
|
"city.chat_accepted": "Message sent to agent.",
|
||||||
"city.chat_error": "Sorry, I can't reply right now. Please try again.",
|
"city.chat_error": "Sorry, I can't reply right now. Please try again.",
|
||||||
|
"city.talk_ratelimited": "Too many messages — wait a moment and try again.",
|
||||||
"city.chat_send": "Send",
|
"city.chat_send": "Send",
|
||||||
"city.chat_with": "Chatting with",
|
"city.chat_with": "Chatting with",
|
||||||
"city.clock": "Time",
|
"city.clock": "Time",
|
||||||
@@ -211,6 +212,25 @@
|
|||||||
"city.exercise_active": "Exercising",
|
"city.exercise_active": "Exercising",
|
||||||
"city.exercise_stopHint": "to stop",
|
"city.exercise_stopHint": "to stop",
|
||||||
"city.exercise_ok": "Nice! +2 health.",
|
"city.exercise_ok": "Nice! +2 health.",
|
||||||
|
"city.beach_need": "To relax, head to the beach.",
|
||||||
|
"city.beach_ok": "You relaxed at the beach and recovered energy!",
|
||||||
|
"city.beach_full": "Energy and health are already at max.",
|
||||||
|
"city.beach_busy": "Finish your current exercise first.",
|
||||||
|
"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.port_missing": "No port authority configured.",
|
||||||
|
"city.drone": "Drone",
|
||||||
|
"city.drone_hint": "WASD fly · ↑↓ up/down · ←→ turn · Space up · C down · Q to land",
|
||||||
|
"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.sign_university": "University",
|
||||||
|
"city.sign_firestation": "Fire Department",
|
||||||
"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.",
|
||||||
|
|||||||
@@ -88,7 +88,7 @@
|
|||||||
"city.error": "Error al cargar los datos de la ciudad.",
|
"city.error": "Error al cargar los datos de la ciudad.",
|
||||||
"city.stores_count": "tiendas",
|
"city.stores_count": "tiendas",
|
||||||
"city.agents_count": "agentes",
|
"city.agents_count": "agentes",
|
||||||
"city.hint": "Haz clic en la escena para controlar · WASD para caminar · Ratón para mirar · Esc para salir",
|
"city.hint": "Haz clic en la escena para controlar · WASD para caminar · Ratón para mirar · Esc para salir · Acércate a alguien para hablar",
|
||||||
"city.refresh": "Recargar",
|
"city.refresh": "Recargar",
|
||||||
"city.selected": "Seleccionada",
|
"city.selected": "Seleccionada",
|
||||||
"city.open": "Abierta",
|
"city.open": "Abierta",
|
||||||
@@ -102,6 +102,7 @@
|
|||||||
"city.chat_responding": "respondiendo…",
|
"city.chat_responding": "respondiendo…",
|
||||||
"city.chat_accepted": "Mensaje enviado al agente.",
|
"city.chat_accepted": "Mensaje enviado al agente.",
|
||||||
"city.chat_error": "Lo siento, no puedo responder ahora. Inténtalo de nuevo.",
|
"city.chat_error": "Lo siento, no puedo responder ahora. Inténtalo de nuevo.",
|
||||||
|
"city.talk_ratelimited": "Muchos mensajes — espera un instante e inténtalo de nuevo.",
|
||||||
"city.chat_send": "Enviar",
|
"city.chat_send": "Enviar",
|
||||||
"city.chat_with": "Chateando con",
|
"city.chat_with": "Chateando con",
|
||||||
"city.clock": "Hora",
|
"city.clock": "Hora",
|
||||||
@@ -211,6 +212,25 @@
|
|||||||
"city.exercise_active": "Ejercitándose",
|
"city.exercise_active": "Ejercitándose",
|
||||||
"city.exercise_stopHint": "para parar",
|
"city.exercise_stopHint": "para parar",
|
||||||
"city.exercise_ok": "¡Bien! +2 de salud.",
|
"city.exercise_ok": "¡Bien! +2 de salud.",
|
||||||
|
"city.beach_need": "Para relajar, ve a la playa.",
|
||||||
|
"city.beach_ok": "¡Relajaste en la playa y recuperaste energía!",
|
||||||
|
"city.beach_full": "La energía y la salud ya están al máximo.",
|
||||||
|
"city.beach_busy": "Termina el ejercicio actual primero.",
|
||||||
|
"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.port_missing": "Ninguna capitanía del puerto configurada.",
|
||||||
|
"city.drone": "Dron",
|
||||||
|
"city.drone_hint": "WASD volar · ↑↓ subir/bajar · ←→ girar · Espacio subir · C bajar · Q para aterrizar",
|
||||||
|
"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.sign_university": "Universidad",
|
||||||
|
"city.sign_firestation": "Cuerpo de Bomberos",
|
||||||
"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.",
|
||||||
|
|||||||
@@ -88,7 +88,7 @@
|
|||||||
"city.error": "Falha ao carregar os dados da cidade.",
|
"city.error": "Falha ao carregar os dados da cidade.",
|
||||||
"city.stores_count": "lojas",
|
"city.stores_count": "lojas",
|
||||||
"city.agents_count": "agentes",
|
"city.agents_count": "agentes",
|
||||||
"city.hint": "Clique na tela para controlar · WASD para andar · Mouse para olhar · Esc para sair",
|
"city.hint": "Clique na tela para controlar · WASD para andar · Mouse para olhar · Esc para sair · Chegue perto de alguém para conversar",
|
||||||
"city.refresh": "Recarregar",
|
"city.refresh": "Recarregar",
|
||||||
"city.selected": "Selecionada",
|
"city.selected": "Selecionada",
|
||||||
"city.open": "Aberta",
|
"city.open": "Aberta",
|
||||||
@@ -102,6 +102,7 @@
|
|||||||
"city.chat_responding": "respondendo…",
|
"city.chat_responding": "respondendo…",
|
||||||
"city.chat_accepted": "Mensagem enviada ao agente.",
|
"city.chat_accepted": "Mensagem enviada ao agente.",
|
||||||
"city.chat_error": "Desculpe, não consegui responder agora. Tente novamente.",
|
"city.chat_error": "Desculpe, não consegui responder agora. Tente novamente.",
|
||||||
|
"city.talk_ratelimited": "Muitas mensagens — aguarde um instante e tente de novo.",
|
||||||
"city.chat_send": "Enviar",
|
"city.chat_send": "Enviar",
|
||||||
"city.chat_with": "Conversando com",
|
"city.chat_with": "Conversando com",
|
||||||
"city.clock": "Horário",
|
"city.clock": "Horário",
|
||||||
@@ -211,6 +212,25 @@
|
|||||||
"city.exercise_active": "Exercitando",
|
"city.exercise_active": "Exercitando",
|
||||||
"city.exercise_stopHint": "para parar",
|
"city.exercise_stopHint": "para parar",
|
||||||
"city.exercise_ok": "Boa! +2 de saúde.",
|
"city.exercise_ok": "Boa! +2 de saúde.",
|
||||||
|
"city.beach_need": "Para relaxar, vá até a praia.",
|
||||||
|
"city.beach_ok": "Você relaxou na praia e recuperou ânimo!",
|
||||||
|
"city.beach_full": "Energia e saúde já estão no máximo.",
|
||||||
|
"city.beach_busy": "Termine o exercício atual primeiro.",
|
||||||
|
"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.port_missing": "Nenhuma capitania do porto configurada.",
|
||||||
|
"city.drone": "Drone",
|
||||||
|
"city.drone_hint": "WASD voar · ↑↓ subir/descer · ←→ girar · Espaço subir · C descer · Q pousar",
|
||||||
|
"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.sign_university": "Universidade",
|
||||||
|
"city.sign_firestation": "Corpo de Bombeiros",
|
||||||
"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.",
|
||||||
|
|||||||
@@ -379,7 +379,9 @@ class CityAudioEngine {
|
|||||||
this._tone(466.16, 0.25, { type: 'sawtooth', peak: 0.16 });
|
this._tone(466.16, 0.25, { type: 'sawtooth', peak: 0.16 });
|
||||||
break;
|
break;
|
||||||
case 'footstep':
|
case 'footstep':
|
||||||
this._tone(90 + Math.random() * 40, 0.06, { type: 'triangle', peak: 0.12, glideTo: 60 });
|
// Louder than the other one-shots: footsteps are the main
|
||||||
|
// feedback while walking and sit low on the spectrum.
|
||||||
|
this._tone(90 + Math.random() * 40, 0.07, { type: 'triangle', peak: 0.3, glideTo: 60 });
|
||||||
break;
|
break;
|
||||||
case 'thunder':
|
case 'thunder':
|
||||||
this.thunder();
|
this.thunder();
|
||||||
|
|||||||
@@ -118,6 +118,7 @@
|
|||||||
<button type="button" id="btn-customize" class="btn" style="padding:.25rem .45rem; font-size:.75rem;">{{ __('city.act_customize') }}</button>
|
<button type="button" id="btn-customize" class="btn" style="padding:.25rem .45rem; font-size:.75rem;">{{ __('city.act_customize') }}</button>
|
||||||
<button type="button" id="btn-ride-bus" class="btn" style="padding:.25rem .45rem; font-size:.75rem;">{{ __('city.act_ride_bus') }}</button>
|
<button type="button" id="btn-ride-bus" class="btn" style="padding:.25rem .45rem; font-size:.75rem;">{{ __('city.act_ride_bus') }}</button>
|
||||||
<button type="button" id="btn-ride-train" class="btn" style="padding:.25rem .45rem; font-size:.75rem;">{{ __('city.act_ride_train') }}</button>
|
<button type="button" id="btn-ride-train" class="btn" style="padding:.25rem .45rem; font-size:.75rem;">{{ __('city.act_ride_train') }}</button>
|
||||||
|
<button type="button" id="btn-drone" class="btn" style="padding:.25rem .45rem; font-size:.75rem;">🚁 {{ __('city.drone') }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Marketplace --}}
|
{{-- Marketplace --}}
|
||||||
@@ -237,14 +238,44 @@
|
|||||||
padding:.35rem .6rem; font-size:.75rem; color:var(--muted);">{{ __('city.hint') }}</span>
|
padding:.35rem .6rem; font-size:.75rem; color:var(--muted);">{{ __('city.hint') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Chat panel (bottom-right, like example images) --}}
|
{{-- Drone command panel: header/hints opaque, #drone-video is a
|
||||||
|
transparent window — the renderer draws the drone camera
|
||||||
|
into that exact region (scissored viewport) of the canvas. --}}
|
||||||
|
<div id="drone-panel"
|
||||||
|
style="display:none; position:absolute; left:50%; top:.9rem; transform:translateX(-50%); z-index:16; width:330px;
|
||||||
|
border:1px solid rgba(148,163,184,.55); border-radius:.6rem; overflow:hidden;
|
||||||
|
box-shadow:0 12px 30px rgba(0,0,0,.5); background:transparent;">
|
||||||
|
<div id="drone-header"
|
||||||
|
style="display:flex; align-items:center; justify-content:space-between; gap:.4rem;
|
||||||
|
padding:.4rem .6rem; background:rgba(2,8,20,.88);">
|
||||||
|
<span style="font-weight:700; font-size:.8rem;">🚁 {{ __('city.drone') }}</span>
|
||||||
|
<span id="drone-tele" style="font-size:.72rem; color:#93c5fd;">↑ 0 m</span>
|
||||||
|
<button type="button" id="drone-close"
|
||||||
|
style="border:0; background:rgba(148,163,184,.18); color:#e2e8f0; border-radius:.35rem; cursor:pointer; padding:.1rem .45rem;">✕</button>
|
||||||
|
</div>
|
||||||
|
<div id="drone-video"
|
||||||
|
style="height:200px;
|
||||||
|
background:repeating-linear-gradient(0deg,
|
||||||
|
rgba(0,0,0,.14) 0px, rgba(0,0,0,.14) 2px,
|
||||||
|
rgba(0,0,0,0) 2px, rgba(0,0,0,0) 4px);
|
||||||
|
border:7px solid #050a14; border-radius:.4rem;
|
||||||
|
box-shadow:inset 0 0 16px rgba(0,0,0,.75),
|
||||||
|
0 0 0 2px rgba(148,163,184,.45);"></div>
|
||||||
|
<div id="drone-hints"
|
||||||
|
style="padding:.4rem .6rem; font-size:.7rem; color:#cbd5e1; background:rgba(2,8,20,.88);">
|
||||||
|
{{ __('city.drone_hint') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Chat panel (bottom-right, transparent — opens on bump) --}}
|
||||||
<div id="chat-panel"
|
<div id="chat-panel"
|
||||||
style="position:absolute; right:.6rem; bottom:3.2rem; z-index:15; width:280px; display:none;
|
style="position:absolute; right:.6rem; bottom:3.2rem; z-index:15; width:280px; display:none;
|
||||||
background:rgba(15,23,42,.96); border:1px solid var(--border); border-radius:.6rem;
|
background:rgba(15,23,42,.62); backdrop-filter:blur(9px); -webkit-backdrop-filter:blur(9px);
|
||||||
overflow:hidden; box-shadow:0 12px 30px rgba(0,0,0,.5); flex-direction:column;">
|
border:1px solid rgba(148,163,184,.3); border-radius:.6rem;
|
||||||
|
overflow:hidden; box-shadow:0 12px 30px rgba(0,0,0,.45); flex-direction:column;">
|
||||||
<div id="chat-header"
|
<div id="chat-header"
|
||||||
style="display:flex; align-items:center; justify-content:space-between; gap:.5rem;
|
style="display:flex; align-items:center; justify-content:space-between; gap:.5rem;
|
||||||
padding:.55rem .7rem; background:rgba(30,41,59,.9); border-bottom:1px solid var(--border);">
|
padding:.55rem .7rem; background:rgba(30,41,59,.55); border-bottom:1px solid rgba(148,163,184,.25);">
|
||||||
<div style="display:flex; align-items:center; gap:.5rem; min-width:0;">
|
<div style="display:flex; align-items:center; gap:.5rem; min-width:0;">
|
||||||
<span id="chat-dot" style="width:8px; height:8px; border-radius:50%; background:#22c55e; flex-shrink:0;"></span>
|
<span id="chat-dot" style="width:8px; height:8px; border-radius:50%; background:#22c55e; flex-shrink:0;"></span>
|
||||||
<div style="min-width:0;">
|
<div style="min-width:0;">
|
||||||
@@ -323,6 +354,7 @@
|
|||||||
chatWith: @json(__('city.chat_with')),
|
chatWith: @json(__('city.chat_with')),
|
||||||
chatSend: @json(__('city.chat_send')),
|
chatSend: @json(__('city.chat_send')),
|
||||||
chatError: @json(__('city.chat_error')),
|
chatError: @json(__('city.chat_error')),
|
||||||
|
talkRateLimited: @json(__('city.talk_ratelimited')),
|
||||||
clock: @json(__('city.clock')),
|
clock: @json(__('city.clock')),
|
||||||
weather: @json(__('city.weather')),
|
weather: @json(__('city.weather')),
|
||||||
weather_clear: @json(__('city.weather_clear')),
|
weather_clear: @json(__('city.weather_clear')),
|
||||||
@@ -363,6 +395,19 @@
|
|||||||
hospitalReleased: @json(__('city.hospital_released')),
|
hospitalReleased: @json(__('city.hospital_released')),
|
||||||
exerciseUse: @json(__('city.exercise_use')),
|
exerciseUse: @json(__('city.exercise_use')),
|
||||||
exerciseActive: @json(__('city.exercise_active')),
|
exerciseActive: @json(__('city.exercise_active')),
|
||||||
|
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')),
|
||||||
|
signUniversity: @json(__('city.sign_university')),
|
||||||
|
signFirestation: @json(__('city.sign_firestation')),
|
||||||
|
drone: @json(__('city.drone')),
|
||||||
|
droneHint: @json(__('city.drone_hint')),
|
||||||
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')),
|
||||||
@@ -372,5 +417,5 @@
|
|||||||
citySlug: @json($city->slug),
|
citySlug: @json($city->slug),
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<script type="module" src="{{ asset('js/city/app.js') }}"></script>
|
<script type="module" src="{{ asset('js/city/app.js') }}?v={{ filemtime(public_path('js/city/app.js')) }}"></script>
|
||||||
@endpush
|
@endpush
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use App\Http\Controllers\Web\CityViewerController;
|
|||||||
use App\Http\Controllers\Web\DashboardController;
|
use App\Http\Controllers\Web\DashboardController;
|
||||||
use App\Http\Controllers\Web\LanguageController;
|
use App\Http\Controllers\Web\LanguageController;
|
||||||
use App\Http\Controllers\Web\LegalController;
|
use App\Http\Controllers\Web\LegalController;
|
||||||
|
use App\Http\Controllers\Web\PersonTalkController;
|
||||||
use App\Http\Middleware\EnsureTermsAccepted;
|
use App\Http\Middleware\EnsureTermsAccepted;
|
||||||
use App\Http\Middleware\EnsureUserIsAdmin;
|
use App\Http\Middleware\EnsureUserIsAdmin;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
@@ -120,6 +121,30 @@ Route::middleware('auth')->group(function (): void {
|
|||||||
->middleware('throttle:30,1')
|
->middleware('throttle:30,1')
|
||||||
->name('city.chat.send');
|
->name('city.chat.send');
|
||||||
Route::get('/city/chat/{agent}/stream', [ChatController::class, 'stream'])->name('city.chat.stream');
|
Route::get('/city/chat/{agent}/stream', [ChatController::class, 'stream'])->name('city.chat.stream');
|
||||||
|
|
||||||
|
// Daily street conversations (bump-to-talk) with persistent context.
|
||||||
|
Route::get('/talk/person', [PersonTalkController::class, 'index'])
|
||||||
|
->middleware('throttle:120,1')
|
||||||
|
->name('talk.person.index');
|
||||||
|
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');
|
||||||
|
|
||||||
|
// Port ops: geometry, Capitania clearances (AI-driven maneuvers).
|
||||||
|
Route::get('/city/port', [CityViewerController::class, 'port'])
|
||||||
|
->middleware('throttle:60,1')
|
||||||
|
->name('city.port');
|
||||||
|
Route::post('/city/port/dispatch', [CityViewerController::class, 'dispatchPort'])
|
||||||
|
->middleware('throttle:60,1')
|
||||||
|
->name('city.port.dispatch');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
<?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'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A model that echoes the prompt instead of speaking as the tower is
|
||||||
|
* rejected and the chain falls through to the next model.
|
||||||
|
*/
|
||||||
|
public function test_dispatch_rejects_prompt_echo_replies(): void
|
||||||
|
{
|
||||||
|
config(['ai.openrouter.api_key' => 'test-key']);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'openrouter.ai/*' => Http::sequence()
|
||||||
|
->push(['choices' => [['message' => ['content' => 'The user wants a single short sentence in Portuguese, ATC style, authorizing takeoff of flight C10-138 (Pampa Air) on runway 36. Must respond only with the sentence, no quotes.']]]], 200)
|
||||||
|
->push(['choices' => [['message' => ['content' => 'C10-138, Pampa Air, autorizado para decolagem na pista 36.']]]], 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-138, Pampa Air, autorizado para decolagem na pista 36.', $clearance['text']);
|
||||||
|
Http::assertSentCount(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Character;
|
||||||
|
use App\Models\User;
|
||||||
|
use Database\Seeders\CityWorldSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* East-beach leisure action and its geometry in the world payload.
|
||||||
|
*/
|
||||||
|
class BeachLeisureTest 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 the beach geometry derived from the grid.
|
||||||
|
*/
|
||||||
|
public function test_city_data_includes_beach_geometry(): void
|
||||||
|
{
|
||||||
|
$payload = $this->getJson('/city/data')->assertOk()->json();
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('beach', $payload);
|
||||||
|
// Layout max X (200 for these bounds) + approach → sand starts at 204.
|
||||||
|
$this->assertEquals(204.0, $payload['beach']['sand_from']);
|
||||||
|
$this->assertEquals(264.0, $payload['beach']['sand_to']);
|
||||||
|
$this->assertEquals(354.0, $payload['beach']['sea_to']);
|
||||||
|
$this->assertEquals(-160.0, $payload['beach']['z_min']);
|
||||||
|
$this->assertEquals(160.0, $payload['beach']['z_max']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relaxing on the sand recovers one point of energy and health.
|
||||||
|
*/
|
||||||
|
public function test_relax_on_sand_recovers_energy_and_health(): void
|
||||||
|
{
|
||||||
|
$this->character->forceFill(['energy' => 40, 'health' => 50])->save();
|
||||||
|
|
||||||
|
$this->postJson('/city/actions/beach', ['pos_x' => 210, 'pos_z' => -40])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('message', __('city.beach_ok'));
|
||||||
|
|
||||||
|
$this->character->refresh();
|
||||||
|
$this->assertSame(41, (int) $this->character->energy);
|
||||||
|
$this->assertSame(51, (int) $this->character->health);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The action is rejected away from the beach.
|
||||||
|
*/
|
||||||
|
public function test_relax_requires_being_on_the_beach(): void
|
||||||
|
{
|
||||||
|
$this->postJson('/city/actions/beach', ['pos_x' => 0, 'pos_z' => 0])
|
||||||
|
->assertStatus(422)
|
||||||
|
->assertJsonPath('message', __('city.beach_need'));
|
||||||
|
|
||||||
|
$this->postJson('/city/actions/beach', ['pos_x' => 300, 'pos_z' => 0])
|
||||||
|
->assertStatus(422)
|
||||||
|
->assertJsonPath('message', __('city.beach_need'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fully restored characters are told they are already at max.
|
||||||
|
*/
|
||||||
|
public function test_relax_at_max_stats_reports_full(): void
|
||||||
|
{
|
||||||
|
$this->character->forceFill(['energy' => 100, 'health' => 100])->save();
|
||||||
|
|
||||||
|
$this->postJson('/city/actions/beach', ['pos_x' => 220, 'pos_z' => 30])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('message', __('city.beach_full'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleeping characters cannot relax.
|
||||||
|
*/
|
||||||
|
public function test_sleeping_character_cannot_relax(): void
|
||||||
|
{
|
||||||
|
$this->character->forceFill(['state' => Character::STATE_SLEEPING])->save();
|
||||||
|
|
||||||
|
$this->postJson('/city/actions/beach', ['pos_x' => 210, 'pos_z' => 0])
|
||||||
|
->assertStatus(422)
|
||||||
|
->assertJsonPath('message', __('city.exercise_need_awake'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Position sync persists up to the shoreline (and no further).
|
||||||
|
*/
|
||||||
|
public function test_position_persists_onto_the_beach(): void
|
||||||
|
{
|
||||||
|
$onSand = $this->postJson('/city/actions/position', ['pos_x' => 250, 'pos_z' => 0])
|
||||||
|
->assertOk();
|
||||||
|
$this->assertEquals(250, $onSand->json('pos_x'));
|
||||||
|
|
||||||
|
$pastShore = $this->postJson('/city/actions/position', ['pos_x' => 400, 'pos_z' => 0])
|
||||||
|
->assertOk();
|
||||||
|
$this->assertEquals(266, $pastShore->json('pos_x'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ use App\Models\InventoryItem;
|
|||||||
use App\Models\Job;
|
use App\Models\Job;
|
||||||
use App\Models\Transaction;
|
use App\Models\Transaction;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Support\Beach;
|
||||||
|
use App\Support\Port;
|
||||||
use Database\Seeders\CityWorldSeeder;
|
use Database\Seeders\CityWorldSeeder;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
@@ -56,7 +58,9 @@ class CityActionsTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Position updates clamp inside city bounds and persist.
|
* Position updates clamp to the beach shoreline (past the road grid)
|
||||||
|
* and persist; Z clamps to the port strip (opened for x >= 150, the
|
||||||
|
* north-east port/coast) or to the city bounds otherwise.
|
||||||
*/
|
*/
|
||||||
public function test_position_persists_and_clamps(): void
|
public function test_position_persists_and_clamps(): void
|
||||||
{
|
{
|
||||||
@@ -65,8 +69,10 @@ class CityActionsTest extends TestCase
|
|||||||
|
|
||||||
$this->character->refresh();
|
$this->character->refresh();
|
||||||
$city = $this->character->city;
|
$city = $this->character->city;
|
||||||
$this->assertSame((float) $city->bounds_max_x, $this->character->pos_x);
|
// Clamped X lands on the shoreline/quay band (>= VISIT_MIN_X), so
|
||||||
$this->assertSame((float) $city->bounds_min_z, $this->character->pos_z);
|
// the deep north coast (port strip) is reachable.
|
||||||
|
$this->assertSame(Beach::sandTo($city) + 2, $this->character->pos_x);
|
||||||
|
$this->assertSame(Port::VISIT_MIN_Z, $this->character->pos_z);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Character;
|
||||||
|
use App\Models\User;
|
||||||
|
use Database\Seeders\CityContentSeeder;
|
||||||
|
use Database\Seeders\CityWorldSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* City places (stores + civic buildings): the new landmarks exist with
|
||||||
|
* the right types, never sit on a roadway, never overlap each other and
|
||||||
|
* do not steal cells from the plaza pattern.
|
||||||
|
*/
|
||||||
|
class CityPlacesTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boot world fixtures.
|
||||||
|
*/
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
// Same order as DatabaseSeeder: content (stores/agents) then world.
|
||||||
|
$this->seed(CityContentSeeder::class);
|
||||||
|
$this->seed(CityWorldSeeder::class);
|
||||||
|
$user = User::factory()->create(['status' => User::STATUS_ACTIVE, 'is_active' => true]);
|
||||||
|
Character::ensureForUser($user);
|
||||||
|
$this->actingAs($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The payload carries every requested place with its type.
|
||||||
|
*/
|
||||||
|
public function test_new_city_places_exist(): void
|
||||||
|
{
|
||||||
|
$payload = $this->getJson('/city/data')->assertOk()->json();
|
||||||
|
|
||||||
|
$storeTypes = array_column($payload['stores'], 'type');
|
||||||
|
$storeNames = array_column($payload['stores'], 'name');
|
||||||
|
foreach ([
|
||||||
|
['supermarket', 'Supermercado Bom Preço'],
|
||||||
|
['pharmacy', 'Drogaria Saúde Total'],
|
||||||
|
['restaurant', 'Restaurante Sabor da City'],
|
||||||
|
['salon', 'Cabeleireiro Estilo'],
|
||||||
|
['bank', 'Banco Central City10'],
|
||||||
|
['hotel', 'Hotel City10 Palace'],
|
||||||
|
['mall', 'Shopping City10'],
|
||||||
|
] as [$type, $name]) {
|
||||||
|
$this->assertContains($type, $storeTypes, "Missing store type {$type}");
|
||||||
|
$this->assertContains($name, $storeNames, "Missing store {$name}");
|
||||||
|
}
|
||||||
|
// Pre-existing places from the original request.
|
||||||
|
$this->assertContains('bakery', $storeTypes, 'Padaria must remain');
|
||||||
|
$this->assertContains('market', $storeTypes, 'Mercado must remain');
|
||||||
|
|
||||||
|
$buildingTypes = array_column($payload['buildings'], 'type');
|
||||||
|
foreach (['hospital', 'school', 'city_hall', 'university', 'fire_station'] as $type) {
|
||||||
|
$this->assertContains($type, $buildingTypes, "Missing building type {$type}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full AABB audit: no structure touches a roadway (half width 4)
|
||||||
|
* and no two structures overlap.
|
||||||
|
*/
|
||||||
|
public function test_places_avoid_roads_and_each_other(): void
|
||||||
|
{
|
||||||
|
$payload = $this->getJson('/city/data')->assertOk()->json();
|
||||||
|
|
||||||
|
$xs = [];
|
||||||
|
$zs = [];
|
||||||
|
foreach ($payload['layout']['roads'] as $road) {
|
||||||
|
if ($road['axis'] === 'z') {
|
||||||
|
$xs[] = (float) $road['from'][0];
|
||||||
|
} else {
|
||||||
|
$zs[] = (float) $road['from'][2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$xs = array_values(array_unique($xs));
|
||||||
|
$zs = array_values(array_unique($zs));
|
||||||
|
$this->assertNotEmpty($xs);
|
||||||
|
$this->assertNotEmpty($zs);
|
||||||
|
|
||||||
|
$structures = [];
|
||||||
|
foreach ($payload['stores'] as $s) {
|
||||||
|
$structures[] = [
|
||||||
|
'label' => 'store:'.$s['name'],
|
||||||
|
'x0' => $s['position'][0] - $s['size'][0] / 2,
|
||||||
|
'x1' => $s['position'][0] + $s['size'][0] / 2,
|
||||||
|
'z0' => $s['position'][2] - $s['size'][2] / 2,
|
||||||
|
'z1' => $s['position'][2] + $s['size'][2] / 2,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
foreach ($payload['buildings'] as $b) {
|
||||||
|
$structures[] = [
|
||||||
|
'label' => 'building:'.$b['type'],
|
||||||
|
'x0' => $b['position'][0] - $b['size'][0] / 2,
|
||||||
|
'x1' => $b['position'][0] + $b['size'][0] / 2,
|
||||||
|
'z0' => $b['position'][2] - $b['size'][2] / 2,
|
||||||
|
'z1' => $b['position'][2] + $b['size'][2] / 2,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$roadHits = [];
|
||||||
|
foreach ($structures as $st) {
|
||||||
|
foreach ($xs as $vx) {
|
||||||
|
if ($st['x0'] < $vx + 4 && $st['x1'] > $vx - 4) {
|
||||||
|
$roadHits[] = "{$st['label']} on z-road@{$vx}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($zs as $vz) {
|
||||||
|
if ($st['z0'] < $vz + 4 && $st['z1'] > $vz - 4) {
|
||||||
|
$roadHits[] = "{$st['label']} on x-road@{$vz}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->assertSame([], $roadHits, 'Structures over roadways: '.implode(', ', $roadHits));
|
||||||
|
|
||||||
|
$collisions = [];
|
||||||
|
for ($i = 0; $i < count($structures); $i++) {
|
||||||
|
for ($j = $i + 1; $j < count($structures); $j++) {
|
||||||
|
$a = $structures[$i];
|
||||||
|
$b = $structures[$j];
|
||||||
|
if ($a['x0'] < $b['x1'] && $a['x1'] > $b['x0'] && $a['z0'] < $b['z1'] && $a['z1'] > $b['z0']) {
|
||||||
|
$collisions[] = "{$a['label']} × {$b['label']}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->assertSame([], $collisions, 'Overlapping structures: '.implode(', ', $collisions));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The new places sit off the plaza rows, so the16-plaza pattern is
|
||||||
|
* unchanged.
|
||||||
|
*/
|
||||||
|
public function test_plaza_pattern_survives_new_places(): void
|
||||||
|
{
|
||||||
|
$payload = $this->getJson('/city/data')->assertOk()->json();
|
||||||
|
|
||||||
|
$this->assertCount(16, $payload['plazas'], 'Plaza count must stay 16 after adding places');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,4 +44,36 @@ class CityWorldSeedingTest extends TestCase
|
|||||||
$active = User::where('is_active', true)->where('status', '!=', User::STATUS_ACTIVE)->count();
|
$active = User::where('is_active', true)->where('status', '!=', User::STATUS_ACTIVE)->count();
|
||||||
$this->assertSame(0, $active);
|
$this->assertSame(0, $active);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The performance-tuned city delivers exactly 72 blocks (9 × 8) with
|
||||||
|
* the matching road grid and bounds in the world payload.
|
||||||
|
*/
|
||||||
|
public function test_layout_has_72_blocks_after_performance_resize(): void
|
||||||
|
{
|
||||||
|
$this->seed(CityWorldSeeder::class);
|
||||||
|
|
||||||
|
$user = User::factory()->create(['status' => User::STATUS_ACTIVE, 'is_active' => true]);
|
||||||
|
Character::ensureForUser($user);
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$payload = $this->getJson('/city/data')->assertOk()->json();
|
||||||
|
|
||||||
|
$this->assertCount(72, $payload['layout']['blocks']);
|
||||||
|
|
||||||
|
$zRoads = count(array_filter($payload['layout']['roads'], fn (array $r): bool => $r['axis'] === 'z'));
|
||||||
|
$xRoads = count(array_filter($payload['layout']['roads'], fn (array $r): bool => $r['axis'] === 'x'));
|
||||||
|
// Server payload: 10 vertical + 9 horizontal grid roads (the bridge
|
||||||
|
// and north extension are appended client-side at build time).
|
||||||
|
$this->assertSame(10, $zRoads);
|
||||||
|
$this->assertSame(9, $xRoads);
|
||||||
|
|
||||||
|
$this->assertEquals(-120, $payload['city']['bounds']['min_x']);
|
||||||
|
$this->assertEquals(160, $payload['city']['bounds']['max_x']);
|
||||||
|
$this->assertEquals(-120, $payload['city']['bounds']['min_z']);
|
||||||
|
$this->assertEquals(120, $payload['city']['bounds']['max_z']);
|
||||||
|
|
||||||
|
// The sparse plaza pattern (~1 in 4 cells) scales with the city.
|
||||||
|
$this->assertGreaterThanOrEqual(10, count($payload['plazas']));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,688 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Web\PersonTalkController;
|
||||||
|
use App\Models\AiAgent;
|
||||||
|
use App\Models\Character;
|
||||||
|
use App\Models\PersonTalk;
|
||||||
|
use App\Models\User;
|
||||||
|
use Database\Seeders\CityWorldSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Db;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daily street conversations (bump-to-talk) coverage: registration per
|
||||||
|
* day, peer validation, agent replies with multi-day context and PII
|
||||||
|
* kept out of upstream payloads.
|
||||||
|
*/
|
||||||
|
class PersonTalkTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed world fixtures.
|
||||||
|
*/
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->seed(CityWorldSeeder::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an active user paired with its in-world character.
|
||||||
|
*/
|
||||||
|
private function makeCitizen(): array
|
||||||
|
{
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'status' => User::STATUS_ACTIVE,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [$user, Character::ensureForUser($user)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulate a human actively using the account (fresh session row).
|
||||||
|
*/
|
||||||
|
private function makePeerLive(User $user): void
|
||||||
|
{
|
||||||
|
Db::table('sessions')->insert([
|
||||||
|
'id' => Str::random(40),
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'ip_address' => '127.0.0.1',
|
||||||
|
'user_agent' => 'phpunit',
|
||||||
|
'payload' => '',
|
||||||
|
'last_activity' => now()->getTimestamp(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Character-to-character talks are stored per day and visible from
|
||||||
|
* both sides of the conversation.
|
||||||
|
*/
|
||||||
|
public function test_character_talk_is_registered_per_day_for_both_sides(): void
|
||||||
|
{
|
||||||
|
[$userA, $me] = $this->makeCitizen();
|
||||||
|
[, $them] = $this->makeCitizen();
|
||||||
|
|
||||||
|
$this->actingAs($userA)
|
||||||
|
->postJson('/talk/person', [
|
||||||
|
'kind' => 'character',
|
||||||
|
'peer' => $them->id,
|
||||||
|
'message' => 'Oi, tudo bem?',
|
||||||
|
])
|
||||||
|
->assertCreated()
|
||||||
|
->assertJsonPath('from', 'me')
|
||||||
|
->assertJsonPath('talked_on', now()->toDateString());
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('person_talks', [
|
||||||
|
'from_character_id' => $me->id,
|
||||||
|
'to_character_id' => $them->id,
|
||||||
|
'to_agent_id' => null,
|
||||||
|
'body' => 'Oi, tudo bem?',
|
||||||
|
]);
|
||||||
|
$stored = PersonTalk::query()->where('body', 'Oi, tudo bem?')->firstOrFail();
|
||||||
|
$this->assertSame(now()->toDateString(), $stored->talked_on->toDateString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The peer reads the same row as an incoming message and can answer
|
||||||
|
* back through the same endpoint.
|
||||||
|
*/
|
||||||
|
public function test_conversation_is_shared_between_both_characters(): void
|
||||||
|
{
|
||||||
|
[$userA, $me] = $this->makeCitizen();
|
||||||
|
[$userB, $them] = $this->makeCitizen();
|
||||||
|
|
||||||
|
// Both sides driven by live humans → pure relay, no auto-replies.
|
||||||
|
$this->makePeerLive($userA);
|
||||||
|
$this->makePeerLive($userB);
|
||||||
|
|
||||||
|
$this->actingAs($userA)
|
||||||
|
->postJson('/talk/person', [
|
||||||
|
'kind' => 'character',
|
||||||
|
'peer' => $them->id,
|
||||||
|
'message' => 'Oi, tudo bem?',
|
||||||
|
])
|
||||||
|
->assertCreated();
|
||||||
|
|
||||||
|
$this->actingAs($userB)
|
||||||
|
->postJson('/talk/person', [
|
||||||
|
'kind' => 'character',
|
||||||
|
'peer' => $me->id,
|
||||||
|
'message' => 'Tudo ótimo!',
|
||||||
|
])
|
||||||
|
->assertCreated();
|
||||||
|
|
||||||
|
$this->actingAs($userB)
|
||||||
|
->getJson('/talk/person?kind=character&peer='.$me->id)
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('peer.kind', 'character')
|
||||||
|
->assertJsonPath('messages.0.from', 'them')
|
||||||
|
->assertJsonPath('messages.0.body', 'Oi, tudo bem?')
|
||||||
|
->assertJsonPath('messages.1.from', 'me')
|
||||||
|
->assertJsonPath('messages.1.body', 'Tudo ótimo!');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Peer and payload validation on the send endpoint.
|
||||||
|
*/
|
||||||
|
public function test_talk_validates_peer_and_message(): void
|
||||||
|
{
|
||||||
|
[$userA, $me] = $this->makeCitizen();
|
||||||
|
|
||||||
|
$this->actingAs($userA)
|
||||||
|
->postJson('/talk/person', [
|
||||||
|
'kind' => 'character',
|
||||||
|
'peer' => $me->id,
|
||||||
|
'message' => 'oi',
|
||||||
|
])
|
||||||
|
->assertStatus(422);
|
||||||
|
|
||||||
|
$this->actingAs($userA)
|
||||||
|
->postJson('/talk/person', [
|
||||||
|
'kind' => 'alien',
|
||||||
|
'peer' => 1,
|
||||||
|
'message' => 'oi',
|
||||||
|
])
|
||||||
|
->assertStatus(422);
|
||||||
|
|
||||||
|
$this->actingAs($userA)
|
||||||
|
->postJson('/talk/person', [
|
||||||
|
'kind' => 'character',
|
||||||
|
'peer' => 999999,
|
||||||
|
'message' => 'oi',
|
||||||
|
])
|
||||||
|
->assertNotFound();
|
||||||
|
|
||||||
|
$this->actingAs($userA)
|
||||||
|
->postJson('/talk/person', [
|
||||||
|
'kind' => 'character',
|
||||||
|
'peer' => 999999,
|
||||||
|
])
|
||||||
|
->assertStatus(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent talks store the message, answer after the response and send
|
||||||
|
* the multi-day history upstream while keeping PII out (RULES.md 2.2).
|
||||||
|
*/
|
||||||
|
public function test_agent_talk_replies_using_daily_history_as_context(): void
|
||||||
|
{
|
||||||
|
config(['ai.openrouter.api_key' => 'test-key']);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'openrouter.ai/*' => Http::response([
|
||||||
|
'choices' => [['message' => ['content' => 'Praça renovada, como combinamos ontem!']]],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
[$userA, $me] = $this->makeCitizen();
|
||||||
|
$agent = AiAgent::create(['name' => 'Prefeito', 'type' => 'mayor']);
|
||||||
|
|
||||||
|
// Yesterday's exchange is part of the persistent context.
|
||||||
|
PersonTalk::create([
|
||||||
|
'from_character_id' => $me->id,
|
||||||
|
'to_agent_id' => $agent->id,
|
||||||
|
'body' => 'Ontem conversamos sobre a praça',
|
||||||
|
'talked_on' => now()->subDay()->toDateString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($userA)
|
||||||
|
->postJson('/talk/person', [
|
||||||
|
'kind' => 'agent',
|
||||||
|
'peer' => $agent->id,
|
||||||
|
'message' => 'E a praça, fica pronta hoje?',
|
||||||
|
])
|
||||||
|
->assertCreated();
|
||||||
|
|
||||||
|
// The after-response job ran during the request lifecycle.
|
||||||
|
$this->assertDatabaseHas('person_talks', [
|
||||||
|
'from_agent_id' => $agent->id,
|
||||||
|
'to_character_id' => $me->id,
|
||||||
|
'body' => 'Praça renovada, como combinamos ontem!',
|
||||||
|
]);
|
||||||
|
$reply = PersonTalk::query()->where('body', 'Praça renovada, como combinamos ontem!')->firstOrFail();
|
||||||
|
$this->assertSame(now()->toDateString(), $reply->talked_on->toDateString());
|
||||||
|
|
||||||
|
$agent->refresh();
|
||||||
|
$this->assertSame('active', $agent->status);
|
||||||
|
$this->assertSame(0, (int) $agent->error_count);
|
||||||
|
$this->assertNotNull($agent->last_think_at);
|
||||||
|
$this->assertNotEmpty($agent->last_decision);
|
||||||
|
|
||||||
|
$userEmail = $userA->email;
|
||||||
|
$userName = $userA->name;
|
||||||
|
Http::assertSent(function ($request) use ($userEmail, $userName): bool {
|
||||||
|
$payload = $request->data();
|
||||||
|
$encoded = json_encode($payload, JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
// Yesterday's line and today's line reach the model (thread kept).
|
||||||
|
$hasHistory = str_contains($encoded, 'Ontem conversamos sobre a praça')
|
||||||
|
&& str_contains($encoded, 'E a praça, fica pronta hoje?');
|
||||||
|
// No registration data upstream (RULES.md 2.2).
|
||||||
|
$leaksPii = str_contains($encoded, $userEmail)
|
||||||
|
|| str_contains($encoded, $userName);
|
||||||
|
|
||||||
|
return $hasHistory && ! $leaksPii;
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->actingAs($userA)
|
||||||
|
->getJson('/talk/person?kind=agent&peer='.$agent->id)
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('peer.kind', 'agent')
|
||||||
|
->assertJsonPath('peer.status', 'active')
|
||||||
|
->assertJsonPath('thinking', false)
|
||||||
|
->assertJsonPath('messages.0.body', 'Ontem conversamos sobre a praça')
|
||||||
|
->assertJsonPath('messages.0.from', 'me')
|
||||||
|
->assertJsonPath('messages.1.body', 'E a praça, fica pronta hoje?')
|
||||||
|
->assertJsonPath('messages.2.body', 'Praça renovada, como combinamos ontem!')
|
||||||
|
->assertJsonPath('messages.2.from', 'them')
|
||||||
|
->assertJsonPath('messages.2.talked_on', now()->toDateString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Without an upstream key the conversation still completes: the
|
||||||
|
* agent stores the localized fallback and counts the failure for
|
||||||
|
* backoff (RULES.md 2.4).
|
||||||
|
*/
|
||||||
|
public function test_agent_talk_without_api_key_stores_fallback_reply(): void
|
||||||
|
{
|
||||||
|
config(['ai.openrouter.api_key' => null]);
|
||||||
|
Http::preventStrayRequests();
|
||||||
|
|
||||||
|
[$userA, $me] = $this->makeCitizen();
|
||||||
|
$agent = AiAgent::create(['name' => 'Lojista', 'type' => 'shopkeeper']);
|
||||||
|
|
||||||
|
$this->actingAs($userA)
|
||||||
|
->postJson('/talk/person', [
|
||||||
|
'kind' => 'agent',
|
||||||
|
'peer' => $agent->id,
|
||||||
|
'message' => 'Bom dia!',
|
||||||
|
])
|
||||||
|
->assertCreated();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('person_talks', [
|
||||||
|
'from_agent_id' => $agent->id,
|
||||||
|
'to_character_id' => $me->id,
|
||||||
|
'body' => __('city.chat_error'),
|
||||||
|
'is_fallback' => true,
|
||||||
|
]);
|
||||||
|
$fallback = PersonTalk::query()->where('body', __('city.chat_error'))->firstOrFail();
|
||||||
|
$this->assertSame(now()->toDateString(), $fallback->talked_on->toDateString());
|
||||||
|
$this->assertTrue((bool) $fallback->is_fallback);
|
||||||
|
|
||||||
|
$agent->refresh();
|
||||||
|
$this->assertSame(1, (int) $agent->error_count);
|
||||||
|
|
||||||
|
$this->actingAs($userA)
|
||||||
|
->getJson('/talk/person?kind=agent&peer='.$agent->id)
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('thinking', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* While the agent reply is still pending the UI is told to keep
|
||||||
|
* showing the thinking state.
|
||||||
|
*/
|
||||||
|
public function test_agent_talk_reports_thinking_until_reply_lands(): void
|
||||||
|
{
|
||||||
|
[$userA, $me] = $this->makeCitizen();
|
||||||
|
$agent = AiAgent::create(['name' => 'Guia', 'type' => 'pedestrian']);
|
||||||
|
|
||||||
|
// Pending row without dispatching a reply (simulate in-flight).
|
||||||
|
PersonTalk::create([
|
||||||
|
'from_character_id' => $me->id,
|
||||||
|
'to_agent_id' => $agent->id,
|
||||||
|
'body' => 'Oi!',
|
||||||
|
'talked_on' => now()->toDateString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($userA)
|
||||||
|
->getJson('/talk/person?kind=agent&peer='.$agent->id)
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('thinking', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* History spans multiple days so later conversations keep the thread.
|
||||||
|
*/
|
||||||
|
public function test_history_returns_previous_days_in_order(): void
|
||||||
|
{
|
||||||
|
[$userA, $me] = $this->makeCitizen();
|
||||||
|
[, $them] = $this->makeCitizen();
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
[now()->subDays(2), 'Primeira conversa'],
|
||||||
|
[now()->subDay(), 'Segunda conversa'],
|
||||||
|
[now(), 'Terceira conversa'],
|
||||||
|
] as [$day, $body]) {
|
||||||
|
PersonTalk::create([
|
||||||
|
'from_character_id' => $me->id,
|
||||||
|
'to_character_id' => $them->id,
|
||||||
|
'body' => $body,
|
||||||
|
'talked_on' => $day->toDateString(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->actingAs($userA)
|
||||||
|
->getJson('/talk/person?kind=character&peer='.$them->id)
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
$messages = $response->json('messages');
|
||||||
|
$this->assertCount(3, $messages);
|
||||||
|
$this->assertSame('Primeira conversa', $messages[0]['body']);
|
||||||
|
$this->assertSame(now()->subDays(2)->toDateString(), $messages[0]['talked_on']);
|
||||||
|
$this->assertSame('Terceira conversa', $messages[2]['body']);
|
||||||
|
$this->assertSame(now()->toDateString(), $messages[2]['talked_on']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A character peer without a live human session (seeded citizen,
|
||||||
|
* demo account like "Maria") answers through the AI persona, using
|
||||||
|
* the daily history as context and keeping PII out of the payload.
|
||||||
|
*/
|
||||||
|
public function test_silent_citizen_peer_answers_with_ai_reply(): void
|
||||||
|
{
|
||||||
|
config(['ai.openrouter.api_key' => 'test-key']);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'openrouter.ai/*' => Http::response([
|
||||||
|
'choices' => [['message' => ['content' => 'Oi! Tudo tranquilo na cidade hoje.']]],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
[$userA, $me] = $this->makeCitizen();
|
||||||
|
[$userB, $them] = $this->makeCitizen(); // no session → silent peer
|
||||||
|
|
||||||
|
PersonTalk::create([
|
||||||
|
'from_character_id' => $me->id,
|
||||||
|
'to_character_id' => $them->id,
|
||||||
|
'body' => 'Ontem te vi na praça',
|
||||||
|
'talked_on' => now()->subDay()->toDateString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($userA)
|
||||||
|
->postJson('/talk/person', [
|
||||||
|
'kind' => 'character',
|
||||||
|
'peer' => $them->id,
|
||||||
|
'message' => 'Oi, tudo bem por aí?',
|
||||||
|
])
|
||||||
|
->assertCreated();
|
||||||
|
|
||||||
|
// The silent peer replied during the after-response phase.
|
||||||
|
$reply = PersonTalk::query()
|
||||||
|
->where('from_character_id', $them->id)
|
||||||
|
->where('to_character_id', $me->id)
|
||||||
|
->first();
|
||||||
|
$this->assertNotNull($reply);
|
||||||
|
$this->assertSame('Oi! Tudo tranquilo na cidade hoje.', $reply->body);
|
||||||
|
$this->assertSame(now()->toDateString(), $reply->talked_on->toDateString());
|
||||||
|
|
||||||
|
$peerEmail = $userB->email;
|
||||||
|
Http::assertSent(function ($request) use ($peerEmail): bool {
|
||||||
|
$encoded = json_encode($request->data(), JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
// Thread kept… and no registration data upstream (RULES.md 2.2).
|
||||||
|
return str_contains($encoded, 'Ontem te vi na praça')
|
||||||
|
&& str_contains($encoded, 'Oi, tudo bem por aí?')
|
||||||
|
&& ! str_contains($encoded, $peerEmail);
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->actingAs($userA)
|
||||||
|
->getJson('/talk/person?kind=character&peer='.$them->id)
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('thinking', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A peer driven by a live human session is relayed as-is and never
|
||||||
|
* answered by the AI on their behalf.
|
||||||
|
*/
|
||||||
|
public function test_live_human_peer_is_not_impersonated(): void
|
||||||
|
{
|
||||||
|
config(['ai.openrouter.api_key' => 'test-key']);
|
||||||
|
Http::preventStrayRequests();
|
||||||
|
|
||||||
|
[$userA, $me] = $this->makeCitizen();
|
||||||
|
[$userB, $them] = $this->makeCitizen();
|
||||||
|
$this->makePeerLive($userB);
|
||||||
|
|
||||||
|
$this->actingAs($userA)
|
||||||
|
->postJson('/talk/person', [
|
||||||
|
'kind' => 'character',
|
||||||
|
'peer' => $them->id,
|
||||||
|
'message' => 'Você está aí?',
|
||||||
|
])
|
||||||
|
->assertCreated();
|
||||||
|
|
||||||
|
$this->assertSame(0, PersonTalk::query()
|
||||||
|
->where('from_character_id', $them->id)
|
||||||
|
->where('to_character_id', $me->id)
|
||||||
|
->count());
|
||||||
|
|
||||||
|
$this->actingAs($userA)
|
||||||
|
->getJson('/talk/person?kind=character&peer='.$them->id)
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('thinking', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Without an API key the silent peer still answers with the stored
|
||||||
|
* fallback so the chat never hangs waiting.
|
||||||
|
*/
|
||||||
|
public function test_silent_citizen_falls_back_without_api_key(): void
|
||||||
|
{
|
||||||
|
config(['ai.openrouter.api_key' => null]);
|
||||||
|
Http::preventStrayRequests();
|
||||||
|
|
||||||
|
[$userA, $me] = $this->makeCitizen();
|
||||||
|
[, $them] = $this->makeCitizen();
|
||||||
|
|
||||||
|
$this->actingAs($userA)
|
||||||
|
->postJson('/talk/person', [
|
||||||
|
'kind' => 'character',
|
||||||
|
'peer' => $them->id,
|
||||||
|
'message' => 'Olá?',
|
||||||
|
])
|
||||||
|
->assertCreated();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('person_talks', [
|
||||||
|
'from_character_id' => $them->id,
|
||||||
|
'to_character_id' => $me->id,
|
||||||
|
'body' => __('city.chat_error'),
|
||||||
|
'is_fallback' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($userA)
|
||||||
|
->getJson('/talk/person?kind=character&peer='.$them->id)
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('thinking', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error-fallback rows stay visible in the chat but are never sent
|
||||||
|
* back to the model as dialogue — an apology in the context would
|
||||||
|
* skew the persona into a support-bot tone or identity confusion.
|
||||||
|
*/
|
||||||
|
public function test_fallback_rows_are_excluded_from_model_context(): void
|
||||||
|
{
|
||||||
|
config(['ai.openrouter.api_key' => 'test-key']);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'openrouter.ai/*' => Http::response([
|
||||||
|
'choices' => [['message' => ['content' => 'Praça fechada para obra, já passa.']]],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
[$userA, $me] = $this->makeCitizen();
|
||||||
|
[$userB, $them] = $this->makeCitizen();
|
||||||
|
|
||||||
|
// Yesterday: a real line plus an error placeholder.
|
||||||
|
PersonTalk::create([
|
||||||
|
'from_character_id' => $me->id,
|
||||||
|
'to_character_id' => $them->id,
|
||||||
|
'body' => 'E a praça, abre hoje?',
|
||||||
|
'talked_on' => now()->subDay()->toDateString(),
|
||||||
|
]);
|
||||||
|
PersonTalk::create([
|
||||||
|
'from_character_id' => $them->id,
|
||||||
|
'to_character_id' => $me->id,
|
||||||
|
'body' => __('city.chat_error'),
|
||||||
|
'talked_on' => now()->subDay()->toDateString(),
|
||||||
|
'is_fallback' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($userA)
|
||||||
|
->postJson('/talk/person', [
|
||||||
|
'kind' => 'character',
|
||||||
|
'peer' => $them->id,
|
||||||
|
'message' => 'E aí, e a obra?',
|
||||||
|
])
|
||||||
|
->assertCreated();
|
||||||
|
|
||||||
|
Http::assertSent(function ($request): bool {
|
||||||
|
$encoded = json_encode($request->data(), JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
return str_contains($encoded, 'E a praça, abre hoje?')
|
||||||
|
&& str_contains($encoded, 'E aí, e a obra?')
|
||||||
|
&& ! str_contains($encoded, __('city.chat_error'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// The stored reply is a real answer, not another placeholder.
|
||||||
|
$reply = PersonTalk::query()
|
||||||
|
->where('from_character_id', $them->id)
|
||||||
|
->where('is_fallback', false)
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
$this->assertNotNull($reply);
|
||||||
|
$this->assertSame('Praça fechada para obra, já passa.', $reply->body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model resolution priority: column → env map (id/name) → pool slot,
|
||||||
|
* always inside the allow-list (RULES.md 2.1) and stable per id.
|
||||||
|
*/
|
||||||
|
public function test_model_resolution_priority_and_allow_list(): void
|
||||||
|
{
|
||||||
|
$mapped = 'openrouter/free';
|
||||||
|
$pooled = 'nvidia/nemotron-3.5-lightning:free';
|
||||||
|
|
||||||
|
config([
|
||||||
|
'ai.models' => [$pooled, $mapped],
|
||||||
|
'ai.character_models' => [
|
||||||
|
'maria' => $mapped,
|
||||||
|
'7' => $pooled,
|
||||||
|
'bad' => 'not/allowed-model',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 1) Character column wins over everything.
|
||||||
|
$this->assertSame(
|
||||||
|
$mapped,
|
||||||
|
PersonTalkController::resolveModel($mapped, 'Qualquer', 99)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2) Env map by name (case-insensitive) and by id.
|
||||||
|
$this->assertSame($mapped, PersonTalkController::resolveModel(null, 'Maria', 3));
|
||||||
|
$this->assertSame($pooled, PersonTalkController::resolveModel(null, 'Outro', 7));
|
||||||
|
|
||||||
|
// 3) Non allow-listed entries are ignored → deterministic pool slot.
|
||||||
|
$fromPool = PersonTalkController::resolveModel('not/allowed-model', 'Bad', 4);
|
||||||
|
$this->assertContains($fromPool, [$pooled, $mapped]);
|
||||||
|
$this->assertNotSame('not/allowed-model', $fromPool);
|
||||||
|
|
||||||
|
// 4) Empty map/pool falls back to the allow-listed default.
|
||||||
|
config(['ai.character_models' => [], 'ai.models' => []]);
|
||||||
|
$default = config('ai.default_model');
|
||||||
|
$this->assertContains($default, config('ai.allowed_models'));
|
||||||
|
$this->assertSame($default, PersonTalkController::resolveModel(null, 'X', 1));
|
||||||
|
|
||||||
|
// 5) Same character always resolves to the same model.
|
||||||
|
config(['ai.models' => [$pooled, $mapped]]);
|
||||||
|
$stable = PersonTalkController::resolveModel(null, 'Estável', 5);
|
||||||
|
$this->assertSame($stable, PersonTalkController::resolveModel(null, 'Estável', 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The identity/personality profile and the resolved model travel in
|
||||||
|
* the upstream payload while registration data stays out (RULES.md 2.2).
|
||||||
|
*/
|
||||||
|
public function test_identity_and_model_flow_into_upstream_payload(): void
|
||||||
|
{
|
||||||
|
config([
|
||||||
|
'ai.openrouter.api_key' => 'test-key',
|
||||||
|
'ai.models' => ['liquid/lfm-2.5-2.6b:free'],
|
||||||
|
'ai.character_models' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'openrouter.ai/*' => Http::response([
|
||||||
|
'choices' => [['message' => ['content' => 'Oi! Tudo tranquilo na cidade.']]],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
[$userA, $me] = $this->makeCitizen();
|
||||||
|
[$userB, $them] = $this->makeCitizen();
|
||||||
|
|
||||||
|
$them->update([
|
||||||
|
'ai_age' => 34,
|
||||||
|
'ai_gender' => 'feminino',
|
||||||
|
'ai_personality' => 'Extrovertido e curiosa, adora puxar conversa.',
|
||||||
|
'ai_bio' => 'Moradora do centro, vive nas novidades do bairro.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($userA)
|
||||||
|
->postJson('/talk/person', [
|
||||||
|
'kind' => 'character',
|
||||||
|
'peer' => $them->id,
|
||||||
|
'message' => 'Oi, tudo bem?',
|
||||||
|
])
|
||||||
|
->assertCreated();
|
||||||
|
|
||||||
|
$peerEmail = $userB->email;
|
||||||
|
$peerName = $them->name;
|
||||||
|
Http::assertSent(function ($request) use ($peerEmail, $peerName): bool {
|
||||||
|
$payload = $request->data();
|
||||||
|
$encoded = json_encode($payload, JSON_UNESCAPED_UNICODE);
|
||||||
|
$system = (string) ($payload['messages'][0]['content'] ?? '');
|
||||||
|
|
||||||
|
return $payload['model'] === 'liquid/lfm-2.5-2.6b:free'
|
||||||
|
&& str_contains($system, 'Idade: 34')
|
||||||
|
&& str_contains($system, 'Sexo: feminino')
|
||||||
|
&& str_contains($system, 'Extrovertido e curiosa')
|
||||||
|
&& str_contains($system, 'Moradora do centro')
|
||||||
|
&& str_contains($system, 'Nome: '.$peerName)
|
||||||
|
&& ! str_contains($encoded, $peerEmail);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The reply from the fake model is stored normally.
|
||||||
|
$this->assertNotNull(PersonTalk::query()
|
||||||
|
->where('from_character_id', $them->id)
|
||||||
|
->where('body', 'Oi! Tudo tranquilo na cidade.')
|
||||||
|
->first());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fresh citizens are created with a ready-to-use default persona.
|
||||||
|
*/
|
||||||
|
public function test_new_characters_receive_default_persona(): void
|
||||||
|
{
|
||||||
|
[, $character] = $this->makeCitizen();
|
||||||
|
|
||||||
|
$this->assertNotNull($character->ai_age);
|
||||||
|
$this->assertGreaterThanOrEqual(18, $character->ai_age);
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\AiAgent;
|
||||||
|
use App\Models\Character;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\Port;
|
||||||
|
use Database\Seeders\CityWorldSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port payload, half-side placement (expansion buffer), Capitania staff
|
||||||
|
* and the AI-driven maneuver clearances.
|
||||||
|
*/
|
||||||
|
class PortTest 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 payload carries the full port descriptor.
|
||||||
|
*/
|
||||||
|
public function test_payload_contains_port_descriptor(): void
|
||||||
|
{
|
||||||
|
$port = $this->getJson('/city/data')->assertOk()->json('port');
|
||||||
|
|
||||||
|
$this->assertEquals(-430.0, $port['visit']['min_z']);
|
||||||
|
$this->assertEquals(-290.0, $port['visit']['max_z']);
|
||||||
|
$this->assertNotEmpty($port['quay']);
|
||||||
|
$this->assertNotEmpty($port['yard']);
|
||||||
|
$this->assertNotEmpty($port['terminal']);
|
||||||
|
$this->assertCount(2, $port['cranes']);
|
||||||
|
$this->assertCount(2, $port['berths']);
|
||||||
|
$this->assertCount(3, $port['ships']);
|
||||||
|
$this->assertCount(3, $port['boats']);
|
||||||
|
$this->assertSame(
|
||||||
|
['container', 'container', 'liner'],
|
||||||
|
array_column($port['ships'], 'kind')
|
||||||
|
);
|
||||||
|
foreach ($port['ships'] as $ship) {
|
||||||
|
$this->assertNotEmpty($ship['company']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ONLY the far half of the side hosts port structures: everything
|
||||||
|
* sits at z ≤ −290, leaving the near half (−290..−150) free for
|
||||||
|
* future city expansion.
|
||||||
|
*/
|
||||||
|
public function test_port_occupies_only_the_far_half_of_the_side(): void
|
||||||
|
{
|
||||||
|
$port = $this->getJson('/city/data')->assertOk()->json('port');
|
||||||
|
|
||||||
|
$centers = [
|
||||||
|
'quay' => $port['quay']['center'][1],
|
||||||
|
'yard' => $port['yard']['center'][1],
|
||||||
|
'terminal' => $port['terminal']['center'][1],
|
||||||
|
];
|
||||||
|
foreach ($port['cranes'] as $i => $crane) {
|
||||||
|
$centers["crane{$i}"] = $crane['center'][1];
|
||||||
|
}
|
||||||
|
foreach ($port['berths'] as $berth) {
|
||||||
|
$centers['berth'.$berth['id']] = $berth['center'][1];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($centers as $label => $z) {
|
||||||
|
$this->assertLessThanOrEqual(-290.0, (float) $z, "{$label} must stay in the far half");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visit zone ends at the buffer line as well.
|
||||||
|
$this->assertEquals(-290.0, $port['visit']['max_z']);
|
||||||
|
$this->assertGreaterThanOrEqual(
|
||||||
|
Port::VISIT_MIN_Z,
|
||||||
|
Port::VISIT_MAX_Z
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capitania + dock workers exist with allow-listed models, maritime
|
||||||
|
* personas and staff positions inside the port zone.
|
||||||
|
*/
|
||||||
|
public function test_port_staff_seeded(): void
|
||||||
|
{
|
||||||
|
$captain = AiAgent::query()->where('type', 'port_master')->firstOrFail();
|
||||||
|
$this->assertSame('Capitão do Porto', $captain->name);
|
||||||
|
$this->assertContains($captain->model, config('ai.allowed_models'));
|
||||||
|
$this->assertStringContainsString('Capitão do Porto', $captain->system_prompt);
|
||||||
|
$this->assertLessThanOrEqual(-290, (float) $captain->pos_z);
|
||||||
|
|
||||||
|
$dockers = AiAgent::query()->where('type', 'dock_worker')->get();
|
||||||
|
$this->assertCount(2, $dockers);
|
||||||
|
foreach ($dockers as $docker) {
|
||||||
|
$this->assertContains($docker->model, config('ai.allowed_models'));
|
||||||
|
$this->assertLessThanOrEqual(-290, (float) $docker->pos_z);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The port endpoint exposes geometry, captain and clearance.
|
||||||
|
*/
|
||||||
|
public function test_port_endpoint_exposes_captain(): void
|
||||||
|
{
|
||||||
|
$this->getJson('/city/port')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('captain.name', 'Capitão do Porto')
|
||||||
|
->assertJsonPath('clearance', null);
|
||||||
|
|
||||||
|
$payload = $this->getJson('/city/port')->json();
|
||||||
|
$this->assertNotEmpty($payload['geometry']['quay']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch generates an AI clearance with structured maneuver fields.
|
||||||
|
*/
|
||||||
|
public function test_dispatch_generates_model_clearance(): void
|
||||||
|
{
|
||||||
|
config(['ai.openrouter.api_key' => 'test-key']);
|
||||||
|
Http::fake([
|
||||||
|
'openrouter.ai/*' => Http::response([
|
||||||
|
'choices' => [['message' => ['content' => 'Navio CITY VOYAGER, autorizado a atracar no berço 1.']]],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->postJson('/city/port/dispatch')
|
||||||
|
->assertStatus(202)
|
||||||
|
->assertJsonPath('accepted', true);
|
||||||
|
|
||||||
|
$clearance = cache()->get('port.clearance');
|
||||||
|
$this->assertNotNull($clearance);
|
||||||
|
$this->assertSame('model', $clearance['source']);
|
||||||
|
$this->assertContains($clearance['action'], ['berth', 'depart']);
|
||||||
|
$this->assertContains($clearance['ship'], ['CITY VOYAGER', 'AZUL MAR', 'ATLÂNTICO CELESTE']);
|
||||||
|
$this->assertNotSame('', $clearance['text']);
|
||||||
|
|
||||||
|
Http::assertSent(function ($request): bool {
|
||||||
|
$content = (string) $request['messages'][0]['content'];
|
||||||
|
|
||||||
|
return $request['model'] === config('ai.default_model')
|
||||||
|
&& str_contains($content, 'Capitania')
|
||||||
|
&& str_contains($content, 'navio');
|
||||||
|
});
|
||||||
|
|
||||||
|
$captain = AiAgent::query()->where('type', 'port_master')->firstOrFail();
|
||||||
|
$this->assertSame(0, (int) $captain->error_count);
|
||||||
|
$this->assertSame('active', $captain->status);
|
||||||
|
$this->assertSame('port', collect($captain->last_decision)->last()['role'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meta prompt-echoes are rejected and the chain falls through.
|
||||||
|
*/
|
||||||
|
public function test_dispatch_rejects_prompt_echoes(): void
|
||||||
|
{
|
||||||
|
config(['ai.openrouter.api_key' => 'test-key']);
|
||||||
|
Http::fake([
|
||||||
|
'openrouter.ai/*' => Http::sequence()
|
||||||
|
->push(['choices' => [['message' => ['content' => 'The user wants a single short sentence in Portuguese, ATC style, no quotes.']]]], 200)
|
||||||
|
->push(['choices' => [['message' => ['content' => 'AZUL MAR, autorizado a deixar o cais, partida liberada.']]]], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->postJson('/city/port/dispatch')->assertStatus(202);
|
||||||
|
|
||||||
|
$clearance = cache()->get('port.clearance');
|
||||||
|
$this->assertNotNull($clearance);
|
||||||
|
$this->assertSame('model', $clearance['source']);
|
||||||
|
$this->assertStringContainsString('autorizado', $clearance['text']);
|
||||||
|
Http::assertSentCount(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Without an API key the deterministic fallback (with structure) 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/port/dispatch')
|
||||||
|
->assertStatus(202)
|
||||||
|
->assertJsonPath('accepted', true);
|
||||||
|
|
||||||
|
$clearance = cache()->get('port.clearance');
|
||||||
|
$this->assertNotNull($clearance);
|
||||||
|
$this->assertSame('fallback', $clearance['source']);
|
||||||
|
$this->assertStringContainsString('autorizado', $clearance['text']);
|
||||||
|
$this->assertContains($clearance['action'], ['berth', 'depart']);
|
||||||
|
|
||||||
|
$captain = AiAgent::query()->where('type', 'port_master')->firstOrFail();
|
||||||
|
$this->assertSame(1, (int) $captain->error_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal cooldown: one clearance per 30 seconds.
|
||||||
|
*/
|
||||||
|
public function test_dispatch_has_internal_cooldown(): void
|
||||||
|
{
|
||||||
|
config(['ai.openrouter.api_key' => null]);
|
||||||
|
Http::preventStrayRequests();
|
||||||
|
|
||||||
|
$this->postJson('/city/port/dispatch')->assertJsonPath('accepted', true);
|
||||||
|
$this->postJson('/city/port/dispatch')
|
||||||
|
->assertStatus(202)
|
||||||
|
->assertJsonPath('accepted', false)
|
||||||
|
->assertJsonPath('reason', 'cooldown');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Position sync reaches the quay inside the visit zone and stops at
|
||||||
|
* its edge; outside the port X-band the city bounds still apply.
|
||||||
|
*/
|
||||||
|
public function test_position_reaches_the_quay(): void
|
||||||
|
{
|
||||||
|
$this->postJson('/city/actions/position', ['pos_x' => 250, 'pos_z' => -350])
|
||||||
|
->assertOk();
|
||||||
|
$this->character->refresh();
|
||||||
|
$this->assertEquals(250.0, (float) $this->character->pos_x);
|
||||||
|
$this->assertEquals(-350.0, (float) $this->character->pos_z);
|
||||||
|
|
||||||
|
// Past the quay edge → clamped to the visit max.
|
||||||
|
$this->postJson('/city/actions/position', ['pos_x' => 400, 'pos_z' => -350])
|
||||||
|
->assertOk();
|
||||||
|
$this->character->refresh();
|
||||||
|
$this->assertEquals(266.0, (float) $this->character->pos_x);
|
||||||
|
|
||||||
|
// Outside the port X-band the deep Z stays blocked.
|
||||||
|
$this->postJson('/city/actions/position', ['pos_x' => 130, 'pos_z' => -350])
|
||||||
|
->assertOk();
|
||||||
|
$this->character->refresh();
|
||||||
|
$this->assertEquals((float) $this->character->city->bounds_min_z, (float) $this->character->pos_z);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?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',
|
||||||
|
'city.sign_university',
|
||||||
|
'city.sign_firestation',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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', 'signUniversity', 'signFirestation'] as $jsKey) {
|
||||||
|
$this->assertStringContainsString($jsKey.':', $html, "Missing i18n entry {$jsKey}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\AiAgent;
|
||||||
|
use App\Models\Character;
|
||||||
|
use App\Models\User;
|
||||||
|
use Database\Seeders\CityWorldSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Humanoid worker robots: seeded as walkable agents with allow-listed
|
||||||
|
* models and in-city positions so the viewer spawns them on routes.
|
||||||
|
*/
|
||||||
|
class WorkerRobotTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boot world fixtures.
|
||||||
|
*/
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->seed(CityWorldSeeder::class);
|
||||||
|
$user = User::factory()->create(['status' => User::STATUS_ACTIVE, 'is_active' => true]);
|
||||||
|
Character::ensureForUser($user);
|
||||||
|
$this->actingAs($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The payload carries the five worker robots; their AI personas
|
||||||
|
* (model/prompt) live only in the database — never in the public
|
||||||
|
* world payload.
|
||||||
|
*/
|
||||||
|
public function test_payload_contains_worker_robots(): void
|
||||||
|
{
|
||||||
|
$payload = $this->getJson('/city/data')->assertOk()->json();
|
||||||
|
|
||||||
|
$robots = array_values(array_filter(
|
||||||
|
$payload['agents'],
|
||||||
|
static fn (array $a): bool => $a['type'] === 'worker_robot'
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->assertCount(5, $robots);
|
||||||
|
$this->assertSame(
|
||||||
|
['Robô TR-01', 'Robô TR-02', 'Robô TR-03', 'Robô TR-04', 'Robô TR-05'],
|
||||||
|
array_column($robots, 'name')
|
||||||
|
);
|
||||||
|
foreach ($robots as $robot) {
|
||||||
|
$this->assertSame('active', $robot['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Personas are stored server-side only.
|
||||||
|
$this->assertArrayNotHasKey('model', $robots[0]);
|
||||||
|
$this->assertArrayNotHasKey('system_prompt', $robots[0]);
|
||||||
|
|
||||||
|
foreach (AiAgent::where('type', 'worker_robot')->get() as $agent) {
|
||||||
|
$this->assertContains($agent->model, config('ai.allowed_models'));
|
||||||
|
$this->assertNotEmpty($agent->system_prompt);
|
||||||
|
$this->assertStringContainsString('robô', $agent->system_prompt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Robot spawn points sit inside the city bounds (they seed the DB
|
||||||
|
* row; the viewer re-places them on walking routes).
|
||||||
|
*/
|
||||||
|
public function test_robot_positions_stay_inside_city_bounds(): void
|
||||||
|
{
|
||||||
|
$payload = $this->getJson('/city/data')->assertOk()->json();
|
||||||
|
$bounds = $payload['city']['bounds'];
|
||||||
|
|
||||||
|
$robots = array_values(array_filter(
|
||||||
|
$payload['agents'],
|
||||||
|
static fn (array $a): bool => $a['type'] === 'worker_robot'
|
||||||
|
));
|
||||||
|
$this->assertNotEmpty($robots);
|
||||||
|
|
||||||
|
foreach ($robots as $robot) {
|
||||||
|
$this->assertGreaterThanOrEqual($bounds['min_x'], $robot['position'][0]);
|
||||||
|
$this->assertLessThanOrEqual($bounds['max_x'], $robot['position'][0]);
|
||||||
|
$this->assertGreaterThanOrEqual($bounds['min_z'], $robot['position'][2]);
|
||||||
|
$this->assertLessThanOrEqual($bounds['max_z'], $robot['position'][2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||