Compare commits

..
20 Commits
Author SHA1 Message Date
marcocosta10000 a43176a68e fix(drone): resolve drone feed fog and flicker issues
- Add preserveDrawingBuffer to enable automated test screenshots
- Neutralize scene fog during drone render pass to prevent misted aerial view
- Render drone feed every frame instead of alternating with ground view
- Update visor styling with scanline effect and improved borders
- Add debug getters for WebGL renderer and scene information
2026-11-01 01:00:56 -03:00
marcocosta10000 68cb4eb9ad feat(drone): add personal drone with first-person camera and control panel
Implements a flyable quadcopter with WASD/arrow controls, altitude hold,
and landing. Features a scissored camera view in a command panel showing
real-time feed, telemetry, and control hints. Includes localized strings
for English, Spanish, and Portuguese.
2026-11-01 00:10:37 -03:00
marcocosta10000 580c830ffb feat(port): add sea port with quay, terminal, berths, and AI port master
Adds a new port district on the north-east coast with walkable quay, container yard, terminal, gantry cranes, and berths. Includes AI-driven port master (Capitão do Porto) and dock workers for ship dispatch, plus full frontend rendering of ships, boats, and trucks.
2026-10-31 23:33:06 -03:00
marcocosta10000 e230c391ad feat(city): add landing gear, cockpit glass, and winglets to aircraft
Implement proper ground clearance and landing gear for all aircraft
types (airliner, regional, prop). Add cockpit glass at the nose,
winglets at wing tips, and configurable propeller blade length.
Wheels now touch the runway surface (y=0) with the fuselage lifted
by clearance values.
2026-10-31 22:40:17 -03:00
marcocosta10000 ecf5291027 feat(city): add humanoid worker robots as city agents
Seed five worker robot agents (TR-01 to TR-05) with Portuguese
system prompts and active status. Extend the 3D humanoid renderer
with metallic materials, glowing visor, antenna beacon, and chest
status light for the worker_robot type. Add feature tests covering
payload serialization and city bounds validation.
2026-10-31 22:31:03 -03:00
marcocosta10000 46b15ec7be feat(city): add city landmarks, stores, luminous signs, and ATC echo guard
Add university and fire station civic buildings plus 7 new store types,
each with facade and luminous blade signs. Extend landmark material
colors and sign accents. Fix ATC dispatch to reject prompt-echoing AI
responses via heuristic guard. Add CityPlacesTest for collision and
roadway avoidance, and extend AirportTest and SignLabelsTest.
2026-10-31 22:22:44 -03:00
marcocosta10000 e7498e1566 feat(city): add airports, ATC agent, luminous signs, and AI model fallback chain
- Add two airports (north/south) with full geometry: runways, taxiways,
  aprons, terminals, hangars, stands, and helipads; walkable airport
  grounds extend beyond city bounds.
- Introduce ATC AI agent ("Torre de Controle City10") using AI_ATC_MODEL
  to generate takeoff clearances; includes dispatch endpoint with
  30-second cooldown and deterministic fallback on failure.
- Implement AI model fallback chain: preferred model → AI_FALLBACK_MODEL
  before failing, applied in ChatController and PersonTalkController.
- Add luminous sign labels (letreiros) for plazas, beach, food, hospital,
  school, city hall, and airports with day/night glow.
- Fix city presence cache: compute is_me per viewer instead of baking
  into shared per-second cache to prevent cross-user flag leakage.
- Add tests: AirportTest, CityPresenceTest, SignLabelsTest, and
  PersonTalkTest fallback model retry scenario.
2026-10-31 22:06:18 -03:00
marcocosta10000 a12807492f docs: update README with Phase 2 features and architecture details
Refresh project description, tech stack table, and add comprehensive
Phase 2 section documenting the playable 3D world, traffic system,
pedestrians, plazas, beach activities, and AI agents.
2026-10-31 20:40:11 -03:00
marcocosta10000 b8c31e7be4 feat(city): add east shore beach leisure activity
Characters can now relax on the beach sand to recover energy and
health. The server clamps position to the shoreline, exposes beach
geometry in the city payload, and the client renders animated waves,
sand, stalls, chairs, and ambient NPCs.

Includes Beach support class, new beach action endpoint, localization
strings, and feature tests.
2026-10-31 20:35:22 -03:00
marcocosta10000 4b71a9d0ff perf(city): optimize rendering for expanded city bounds
Tighten directional light shadow frustum to follow the player and
batch static geometries into merged meshes, reducing draw calls
significantly. Expand city world bounds to support 72 blocks and
add per-frame performance monitoring instrumentation.

BREAKING CHANGE: city bounds changed from ±80 to ±120/±160, update
any hardcoded client-side world assumptions.
2026-10-31 20:07:13 -03:00
marcocosta10000 b4f80cee24 feat(city): add zebra crosswalks and segmented sidewalk rendering
Renders zebra crosswalks with longitudinal bars and updates sidewalk
generation to stop at crossing bands instead of spanning across roadways.
2026-10-31 18:56:36 -03:00
marcocosta10000 81be63b4c3 feat(ai): add per-character AI model assignment and identity profiles
Implements deterministic model resolution (column → env map → pool →
default) and adds AI identity fields to characters. Migration backfills
existing rows with deterministic defaults. Controller uses resolved model
and identity block in system prompts for contextual conversations.
2026-10-31 18:35:57 -03:00
marcocosta10000 59963de995 feat(city): add bump-to-talk conversations with persistent context
Implements proximity-based street conversations between player characters and peers (human or agentic NPC). New PersonTalk model, controller, and migrations store daily message threads that persist across sessions and provide context for future interactions. Agent replies include backoff throttling and fallback persona handling to avoid dead-end chats.

Frontend changes refactor the chat panel to support both character and agent peers, opening automatically when the player moves within range, polling for replies, rendering day separators, and displaying rate-limit warnings. Styling updated to a transparent blurred panel. Translations extended with new hint text and rate-limit messages across en, es, and pt_BR.
2026-10-31 17:59:22 -03:00
marcocosta10000 50a6d40e8e fix(audio): increase footstep volume and duration
Raise peak amplitude from 0.12 to 0.3 and extend duration from 0.06 to 0.07 seconds for better walking feedback
2026-10-31 16:56:43 -03:00
marcocosta10000 4f497844bc feat(city): add touch mode and drag-to-look camera controls
- Add touchMode flag and lookDrag state for non-pointer-lock input
- Implement dismissGate() and tryRequestLock() for graceful fallback
- Add applyLook() helper for camera rotation with pitch clamping
- Support drag-to-look via pointer events on renderer.domElement
- Prevent context menu and page scroll during active camera control
2026-10-31 16:47:56 -03:00
marcocosta10000 bd6801994a fix(city): correct traffic signal orientation for right-hand traffic
- Fix vehicle signal head rotation to face approaching traffic correctly
- Refactor pedestrian signal positioning with descriptive naming
- Add getSignalHeads() method for snapshotting signal state
- Add cache-busting query parameter to app.js script tag
2026-10-31 16:25:04 -03:00
marcocosta10000 10603a120f chore(test): update file permissions for Playwright MCP artifacts 2026-10-31 15:58:05 -03:00
marcocosta10000 a4b1cc8d78 fix(city): enforce right-hand traffic and refine collision sampling
Every block now circulates clockwise so adjacent blocks use opposite
lanes of a shared road, eliminating contra-mão paths. Collision
detection samples bodies more densely (0.35 step, 0.88 lateral
tolerance) to catch near-misses, and corner radii were increased for
smoother turns.

Also adds cosmetic car details — front/rear license plates, side
mirrors with mounting arms, and a roof trim panel — plus updated
traffic-rule screenshots.
2026-10-24 16:48:33 -03:00
marcocosta10000 a1357fe953 feat(city): add vehicle tail lights, beams and smooth dusk lighting 2026-10-24 16:34:07 -03:00
marcocosta10000 693cd4cb00 gfgfg 2026-10-24 15:42:20 -03:00
103 changed files with 8381 additions and 393 deletions
+15
View File
@@ -60,6 +60,21 @@ MAIL_FROM_NAME="${APP_NAME}"
OPENROUTER_API_KEY=
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_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

+174 -20
View File
@@ -1,10 +1,12 @@
# City10 — Cidade 3D Virtual com Agentes de IA
**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**.
Agentes (Prefeito, pedestres, lojistas) tomam decisões estratégicas em tempo real
através da API [OpenRouter.ai](https://openrouter.ai), restrita a uma lista de
modelos gratuitos.
inteligência artificial, construída sobre **Laravel 13** e **Three.js**. O
visualizador3D é jogável no navegador: trânsito semafórico, pedestres, agentes
(Prefeito, lojistas), conversas de rua com IA, praças com academia e uma praia
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.
> Contato: [contato@syspanel.com.br](mailto:contato@syspanel.com.br) ·
@@ -17,15 +19,15 @@ modelos gratuitos.
| Camada | Tecnologia | Responsabilidade |
|---|---|---|
| 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 |
| Cache / Fila | Redis / Database | Sessões, cache e jobs |
| Frontend 3D | Three.js (Fase 2+) | Renderização da cidade no navegador |
| Tempo Real | Laravel Reverb (planejado) | Sincronização de estado entre clientes |
| Cérebro dos Agentes | OpenRouter.ai | Raciocínio dos NPCs com modelos gratuitos |
| Banco de Dados | MariaDB (MySQL) | Persistência de usuários, lojas, agentes, conversas e consentimentos |
| Cache / Fila | Database (sessões/cache/jobs) | Sessões, cache, agendamentos e throttle |
| Frontend 3D | Three.js | Mundo jogável no navegador (cidade, trânsito, praia, clima) |
| 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 e conversas com modelos gratuitos |
---
## Fase 1 (Atual) — Entregue
## Fase 1 — Plataforma (Entregue)
- 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).
@@ -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
- PHP **≥ 8.3** (com `pdo_mysql`, `mbstring`, `curl`, `zip`, `gd`, `bcmath`)
@@ -74,13 +166,28 @@ DB_USERNAME=root
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:
```env
OPENROUTER_API_KEY=<sua_chave>
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
@@ -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
Base URL: `http://localhost:16980/api`
Base URL: `http://localhost:16980/api` (Bearer Sanctum)
### Públicos
@@ -145,26 +267,52 @@ Base URL: `http://localhost:16980/api`
| GET | `/city/world` | Snapshot da cidade (lojas + agentes com coordenadas 3D) |
| GET/POST | `/stores` | Lista/cria lojas |
| 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 |
| POST | `/legal/accept` | Registra o aceite (auditoria LGPD) |
| GET | `/legal/history` | Histórico de consentimentos do usuário |
### Somente Administrador (`/admin`)
### Somente Administrador
| Método | Rota | Descrição |
|---|---|---|
| GET/POST | `/admin/users` | Lista/cria usuários |
| GET/PUT/DELETE | `/admin/users/{id}` | Gerencia usuários |
| GET/POST | `/admin/agents` | Lista/cria agentes de IA |
| GET/PUT/DELETE | `/admin/agents/{id}` | Gerencia agentes |
| GET | `/admin/agents-meta/models` | Lista os modelos gratuitos permitidos |
| CRUD | `/api/users` | Gestão de usuários |
| CRUD | `/api/agents` | Gestão de agentes de IA |
| GET | `/api/agents-meta/models` | Lista os modelos gratuitos permitidos |
| GET | `/api/agents-world/seed` | Mundo de agentes (seed 3D) |
| 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
Somente os modelos **gratuitos** abaixo podem ser atribuídos a agentes
(validado no backend em `config/ai.php`):
Somente os modelos abaixo podem ser usados (validado no backend em
`config/ai.php`; os quatro últimos foram verificados ao vivo no catálogo do
OpenRouter). Exceção aprovada pelo responsável: `xiaomi/mimo-v2.6-flash`
(pago, custo mínimo ~1,4e-7/token, verificado ao vivo) — usado como
**fallback** de todas as cadeias de chamada:
```
poolside/laguna-m.1:free cohere/north-mini-code:free
@@ -176,6 +324,9 @@ nvidia/nemotron-3-nano-omni-30b-a3b-reasoning: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
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.
- **Direitos do titular** (art. 18 LGPD): histórico de consentimentos disponível
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).
---
+7 -3
View File
@@ -35,9 +35,13 @@ ao trabalhar neste repositório.
## 2. Regras para Modelos de IA (Agentes do City10)
2.1. **Somente modelos gratuitos allow-listed.** Agentes só podem usar
modelos definidos em `config/ai.php` (`allowed_models`). A validação
acontece no backend (`AiAgentRequest`) — não contorne.
2.1. **Somente modelos allow-listed.** Agentes só podem usar modelos
definidos em `config/ai.php` (`allowed_models`). A validação
acontece no backend (`AiAgentRequest`) — não contorne. Por padrão a
lista contém modelos gratuitos; **exceção aprovada pelo responsável**:
`xiaomi/mimo-v2.6-flash` (pago, custo mínimo ~1,4e-7/token,
verificado ao vivo) — atua como **modelo de fallback** das cadeias
de chamada (`AI_FALLBACK_MODEL`): preferido → default → fallback.
2.2. **Nunca envie dados pessoais ao modelo.** Ao chamar o OpenRouter, envie
apenas contexto simulado da cidade (posições, metas, estado de lojas).
BIN
View File
Binary file not shown.
+31 -2
View File
@@ -127,22 +127,51 @@ class ChatController extends Controller
."\n\nContexto simulado da cidade: ".json_encode($cityContext, JSON_UNESCAPED_UNICODE)
."\nResponda de forma curta e natural, em português. Não invente dados pessoais de usuários.";
// Preferred agent model first, then the configured fallback
// chain (AI_FALLBACK_MODEL) before failing the turn.
$models = [$agent->model];
$fallback = config('ai.fallback_model');
if (is_string($fallback) && $fallback !== ''
&& $fallback !== $agent->model
&& in_array($fallback, config('ai.allowed_models', []), true)) {
$models[] = $fallback;
}
$response = null;
foreach ($models as $candidate) {
try {
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$apiKey,
'HTTP-Referer' => config('ai.openrouter.app_url'),
'X-Title' => config('ai.openrouter.app_name'),
])->timeout((int) config('ai.openrouter.timeout', 30))
->post(config('ai.openrouter.base_url').'/chat/completions', [
'model' => $agent->model,
'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()) {
self::fail($agent, 'HTTP '.$response->status());
Log::warning('NPC think model failed', ['model' => $candidate, 'status' => $response->status()]);
$response = null;
continue;
}
break;
}
if ($response === null) {
self::fail($agent, 'all models failed');
return;
}
@@ -9,8 +9,11 @@ use App\Models\City;
use App\Models\InventoryItem;
use App\Models\Job;
use App\Models\Transaction;
use App\Support\Airports;
use App\Support\Beach;
use App\Support\CityPlazas;
use App\Support\MissionTracker;
use App\Support\Port;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -48,8 +51,24 @@ class CityActionController extends Controller
}
$city = $character->city;
$x = max((float) $city->bounds_min_x, min((float) $city->bounds_max_x, (float) $data['pos_x']));
$z = max((float) $city->bounds_min_z, min((float) $city->bounds_max_z, (float) $data['pos_z']));
// Players may persist onto the east beach sand (past the road grid)
// 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([
'pos_x' => $x,
@@ -100,12 +119,63 @@ class CityActionController extends Controller
'build' => $this->build($request, $character),
'apply-job' => $this->applyJob($request, $character),
'exercise' => $this->exercise($request, $character),
'beach' => $this->beach($request, $character),
'stop-exercise' => $this->stopExercise($character),
'ride' => $this->ride($request, $character),
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.
*/
@@ -35,8 +35,12 @@ class CityPresenceController extends Controller
->first() ?? $city;
}
// Rows are shared across users for one second, so is_me must NOT
// be baked into the cached payload — it is computed per viewer
// below (otherwise user B sees user A's is_me flags and could see
// their own character as a remote citizen).
$cacheKey = 'presence.'.$city->id.'.'.now()->timestamp;
$citizens = Cache::remember($cacheKey, 1, function () use ($city, $me) {
$citizens = Cache::remember($cacheKey, 1, function () use ($city) {
return Character::query()
->where('city_id', $city->id)
->where(function ($q): void {
@@ -52,13 +56,18 @@ class CityPresenceController extends Controller
'status' => $c->state,
'position' => [(float) $c->pos_x, (float) $c->pos_y, (float) $c->pos_z],
'is_online' => (bool) $c->is_online,
'is_me' => (int) $c->id === (int) $me->id,
'owner_name' => $c->user?->name,
])
->values()
->all();
});
$viewerId = (int) $me->id;
$citizens = array_map(
static fn (array $c): array => $c + ['is_me' => (int) $c['id'] === $viewerId],
$citizens
);
return response()->json([
'city_id' => $city->id,
'server_time' => now()->toIso8601String(),
@@ -9,10 +9,15 @@ use App\Models\Character;
use App\Models\City;
use App\Models\Job;
use App\Models\Store;
use App\Support\Airports;
use App\Support\Beach;
use App\Support\CityPlazas;
use App\Support\Port;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
/**
@@ -141,6 +146,9 @@ class CityViewerController extends Controller
] : null,
'layout' => $this->cityLayout($city, $stores->all()),
'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 => [
'id' => $a->id,
'name' => $a->name,
@@ -156,6 +164,446 @@ class CityViewerController extends Controller
return response()->json($payload)->header('Cache-Control', 'no-store');
}
/**
* Airport summary: sites, control-tower agent and latest clearance.
*
* The client polls this while aircraft operate so AI-generated tower
* clearances (AI_ATC_MODEL) show up as they are produced.
*/
public function airport(Request $request): JsonResponse
{
$city = $this->resolveCity($request);
$atc = AiAgent::query()->where('type', 'atc')->first();
return response()->json([
'sites' => array_map(
static fn (array $site): array => ['id' => $site['id'], 'name' => $site['name'], 'code' => $site['code']],
Airports::geometry($city)['sites']
),
'atc' => $atc !== null ? [
'id' => (int) $atc->id,
'name' => $atc->name,
'model' => $atc->model,
'status' => $atc->status,
'error_count' => (int) $atc->error_count,
] : null,
'clearance' => cache()->get('airport.clearance'),
])->header('Cache-Control', 'no-store');
}
/**
* Ask the control tower for the next takeoff clearance (fire and
* forget). Internally throttled: one generation per 30 seconds.
*/
public function dispatch(): JsonResponse
{
if (! cache()->add('airport.dispatch.lock', 1, 30)) {
return response()->json(['accepted' => false, 'reason' => 'cooldown'], 202);
}
$atc = AiAgent::query()->where('type', 'atc')->first();
if ($atc === null) {
return response()->json(['message' => __('city.atc_missing')], 422);
}
dispatch(function (): void {
self::requestClearance();
})->afterResponse();
return response()->json(['accepted' => true], 202);
}
/**
* Generate one ATC clearance with the configured tower model.
*
* Runs after the response (no queue worker needed). The payload only
* carries simulated flight data — no user PII (RULES.md 2.2). On any
* failure a deterministic clearance is stored so operations continue
* and the failure is counted for backoff (RULES.md 2.4).
*/
public static function requestClearance(): void
{
try {
$atc = AiAgent::query()->where('type', 'atc')->first();
if ($atc === null) {
return;
}
$flight = self::clearanceFlight();
// Backoff (RULES.md 2.4): after 5 consecutive failures only
// retry every 5 minutes; never call without an API key.
$mayTry = false;
if (filled(config('ai.openrouter.api_key'))) {
$lastTry = (int) cache()->get('airport.atc.last_try', 0);
$mayTry = (int) $atc->error_count < 5 || $lastTry < time() - 300;
if ($mayTry) {
cache()->put('airport.atc.last_try', time(), now()->addHour());
}
}
$text = $mayTry ? self::askTower($atc, $flight) : null;
if ($text === null) {
// Count real attempts only (backoff-blocked skips and
// missing-key short-circuits keep their own semantics).
$attempted = $mayTry || ! filled(config('ai.openrouter.api_key'));
$errors = (int) $atc->error_count + ($attempted ? 1 : 0);
$atc->update([
'status' => $errors >= 5 ? 'error' : 'active',
'error_count' => $errors,
]);
$text = sprintf(
'Torre: %s (%s) autorizado para decolagem na pista %s.',
$flight['number'],
$flight['airline'],
$flight['runway']
);
} else {
$atc->update([
'status' => 'active',
'error_count' => 0,
'last_think_at' => now(),
]);
}
cache()->put('airport.clearance', [
'text' => $text,
'flight' => $flight['number'],
'airline' => $flight['airline'],
'runway' => $flight['runway'],
'source' => $atc->error_count > 0 ? 'fallback' : 'model',
'at' => now()->toIso8601String(),
], now()->addHours(6));
$log = $atc->last_decision ?? [];
$log[] = ['role' => 'atc', 'content' => $text, 'at' => now()->toIso8601String()];
$atc->update(['last_decision' => array_slice($log, -20)]);
} catch (\Throwable $e) {
Log::warning('ATC clearance generation failed', ['error' => $e->getMessage()]);
}
}
/**
* Random simulated flight for the next clearance (no real bookings).
*
* @return array{number: string, airline: string, runway: string}
*/
private static function clearanceFlight(): array
{
$airlines = ['City10 Air', 'AzulCéu', 'Bandeirante', 'Sol Nascente', 'Pampa Air', 'NorteLine'];
$runways = ['09', '27', '18', '36'];
return [
'number' => 'C10-'.random_int(101, 399),
'airline' => $airlines[array_rand($airlines)],
'runway' => $runways[array_rand($runways)],
];
}
/**
* 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.
*/
@@ -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,
]);
}
}
+19 -1
View File
@@ -17,7 +17,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* ticker mutates needs and job progress continuously while nobody is online.
*/
#[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',
'state', 'job_id', 'house_id', 'education', 'exercise_count', 'is_online', 'last_tick_at',
'hospitalized_until', 'face', 'beard', 'accessory', 'ride_kind', 'ride_until',
@@ -27,6 +28,20 @@ class Character extends Model
/** @use HasFactory<CharacterFactory, $this> */
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_SLEEPING = 'sleeping';
@@ -176,6 +191,8 @@ class Character extends Model
'user_id' => $user->id,
'city_id' => $city->id,
'name' => $firstName,
'ai_age' => random_int(18, 68),
'ai_personality' => self::AI_PERSONALITIES[array_rand(self::AI_PERSONALITIES)],
'skin' => random_int(1, 4),
'hair' => random_int(1, 4),
'shirt' => random_int(1, 5),
@@ -220,6 +237,7 @@ class Character extends Model
protected function casts(): array
{
return [
'ai_age' => 'integer',
'pos_x' => 'float',
'pos_y' => 'float',
'pos_z' => 'float',
+119
View File
@@ -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);
});
});
}
}
+147
View File
@@ -0,0 +1,147 @@
<?php
namespace App\Support;
use App\Models\City;
/**
* Airport grounds north and south of the city grid.
*
* Geometry derives from the layout edges so both airports always sit on
* open land beyond the roads. The same descriptor drives the client-side
* build (runways, taxiways, aprons, terminals, hangars, stands, helipads)
* and the walkable-visit zone used by the position clamp.
*/
class Airports
{
/** Half width (X) of the walkable airport strips. */
public const VISIT_HALF_WIDTH = 110.0;
/** Walkable Z range inside the airport strips (both airports). */
public const VISIT_MIN_Z = -400.0;
public const VISIT_MAX_Z = 400.0;
/**
* Layout edge north of the city (same derivation the road grid uses).
*/
public static function layoutMaxZ(City $city): float
{
return (float) (ceil(((float) $city->bounds_max_z) / 40) * 40 + 40);
}
/**
* Layout edge south of the city.
*/
public static function layoutMinZ(City $city): float
{
return (float) (floor(((float) $city->bounds_min_z) / 40) * 40 - 40);
}
/**
* Full airports descriptor for the world payload.
*
* @return array{visit: array<string, float>, sites: list<array<string, mixed>>}
*/
public static function geometry(City $city): array
{
return [
'visit' => [
'half_width' => self::VISIT_HALF_WIDTH,
'min_z' => self::VISIT_MIN_Z,
'max_z' => self::VISIT_MAX_Z,
],
'sites' => [
self::northSite(self::layoutMaxZ($city)),
self::southSite(self::layoutMinZ($city)),
],
];
}
/**
* Large airport south of the city (+Z): east-west runway.
*
* @return array<string, mixed>
*/
private static function northSite(float $lz): array
{
// lz = 160 for the shipped bounds: runway at 350, apron at 285.
$standZ = $lz + 135;
return [
'id' => 'sul',
'name' => 'Aeroporto City10 Sul',
'code' => 'CTS',
'runway' => [
'center' => [0.0, $lz + 190],
'length' => 180.0,
'width' => 18.0,
'axis' => 'x',
'designators' => ['09', '27'],
],
'taxiways' => [
['center' => [0.0, $lz + 160], 'length' => 170.0, 'width' => 10.0, 'axis' => 'x'],
['center' => [-80.0, $lz + 175], 'length' => 30.0, 'width' => 8.0, 'axis' => 'z'],
['center' => [80.0, $lz + 175], 'length' => 30.0, 'width' => 8.0, 'axis' => 'z'],
],
'apron' => ['center' => [0.0, $lz + 125], 'size' => [170.0, 60.0]],
'terminal' => ['center' => [-45.0, $lz + 105], 'size' => [80.0, 18.0]],
'hangars' => [
['center' => [35.0, $lz + 106], 'size' => [26.0, 20.0]],
['center' => [65.0, $lz + 106], 'size' => [26.0, 20.0]],
],
'tower' => ['center' => [10.0, $lz + 118]],
'stands' => [
[-70.0, $standZ],
[-35.0, $standZ],
[0.0, $standZ],
[35.0, $standZ],
],
'helipad' => ['center' => [-100.0, $lz + 165], 'radius' => 8.0],
];
}
/**
* Regional airport north of the city (-Z): north-south runway.
*
* @return array<string, mixed>
*/
private static function southSite(float $lz): array
{
// lz = -160 for the shipped bounds: runway centre at -300.
$cZ = $lz - 140;
return [
'id' => 'norte',
'name' => 'Aeroporto Regional do Norte',
'code' => 'CRN',
'runway' => [
'center' => [-50.0, $cZ],
'length' => 160.0,
'width' => 14.0,
'axis' => 'z',
'designators' => ['18', '36'],
],
'taxiways' => [
['center' => [-30.0, $cZ], 'length' => 150.0, 'width' => 8.0, 'axis' => 'z'],
// Connectors aligned with the takeoff lineup (-240) and the
// landing rollout exit (-370) so taxi paths stay on pavement.
['center' => [-40.0, $lz - 80], 'length' => 20.0, 'width' => 7.0, 'axis' => 'x'],
['center' => [-40.0, $lz - 210], 'length' => 20.0, 'width' => 7.0, 'axis' => 'x'],
],
'apron' => ['center' => [15.0, $cZ], 'size' => [90.0, 140.0]],
'terminal' => ['center' => [55.0, $lz - 95], 'size' => [34.0, 14.0]],
'hangars' => [
['center' => [55.0, $lz - 205], 'size' => [24.0, 18.0]],
],
'tower' => ['center' => [40.0, $lz - 80]],
'stands' => [
[0.0, $lz - 95],
[0.0, $lz - 155],
[30.0, $lz - 125],
[30.0, $lz - 185],
],
'helipad' => ['center' => [75.0, $lz - 85], 'radius' => 7.0],
];
}
}
+87
View File
@@ -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;
}
}
+98
View File
@@ -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'];
}
}
+91
View File
@@ -1,5 +1,33 @@
<?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 [
/*
@@ -50,8 +78,71 @@ return [
'poolside/laguna-xs-2.1:free',
'inclusionai/ling-3.0-flash-vl: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
@@ -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']);
});
}
};
+1 -1
View File
@@ -119,7 +119,7 @@ class CityContentSeeder extends Seeder
'description' => 'Material de construção e ferramentas.',
'type' => 'hardware',
'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,
'is_open' => true,
],
+163 -4
View File
@@ -2,6 +2,7 @@
namespace Database\Seeders;
use App\Models\AiAgent;
use App\Models\Building;
use App\Models\Character;
use App\Models\City;
@@ -9,6 +10,7 @@ use App\Models\Job;
use App\Models\Mission;
use App\Models\Store;
use App\Models\User;
use App\Support\Airports;
use Illuminate\Database\Seeder;
/**
@@ -28,15 +30,113 @@ class CityWorldSeeder extends Seeder
['slug' => 'city10'],
[
'name' => 'City10',
'bounds_min_x' => -80,
'bounds_max_x' => 80,
'bounds_min_z' => -80,
'bounds_max_z' => 80,
// Performance-tuned size: 72 blocks (9 × 8) after the
// 40-unit road grid and its outer margin are derived from
// these bounds (layout −160..200 × −160..160).
'bounds_min_x' => -120,
'bounds_max_x' => 160,
'bounds_min_z' => -120,
'bounds_max_z' => 120,
'is_active' => true,
'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();
$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(
['code' => 'exercise-5'],
[
+21 -1
View File
@@ -88,7 +88,7 @@
"city.error": "Failed to load city data.",
"city.stores_count": "stores",
"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.selected": "Selected",
"city.open": "Open",
@@ -102,6 +102,7 @@
"city.chat_responding": "responding…",
"city.chat_accepted": "Message sent to agent.",
"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_with": "Chatting with",
"city.clock": "Time",
@@ -211,6 +212,25 @@
"city.exercise_active": "Exercising",
"city.exercise_stopHint": "to stop",
"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_awake": "You must be awake to exercise.",
"city.exercise_tired": "You are too tired to exercise.",
+21 -1
View File
@@ -88,7 +88,7 @@
"city.error": "Error al cargar los datos de la ciudad.",
"city.stores_count": "tiendas",
"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.selected": "Seleccionada",
"city.open": "Abierta",
@@ -102,6 +102,7 @@
"city.chat_responding": "respondiendo…",
"city.chat_accepted": "Mensaje enviado al agente.",
"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_with": "Chateando con",
"city.clock": "Hora",
@@ -211,6 +212,25 @@
"city.exercise_active": "Ejercitándose",
"city.exercise_stopHint": "para parar",
"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_awake": "Debe estar despierto para ejercitarse.",
"city.exercise_tired": "Está demasiado cansado para ejercitarse.",
+21 -1
View File
@@ -88,7 +88,7 @@
"city.error": "Falha ao carregar os dados da cidade.",
"city.stores_count": "lojas",
"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.selected": "Selecionada",
"city.open": "Aberta",
@@ -102,6 +102,7 @@
"city.chat_responding": "respondendo…",
"city.chat_accepted": "Mensagem enviada ao agente.",
"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_with": "Conversando com",
"city.clock": "Horário",
@@ -211,6 +212,25 @@
"city.exercise_active": "Exercitando",
"city.exercise_stopHint": "para parar",
"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_awake": "Você precisa estar acordado para se exercitar.",
"city.exercise_tired": "Você está cansado demais para se exercitar.",
+4201 -313
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -379,7 +379,9 @@ class CityAudioEngine {
this._tone(466.16, 0.25, { type: 'sawtooth', peak: 0.16 });
break;
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;
case 'thunder':
this.thunder();
+50 -5
View File
@@ -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-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-drone" class="btn" style="padding:.25rem .45rem; font-size:.75rem;">🚁 {{ __('city.drone') }}</button>
</div>
{{-- Marketplace --}}
@@ -237,14 +238,44 @@
padding:.35rem .6rem; font-size:.75rem; color:var(--muted);">{{ __('city.hint') }}</span>
</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"
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;
overflow:hidden; box-shadow:0 12px 30px rgba(0,0,0,.5); flex-direction:column;">
background:rgba(15,23,42,.62); backdrop-filter:blur(9px); -webkit-backdrop-filter:blur(9px);
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"
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;">
<span id="chat-dot" style="width:8px; height:8px; border-radius:50%; background:#22c55e; flex-shrink:0;"></span>
<div style="min-width:0;">
@@ -323,6 +354,7 @@
chatWith: @json(__('city.chat_with')),
chatSend: @json(__('city.chat_send')),
chatError: @json(__('city.chat_error')),
talkRateLimited: @json(__('city.talk_ratelimited')),
clock: @json(__('city.clock')),
weather: @json(__('city.weather')),
weather_clear: @json(__('city.weather_clear')),
@@ -363,6 +395,19 @@
hospitalReleased: @json(__('city.hospital_released')),
exerciseUse: @json(__('city.exercise_use')),
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')),
exerciseStart: @json(__('city.exerciseStart')),
exerciseStop: @json(__('city.exerciseStop')),
@@ -372,5 +417,5 @@
citySlug: @json($city->slug),
};
</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
+25
View File
@@ -17,6 +17,7 @@ use App\Http\Controllers\Web\CityViewerController;
use App\Http\Controllers\Web\DashboardController;
use App\Http\Controllers\Web\LanguageController;
use App\Http\Controllers\Web\LegalController;
use App\Http\Controllers\Web\PersonTalkController;
use App\Http\Middleware\EnsureTermsAccepted;
use App\Http\Middleware\EnsureUserIsAdmin;
use Illuminate\Support\Facades\Route;
@@ -120,6 +121,30 @@ Route::middleware('auth')->group(function (): void {
->middleware('throttle:30,1')
->name('city.chat.send');
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');
});
});
+259
View File
@@ -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);
}
}
+117
View File
@@ -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'));
}
}
+9 -3
View File
@@ -8,6 +8,8 @@ use App\Models\InventoryItem;
use App\Models\Job;
use App\Models\Transaction;
use App\Models\User;
use App\Support\Beach;
use App\Support\Port;
use Database\Seeders\CityWorldSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
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
{
@@ -65,8 +69,10 @@ class CityActionsTest extends TestCase
$this->character->refresh();
$city = $this->character->city;
$this->assertSame((float) $city->bounds_max_x, $this->character->pos_x);
$this->assertSame((float) $city->bounds_min_z, $this->character->pos_z);
// Clamped X lands on the shoreline/quay band (>= VISIT_MIN_X), so
// 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);
}
/**
+146
View File
@@ -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');
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
namespace Tests\Feature;
use App\Models\Character;
use App\Models\User;
use Database\Seeders\CityWorldSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Presence feed correctness: the per-second rows are shared, but the
* is_me flag must always reflect the viewer of the current request —
* otherwise a citizen sees their own character as a remote player and
* the bump-to-talk chat opens with themselves.
*/
class CityPresenceTest extends TestCase
{
use RefreshDatabase;
/**
* Boot world fixtures.
*/
protected function setUp(): void
{
parent::setUp();
$this->seed(CityWorldSeeder::class);
}
/**
* Two viewers polling within the same second each get their own
* is_me flags from the shared cache.
*/
public function test_is_me_is_per_viewer_with_shared_cache(): void
{
[$userA, $charA] = $this->makeCitizen();
[$userB, $charB] = $this->makeCitizen();
// Viewer A seeds the shared per-second cache first.
$first = $this->actingAs($userA)
->getJson('/city/presence')
->assertOk()
->json('citizens');
$this->assertTrue($this->flagFor($first, $charA->id), 'A must see itself as me');
$this->assertFalse($this->flagFor($first, $charB->id), 'A must not see B as me');
// Viewer B hits the SAME cache entry moments later.
$second = $this->actingAs($userB)
->getJson('/city/presence')
->assertOk()
->json('citizens');
$this->assertTrue($this->flagFor($second, $charB->id), 'B must see itself as me');
$this->assertFalse($this->flagFor($second, $charA->id), 'B must not see A as me');
}
/**
* Create an active user paired with its in-world character.
*
* @return array{0: User, 1: Character}
*/
private function makeCitizen(): array
{
$user = User::factory()->create([
'status' => User::STATUS_ACTIVE,
'is_active' => true,
]);
return [$user, Character::ensureForUser($user)];
}
/**
* Extract the is_me flag for a character id from a presence payload.
*
* @param list<array<string, mixed>> $citizens
*/
private function flagFor(array $citizens, int $characterId): bool
{
foreach ($citizens as $c) {
if ((int) $c['id'] === $characterId) {
return (bool) $c['is_me'];
}
}
$this->fail("Character {$characterId} missing from presence payload");
}
}
+32
View File
@@ -44,4 +44,36 @@ class CityWorldSeedingTest extends TestCase
$active = User::where('is_active', true)->where('status', '!=', User::STATUS_ACTIVE)->count();
$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']));
}
}
+688
View File
@@ -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);
}
}

Some files were not shown because too many files have changed in this diff Show More