feat: add City10 platform with Laravel 13, AI agents, 3D viewer, and LGPD compliance

Initial commit of the complete City10 virtual city simulation platform.

Backend (Laravel 13):
- REST API with Sanctum tokens and session-based web authentication
- Role-based access control (admin/user) with middleware protection
- AI agent CRUD with OpenRouter.ai integration restricted to free models
- Store management with 3D coordinates and AI shopkeeper assignment
- Versioned legal documents (Terms, Privacy, Cookies) with LGPD audit trail
- Math CAPTCHA on public forms and multi-language support (pt_BR, en, es)

Frontend:
- Responsive Blade templates with Tailwind CSS v4
- Three.js 3D city viewer with SSE chat streaming to AI agents
- Admin panel for store and user administration

Infrastructure:
- MariaDB/MySQL database with comprehensive migrations
- Database seeders for demo content and test accounts
- PHPUnit test suite and Laravel Pint code styling
- Vite build configuration with hot reload
This commit is contained in:
2026-10-17 20:46:15 -03:00
commit 79d12fa2f4
128 changed files with 17053 additions and 0 deletions
Executable
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[{compose,docker-compose}.{yml,yaml}]
indent_size = 4
Executable
+69
View File
@@ -0,0 +1,69 @@
APP_NAME=City10
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost:16980
APP_LOCALE=pt_BR
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=pt_BR
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=mariadb
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=root
DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="no-reply@syspanel.com.br"
MAIL_FROM_NAME="${APP_NAME}"
# OpenRouter.ai — keep the key in .env only, never commit it.
OPENROUTER_API_KEY=
AI_DEFAULT_MODEL=deepseek/deepseek-v4-flash-0731
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
Executable
+11
View File
@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore
Executable
+27
View File
@@ -0,0 +1,27 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.codex
/.cursor/
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/fonts-manifest.dev.json
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
_ide_helper.php
Homestead.json
Homestead.yaml
Thumbs.db
Executable
+2
View File
@@ -0,0 +1,2 @@
ignore-scripts=true
audit=true
Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Executable
+47
View File
@@ -0,0 +1,47 @@
<laravel-boost-guidelines>
# Laravel Application
This repository contains a Laravel application. Complete the following setup before working on the user's request.
## Prerequisites
Verify that PHP and Composer are available:
```sh
php -v
composer -V
```
If either command is unavailable, detect the user's operating system and install the prerequisites with the appropriate command:
macOS:
```sh
/bin/bash -c "$(curl -fsSL https://php.new/install/mac/8.5)"
```
Windows PowerShell:
```powershell
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://php.new/install/windows/8.5'))
```
Linux:
```sh
/bin/bash -c "$(curl -fsSL https://php.new/install/linux/8.5)"
```
After installation, ask the user to restart their terminal. If the agent needs the restarted shell to continue, ask the user to reopen their terminal and rerun their original prompt.
## Agent Setup
Install Laravel Boost from the application root before making application changes:
```sh
composer require laravel/boost --dev
php artisan boost:install
```
Boost replaces these bootstrap instructions with guidelines tailored to the application. After installation, read `AGENTS.md` again and continue with the user's original request using the generated guidelines.
</laravel-boost-guidelines>
Executable
+47
View File
@@ -0,0 +1,47 @@
<laravel-boost-guidelines>
# Laravel Application
This repository contains a Laravel application. Complete the following setup before working on the user's request.
## Prerequisites
Verify that PHP and Composer are available:
```sh
php -v
composer -V
```
If either command is unavailable, detect the user's operating system and install the prerequisites with the appropriate command:
macOS:
```sh
/bin/bash -c "$(curl -fsSL https://php.new/install/mac/8.5)"
```
Windows PowerShell:
```powershell
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://php.new/install/windows/8.5'))
```
Linux:
```sh
/bin/bash -c "$(curl -fsSL https://php.new/install/linux/8.5)"
```
After installation, ask the user to restart their terminal. If the agent needs the restarted shell to continue, ask the user to reopen their terminal and rerun their original prompt.
## Agent Setup
Install Laravel Boost from the application root before making application changes:
```sh
composer require laravel/boost --dev
php artisan boost:install
```
Boost replaces these bootstrap instructions with guidelines tailored to the application. After installation, read `AGENTS.md` again and continue with the user's original request using the generated guidelines.
</laravel-boost-guidelines>
+81
View File
@@ -0,0 +1,81 @@
LICENÇA PROPRIETÁRIA — CITY10
=============================
Copyright (c) 2026 Syspanel — Santa Rita do Sapucaí/MG, Brasil
Todos os direitos reservados.
Contato: contato@syspanel.com.br — https://syspanel.com.br
1. DEFINIÇÕES
-------------
"Licenciante" : Syspanel, titular dos direitos patrimoniais sobre o Software.
"Licenciado" : pessoa física ou jurídica autorizada a utilizar o Software.
"Software" : o sistema City10, incluindo código-fonte, banco de dados,
documentação, marca, interface e quaisquer work derivados.
2. CONCESSÃO DA LICENÇA
-----------------------
O Licenciante concede ao Licenciado uma licença limitada, não exclusiva,
não transferível e revogável para utilizar o Software exclusivamente para
fins internos ou de avaliação, nos termos deste documento.
3. RESTRIÇÕES
-------------
É vedado ao Licenciado, sem autorização prévia e por escrito do Licenciante:
3.1. Copiar, modificar, adaptar, traduzir ou criar obras derivadas do
Software, total ou parcialmente;
3.2. Alugar, arrendar, emprestar, vender, redistribuir ou sublicenciar
o Software;
3.3. Realizar engenharia reversa, descompilar ou desmontar o Software,
exceto na medida estritamente permitida pela legislação aplicável;
3.4. Remover ou alterar quaisquer avisos de propriedade, marcas
registradas ou notações de direitos autorais;
3.5. Utilizar o Software para fins ilícitos, ofensivos ou que violem
direitos de terceiros ou a legislação em vigor.
4. PROPRIEDADE INTELECTUAL
--------------------------
Todo o Software, incluindo, mas não se limitando a, código-fonte, algoritmos,
estruturas de dados, design, textos, gráficos, marcas e logotipos, é de
propriedade exclusiva do Licenciante, protegido pelas leis brasileiras de
propriedade intelectual (Lei nº 9.610/1998) e por tratados internacionais
aplicáveis.
5. DADOS PESSOAIS E LGPD
------------------------
O Licenciado declara estar ciente de que o Software trata dados pessoais em
conformidade com a Lei nº 13.709/2018 (LGPD). O Licenciado é responsável
por quaisquer dados que venha a inserir na plataforma e deve respeitar os
direitos dos titulares de dados.
6. ATUALIZAÇÕES E SUPORTE
-------------------------
O Licenciante poderá, a seu exclusivo critério, fornecer atualizações,
correções e suporte. Não há obrigação de manutenção contínua, salvo acordo
específico firmado entre as partes.
7. LIMITAÇÃO DE RESPONSABILIDADE
--------------------------------
O Software é fornecido "no estado em que se encontra" (AS IS), sem garantias
de qualquer natureza, expressas ou implícitas, incluindo, mas não se limitando
a, garantias de comercialização, adequação a um propósito específico ou
inviolabilidade. O Licenciante não se responsabiliza por danos diretos,
indiretos, incidentais, especiais ou consequenciais decorrentes do uso ou da
impossibilidade de uso do Software.
8. VIOLAÇÃO E ENCERRAMENTO
--------------------------
Qualquer violação dos termos desta Licença resultará na rescisão automática
da licença concedida, sem prejuízo da apuração de eventuais danos e da
responsabilização civil e criminal cabível.
9. LEGISLAÇÃO APLICÁVEL E FORO
------------------------------
Este documento é regido pelas leis da República Federativa do Brasil.
Fica eleito o foro da Comarca de Santa Rita do Sapucaí — MG para dirimir
quaisquer controvérsias decorrentes desta Licença, com renúncia a qualquer
outro, por mais privilegiado que seja.
10. CONTATO
-----------
Dúvidas sobre esta Licença: contato@syspanel.com.br
Executable
+218
View File
@@ -0,0 +1,218 @@
# 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.
> Plataforma operada pela **Syspanel** — Santa Rita do Sapucaí/MG, Brasil.
> Contato: [contato@syspanel.com.br](mailto:contato@syspanel.com.br) ·
> WhatsApp: (35) 99226-1684 · [www.syspanel.com.br](https://syspanel.com.br)
---
## Visão Geral da Arquitetura
| 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 |
---
## Fase 1 (Atual) — 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).
- **API REST** (Sanctum) para usuários, lojas, agentes, cidade e documentos legais.
- **CAPTCHA matemático** próprio (soma), sem dependências externas, nos formulários públicos.
- **Multi-idioma**: Português (Brasil), Inglês e Espanhol — selecionável pelo usuário.
- **Termos de Uso, Política de Privacidade e Política de Cookies** (LGPD), versionados e com aceite auditável (data, hora, versão, IP).
- **Banner de cookies** com link para os Termos de Uso e Privacidade.
- **Página inicial** responsiva (desktop e smartphone).
---
## Requisitos
- PHP **≥ 8.3** (com `pdo_mysql`, `mbstring`, `curl`, `zip`, `gd`, `bcmath`)
- Composer 2
- MariaDB ≥ 10.6 (ou MySQL ≥ 8.0)
- Node.js ≥ 18 (apenas para assets Vite/Tailwind, opcional na Fase 1)
- Docker (opcional, para o ambiente `dockercity10`)
---
## Instalação
### 1. Dependências PHP
```bash
composer install
```
### 2. Configuração do ambiente
```bash
cp .env.example .env
php artisan key:generate
```
Edite o `.env` e configure o banco de dados:
```env
DB_CONNECTION=mariadb
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=root
DB_PASSWORD=<sua_senha>
```
### 3. Chave do OpenRouter.ai
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
```
### 4. Banco de dados
```bash
php artisan migrate --seed
```
### 5. Servidor de desenvolvimento
```bash
php artisan serve --port=16980
```
A aplicação ficará disponível em `http://localhost:16980`.
### Ambiente Docker (dockercity10)
O container `dockercity10` já expõe a aplicação na porta do host **16980**
(container `:80`, Apache + PHP-FPM) e o MariaDB na porta **16981**. Após clonar
o projeto em `/app` (bind-mount), rode as migrations dentro do container:
```bash
docker exec -it dockercity10 bash -c "cd /app && php artisan migrate --seed"
```
---
## Contas de Teste (ambiente de desenvolvimento)
| Papel | E-mail | Senha |
|---|---|---|
| Administrador | `marcocosta@gmx.com` | `Mega886ax` |
| Usuário Comum | `user@sistema.net` | `User123` |
> ⚠️ Estas credenciais são exclusivas para desenvolvimento. Altere-as ou
> remova-as (`php artisan db:seed --class=UserSeeder` após ajustar) antes de
> qualquer implantação em produção.
---
## Estrutura de Endpoints da API
Base URL: `http://localhost:16980/api`
### Públicos
| Método | Rota | Descrição |
|---|---|---|
| GET | `/auth/captcha` | Gera desafio matemático (necessário para login/registro) |
| POST | `/auth/login` | Autentica e emite token Sanctum |
| POST | `/auth/register` | Cria conta de usuário comum |
| GET | `/legal/documents` | Lista a versão atual dos documentos legais |
### Autenticados (Bearer token)
| Método | Rota | Descrição |
|---|---|---|
| GET | `/auth/me` | Perfil + status de consentimento |
| PATCH | `/auth/profile` | Atualiza nome, idioma ou senha |
| POST | `/auth/logout` | Revoga o token atual |
| 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 | `/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`)
| 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 |
---
## Modelos de IA Permitidos
Somente os modelos **gratuitos** abaixo podem ser atribuídos a agentes
(validado no backend em `config/ai.php`):
```
poolside/laguna-m.1:free cohere/north-mini-code:free
deepseek/deepseek-v4-flash-0731:free qwen/qwen3.8-27b:free
nvidia/nemotron-3-ultra-550b-a55b:free nvidia/nemotron-3-super-120b-a12b:free
google/gemma-4-31b-it:free google/gemma-4-26b-a4b-it:free
openai/gpt-oss-20b:free nvidia/nemotron-nano-12b-v2-vl:free
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
```
---
## Segurança e Conformidade (LGPD)
- **Senhas** armazenadas com bcrypt (cast `hashed`).
- **Tokens de API** (Sanctum) revogáveis por usuário.
- **RBAC**: papéis `admin` / `user` com middleware dedicado.
- **CAPTCHA matemático** nos formulários públicos (login/registro).
- **Consentimento auditável**: data, hora, versão do documento, IP e user-agent
gravados em `user_consents` (imutáveis).
- **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`.
---
## Licença
Software **Proprietário** — © 2026 Syspanel. Todos os direitos reservados.
Veja o arquivo [`LICENSE`](LICENSE) para os termos completos de uso.
Bibliotecas de terceiros utilizadas são compatíveis com uso comercial
(por exemplo: Laravel — MIT; Three.js — MIT). O crédito aos autores originais
está registrado em [`RULES.md`](RULES.md).
---
## Contato
- **E-mail**: [contato@syspanel.com.br](mailto:contato@syspanel.com.br)
- **WhatsApp**: (35) 99226-1684
- **Site**: [www.syspanel.com.br](https://syspanel.com.br)
- **Localização**: Santa Rita do Sapucaí — Minas Gerais — Brasil
---
© 2026 — Todos os direitos reservados — Powered by
[Syspanel](https://syspanel.com.br) — www.syspanel.com.br
+144
View File
@@ -0,0 +1,144 @@
# RULES.md — Regras para IA e Desenvolvedores do City10
Este documento reúne as regras obrigatórias que **modelos de IA** (agentes,
copilotos, assistentes de código) e **desenvolvedores humanos** devem seguir
ao trabalhar neste repositório.
---
## 1. Diretrizes Gerais
1.1. **Priorize a segurança.** Nunca introduza código que exponha, registre ou
transmita segredos (chaves de API, senhas, tokens). Chave do OpenRouter
vive apenas em `.env` (não versionado).
1.2. **Não remova a proteção LGPD.** Consentimento, banner de cookies,
trilha de auditoria e direitos do titular são obrigatórios e não podem
ser desativados ou contornados.
1.3. **Comentários PHPDoc em inglês.** Todo arquivo PHP de origem deve
possuir comentários técnicos em inglês usando o padrão PHPDoc.
1.4. **Multi-idioma.** Toda string visível ao usuário deve existir nos
arquivos `lang/pt_BR.json`, `lang/en.json` e `lang/es.json`.
1.5. **CSRF em formulários.** Todo formulário HTML deve incluir `@csrf`.
Nunca desative o middleware `VerifyCsrfToken`.
1.6. **CAPTCHA nos formulários públicos.** Login e registro devem manter o
CAPTCHA matemático (soma) nativo, sem bibliotecas externas.
1.7. **Senhas com ícone "eye".** Campos de senha em formulários devem exibir
o botão de alternar visibilidade (olho).
---
## 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.2. **Nunca envie dados pessoais ao modelo.** Ao chamar o OpenRouter, envie
apenas contexto simulado da cidade (posições, metas, estado de lojas).
Nunca inclua nome, e-mail, senha ou outros dados cadastrais de usuários.
2.3. **Respeite o intervalo de raciocínio.** Não faça chamadas à API com
frequência maior que `config('ai.think_interval')` por agente.
2.4. **Trate falhas com backoff.** Erros da API devem incrementar
`error_count` e usar espera exponencial; não repita chamadas em loop.
2.5. **Persista metas e decisões.** O estado do agente (`current_goal`,
`last_decision`) deve ser gravado no banco para auditoria e retomada.
2.6. **Não execute ações destrutivas por conta própria.** Decisões que
alterem dados de usuários ou excluam recursos exigem aprovação humana.
---
## 3. Regras para Desenvolvedores
3.1. **Estrutura Laravel 13 (estilo atual)**
- Middleware customizado em `app/Http/Middleware/`, registrado em
`bootstrap/app.php` via alias.
- Controllers em `app/Http/Controllers/{Api,Web}/`.
- Validação em `app/Http/Requests/`.
- Rotas em `routes/api.php` e `routes/web.php`.
3.2. **Migrations**
- Nunca edite uma migration já executada em produção; crie uma nova.
- Use tipos adequados (`foreignId`, `decimal` para coordenadas 3D).
3.3. **Eloquent**
- Declare relações e `casts()` nos models.
- Use `$fillable` (ou attributes `#[Fillable]`) para evitar mass-assignment.
3.4. **Autorização**
- `admin` e `user` são os papéis suportados (`User::ROLE_*`).
- Proteja rotas administrativas com o middleware `admin`.
3.5. **Testes**
- Rode `php artisan test` antes de considerar uma tarefa concluída.
- Rode `./vendor/bin/pint --test` para validar o estilo de código.
3.6. **Bibliotecas e licenças**
- Antes de adicionar qualquer dependência, verifique a licença.
- Apenas licenças que permitam uso comercial (MIT, Apache-2.0, BSD,
ISC, etc.) são aceitáveis.
- Registre a licença e o autor aqui (ver seção 4).
3.7. **Imagens e assets**
- Use apenas imagens, fontes e ícones de licença livre ou própria.
3.8. **Variáveis de ambiente**
- Adicione novas chaves ao `.env.example` (sem valores secretos).
- Documente o propósito no `.env` com comentário.
---
## 4. Registro de Bibliotecas e Licenças
| Biblioteca | Licença | Autor / Origem | Uso |
|---|---|---|---|
| Laravel Framework | MIT | Laravel LLC / Taylor Otwell | Backend |
| Laravel Sanctum | MIT | Laravel LLC | Tokens de API |
| Three.js | MIT | Ricardo Cabello (Mr.doob) e contribuidores | Renderização 3D (Fase 2) |
| Tailwind CSS | MIT | Tailwind Labs | Estilos |
| Instrument Sans | OFL 1.1 | Instrument (Google Fonts) | Tipografia |
| PHP | PHP License | The PHP Group | Runtime |
| MariaDB | GPL / comercial | MariaDB Foundation | Banco de dados |
> Observação: licenças MIT, Apache-2.0, BSD, ISC e OFL permitem uso comercial
> livremente, atendendo à exigência da Syspanel. Registre aqui toda nova
> dependência antes de mergear.
---
## 5. Padrões de Código
5.1. Siga o estilo do Laravel (Pint). Formate com `./vendor/bin/pint`.
5.2. Prefira código explícito a "mágica" obscura; legibilidade primeiro.
5.3. Valide sempre a entrada do usuário (Form Requests).
5.4. Retorne respostas de erro consistentes (JSON com `message` e `errors`).
5.5. Use as traduções (`__()` / `@lang`) em vez de strings literais
no meio das views e controllers.
---
## 6. Commits e Versionamento
6.1. Mensagens de commit claras e no imperativo (ex.: `Add store CRUD API`).
6.2. Nunca commite: `.env`, chaves, `storage/logs/*`, `vendor/`, `node_modules/`.
6.3. Uma mudança por commit quando possível; mantenha o histórico legível.
---
© 2026 — Todos os direitos reservados — Powered by
[Syspanel](https://syspanel.com.br) — www.syspanel.com.br
+112
View File
@@ -0,0 +1,112 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\AiAgentRequest;
use App\Models\AiAgent;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* AI agent management endpoints (admin only).
*
* Agents power the NPCs and managers of the virtual city through the
* OpenRouter.ai API, restricted to the allow-listed free models.
*/
class AiAgentController extends Controller
{
/**
* Return the list of models allowed for agent configuration.
*/
public function models(): JsonResponse
{
return response()->json([
'models' => config('ai.allowed_models', []),
'default' => config('ai.default_model'),
'api_key_configured' => filled(config('ai.openrouter.api_key')),
]);
}
/**
* Paginated listing of registered AI agents.
*/
public function index(Request $request): JsonResponse
{
$query = AiAgent::query()->orderBy('name');
if ($type = $request->query('type')) {
$query->where('type', $type);
}
if ($status = $request->query('status')) {
$query->where('status', $status);
}
return response()->json($query->paginate((int) $request->query('per_page', 15)));
}
/**
* Return a compact snapshot of active agents for the 3D renderer.
*/
public function world(): JsonResponse
{
$agents = AiAgent::query()
->where('status', '!=', 'paused')
->get(['id', 'name', 'type', 'status', 'pos_x', 'pos_y', 'pos_z', 'rotation_y', 'current_goal'])
->map(fn (AiAgent $agent): array => [
'id' => $agent->id,
'name' => $agent->name,
'type' => $agent->type,
'status' => $agent->status,
'position' => [(float) $agent->pos_x, (float) $agent->pos_y, (float) $agent->pos_z],
'rotation_y' => (float) $agent->rotation_y,
'goal' => $agent->current_goal,
]);
return response()->json(['agents' => $agents]);
}
/**
* Persist a new AI agent.
*/
public function store(AiAgentRequest $request): JsonResponse
{
$data = $request->validated();
$data['model'] = $data['model'] ?? config('ai.default_model');
$data['status'] = $data['status'] ?? 'active';
$agent = AiAgent::create($data);
return response()->json($agent, 201);
}
/**
* Return a single AI agent.
*/
public function show(AiAgent $aiAgent): JsonResponse
{
return response()->json($aiAgent->load('stores:id,name,agent_id,is_open'));
}
/**
* Update an AI agent.
*/
public function update(AiAgentRequest $request, AiAgent $aiAgent): JsonResponse
{
$aiAgent->update($request->validated());
return response()->json($aiAgent->fresh());
}
/**
* Delete an AI agent.
*/
public function destroy(AiAgent $aiAgent): JsonResponse
{
$aiAgent->delete();
return response()->json(['message' => __('agents.deleted')]);
}
}
+159
View File
@@ -0,0 +1,159 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\LoginRequest;
use App\Http\Requests\RegisterRequest;
use App\Models\LegalDocument;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
/**
* Authentication endpoints for the City10 REST API.
*
* Issues personal access tokens (Laravel Sanctum) consumed by the 3D
* frontend and by external integrations.
*/
class AuthController extends Controller
{
/**
* Issue a fresh math CAPTCHA challenge for the login/registration forms.
*
* The expected result is stored server-side in the session, so clients
* only receive the question to solve.
*/
public function captcha(): JsonResponse
{
$a = random_int(1, 9);
$b = random_int(1, 9);
session()->put('captcha_result', $a + $b);
session()->put('captcha_expires_at', now()->addMinutes(10)->getTimestamp());
return response()->json([
'question' => sprintf('%d + %d = ?', $a, $b),
]);
}
/**
* Register a new regular user account.
*
* Applies a simple math CAPTCHA and immediately records acceptance of
* the current legal documents when the client confirms them.
*/
public function register(RegisterRequest $request): JsonResponse
{
$data = $request->validated();
$user = User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => $data['password'],
'role' => User::ROLE_USER,
'locale' => $data['locale'] ?? 'pt_BR',
'is_active' => true,
]);
$token = $user->createToken('city10-api')->plainTextToken;
return response()->json([
'user' => $user,
'token' => $token,
'token_type' => 'Bearer',
], 201);
}
/**
* Authenticate a user and return an API token.
*/
public function login(LoginRequest $request): JsonResponse
{
$user = User::where('email', $request->validated('email'))->first();
if ($user === null || ! Hash::check($request->validated('password'), $user->password)) {
throw ValidationException::withMessages([
'email' => [__('auth.failed')],
]);
}
if (! $user->is_active) {
throw ValidationException::withMessages([
'email' => [__('auth.disabled')],
]);
}
$user->forceFill(['last_login_at' => now()])->save();
$token = $user->createToken('city10-api')->plainTextToken;
return response()->json([
'user' => $user,
'token' => $token,
'token_type' => 'Bearer',
'requires_terms' => ! $user->hasAcceptedCurrentTerms(),
]);
}
/**
* Update the authenticated user's own profile (name, locale, password).
*
* Role and active status are intentionally excluded: those are managed
* exclusively through the admin endpoints.
*/
public function updateProfile(Request $request): JsonResponse
{
$data = $request->validate([
'name' => ['sometimes', 'string', 'max:120'],
'locale' => ['sometimes', Rule::in(['pt_BR', 'en', 'es'])],
'password' => ['sometimes', 'string', 'min:8', 'confirmed'],
]);
$user = $request->user();
if (! empty($data['password'])) {
$user->password = $data['password'];
unset($data['password']);
}
$user->fill($data)->save();
return response()->json($user);
}
/**
* Revoke the token used for the current request.
*/
public function logout(Request $request): JsonResponse
{
$request->user()->currentAccessToken()?->delete();
return response()->json(['message' => __('auth.logged_out')]);
}
/**
* Return the authenticated user plus consent status.
*/
public function me(Request $request): JsonResponse
{
$user = $request->user();
$currentDocuments = LegalDocument::query()
->where('is_current', true)
->get(['id', 'type', 'version']);
$acceptedIds = $user->consents()->pluck('legal_document_id');
return response()->json([
'user' => $user,
'requires_terms' => ! $user->hasAcceptedCurrentTerms(),
'pending_documents' => $currentDocuments
->reject(fn ($doc) => $acceptedIds->contains($doc->id))
->values(),
]);
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\AiAgent;
use App\Models\Store;
use Illuminate\Http\JsonResponse;
/**
* Aggregated city snapshot consumed by the Three.js frontend.
*
* Provides a single lightweight payload describing the static and dynamic
* elements of the virtual city (stores and AI agents with coordinates).
*/
class CityController extends Controller
{
/**
* Return the current world state used to bootstrap the 3D scene.
*/
public function world(): JsonResponse
{
$stores = Store::query()
->get(['id', 'name', 'type', 'pos_x', 'pos_y', 'pos_z', 'width', 'height', 'depth', 'is_open'])
->map(fn (Store $store): array => [
'id' => $store->id,
'name' => $store->name,
'type' => $store->type,
'position' => [(float) $store->pos_x, (float) $store->pos_y, (float) $store->pos_z],
'size' => [(float) $store->width, (float) $store->height, (float) $store->depth],
'is_open' => $store->is_open,
]);
$agents = AiAgent::query()
->where('status', '!=', 'paused')
->get(['id', 'name', 'type', 'status', 'pos_x', 'pos_y', 'pos_z', 'rotation_y'])
->map(fn (AiAgent $agent): array => [
'id' => $agent->id,
'name' => $agent->name,
'type' => $agent->type,
'status' => $agent->status,
'position' => [(float) $agent->pos_x, (float) $agent->pos_y, (float) $agent->pos_z],
'rotation_y' => (float) $agent->rotation_y,
]);
return response()->json([
'generated_at' => now()->toIso8601String(),
'stores' => $stores,
'agents' => $agents,
]);
}
}
+123
View File
@@ -0,0 +1,123 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\ConsentRequest;
use App\Models\LegalDocument;
use App\Models\UserConsent;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Legal document and consent endpoints.
*
* Exposes the current Terms of Use, Privacy Policy and Cookie Policy and
* records auditable consent events required by the Brazilian LGPD.
*/
class LegalController extends Controller
{
/**
* Return the current version of every legal document.
*/
public function index(Request $request): JsonResponse
{
$locale = $request->query('locale', 'pt_BR');
$documents = LegalDocument::query()
->where('is_current', true)
->orderBy('type')
->get()
->map(fn (LegalDocument $doc): array => [
'id' => $doc->id,
'type' => $doc->type,
'version' => $doc->version,
'title' => $doc->title[$locale] ?? ($doc->title['pt_BR'] ?? $doc->type),
'body' => $doc->body[$locale] ?? ($doc->body['pt_BR'] ?? ''),
'published_at' => $doc->published_at?->toIso8601String(),
]);
return response()->json(['documents' => $documents]);
}
/**
* Return the consent status of the authenticated user.
*/
public function status(Request $request): JsonResponse
{
$user = $request->user();
$current = LegalDocument::query()->where('is_current', true)->get();
$accepted = $user->consents()->get(['legal_document_id', 'accepted_at']);
$items = $current->map(fn (LegalDocument $doc): array => [
'id' => $doc->id,
'type' => $doc->type,
'version' => $doc->version,
'accepted' => $accepted->contains('legal_document_id', $doc->id),
'accepted_at' => $accepted->firstWhere('legal_document_id', $doc->id)?->accepted_at?->toIso8601String(),
]);
return response()->json([
'all_accepted' => $items->every(fn (array $item): bool => $item['accepted']),
'documents' => $items,
]);
}
/**
* Record the acceptance of all current legal documents.
*
* The event is stored with timestamp, client IP, user agent and the
* exact document versions accepted, forming an immutable audit trail.
*/
public function accept(ConsentRequest $request): JsonResponse
{
$user = $request->user();
$documents = LegalDocument::query()->where('is_current', true)->get();
DB::transaction(function () use ($user, $documents, $request): void {
foreach ($documents as $document) {
UserConsent::updateOrCreate(
[
'user_id' => $user->id,
'legal_document_id' => $document->id,
],
[
'accepted_at' => now(),
'ip_address' => $request->ip(),
'user_agent' => (string) $request->userAgent(),
'source' => 'web_form',
],
);
}
});
return response()->json([
'message' => __('legal.accepted'),
'accepted_at' => now()->toIso8601String(),
]);
}
/**
* Return the full consent audit history of the authenticated user.
*/
public function history(Request $request): JsonResponse
{
$history = $request->user()
->consents()
->with('document:id,type,version')
->orderByDesc('accepted_at')
->get()
->map(fn (UserConsent $consent): array => [
'document_type' => $consent->document?->type,
'document_version' => $consent->document?->version,
'accepted_at' => $consent->accepted_at?->toIso8601String(),
'ip_address' => $consent->ip_address,
'source' => $consent->source,
]);
return response()->json(['history' => $history]);
}
}
+112
View File
@@ -0,0 +1,112 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreRequest as StoreFormRequest;
use App\Models\Store;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Store management endpoints.
*
* Regular users may list stores and manage the ones they own; admins may
* manage the entire registry. Coordinates follow the 3D city grid.
*/
class StoreController extends Controller
{
/**
* Paginated listing of stores with geographic filtering.
*/
public function index(Request $request): JsonResponse
{
$query = Store::query()->with(['owner:id,name', 'agent:id,name,status'])->orderBy('name');
if ($search = $request->query('search')) {
$query->where('name', 'like', "%{$search}%");
}
if ($type = $request->query('type')) {
$query->where('type', $type);
}
if ($request->boolean('only_open')) {
$query->where('is_open', true);
}
return response()->json($query->paginate((int) $request->query('per_page', 15)));
}
/**
* Return a compact snapshot of all stores for the 3D renderer.
*/
public function world(): JsonResponse
{
$stores = Store::query()
->get(['id', 'name', 'type', 'pos_x', 'pos_y', 'pos_z', 'width', 'height', 'depth', 'is_open'])
->map(fn (Store $store): array => [
'id' => $store->id,
'name' => $store->name,
'type' => $store->type,
'position' => [(float) $store->pos_x, (float) $store->pos_y, (float) $store->pos_z],
'size' => [(float) $store->width, (float) $store->height, (float) $store->depth],
'is_open' => $store->is_open,
]);
return response()->json(['stores' => $stores]);
}
/**
* Persist a new store.
*/
public function store(StoreFormRequest $request): JsonResponse
{
$data = $request->validated();
// Regular users always own the stores they create.
if (! $request->user()->isAdmin()) {
$data['owner_id'] = $request->user()->id;
}
$store = Store::create($data);
return response()->json($store->load(['owner:id,name', 'agent:id,name']), 201);
}
/**
* Return a single store.
*/
public function show(Store $store): JsonResponse
{
return response()->json($store->load(['owner:id,name,email', 'agent:id,name,type,status']));
}
/**
* Update a store (owner or admin only).
*/
public function update(StoreFormRequest $request, Store $store): JsonResponse
{
if (! $request->user()->isAdmin() && $store->owner_id !== $request->user()->id) {
return response()->json(['message' => __('auth.forbidden')], 403);
}
$store->update($request->validated());
return response()->json($store->fresh()->load(['owner:id,name', 'agent:id,name']));
}
/**
* Delete a store (owner or admin only).
*/
public function destroy(Request $request, Store $store): JsonResponse
{
if (! $request->user()->isAdmin() && $store->owner_id !== $request->user()->id) {
return response()->json(['message' => __('auth.forbidden')], 403);
}
$store->delete();
return response()->json(['message' => __('stores.deleted')]);
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
/**
* User management endpoints (admin only).
*
* Lists, creates, updates and deactivates accounts of the platform while
* exposing never the password hash or other sensitive material.
*/
class UserController extends Controller
{
/**
* Paginated listing of user accounts with optional search.
*/
public function index(Request $request): JsonResponse
{
$query = User::query()->orderBy('name');
if ($search = $request->query('search')) {
$query->where(function ($q) use ($search): void {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
if ($role = $request->query('role')) {
$query->where('role', $role);
}
return response()->json($query->paginate((int) $request->query('per_page', 15)));
}
/**
* Persist a new user account (admin provisioning).
*/
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'name' => ['required', 'string', 'max:120'],
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')],
'password' => ['required', 'string', 'min:8'],
'role' => ['required', Rule::in([User::ROLE_ADMIN, User::ROLE_USER])],
'locale' => ['nullable', Rule::in(['pt_BR', 'en', 'es'])],
'is_active' => ['nullable', 'boolean'],
]);
$user = User::create($data);
return response()->json($user, 201);
}
/**
* Return a single user account.
*/
public function show(User $user): JsonResponse
{
return response()->json($user->load('stores:id,name,owner_id,is_open'));
}
/**
* Update an existing user account.
*/
public function update(Request $request, User $user): JsonResponse
{
$data = $request->validate([
'name' => ['sometimes', 'string', 'max:120'],
'email' => ['sometimes', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)],
'password' => ['sometimes', 'string', 'min:8'],
'role' => ['sometimes', Rule::in([User::ROLE_ADMIN, User::ROLE_USER])],
'locale' => ['sometimes', Rule::in(['pt_BR', 'en', 'es'])],
'is_active' => ['sometimes', 'boolean'],
]);
$user->fill($data);
if (! empty($data['password'])) {
$user->password = $data['password'];
}
$user->save();
return response()->json($user);
}
/**
* Soft-disable an account (keeps audit data intact).
*
* Prevents administrators from locking themselves out of the platform.
*/
public function destroy(Request $request, User $user): JsonResponse
{
if ($user->id === $request->user()?->id) {
return response()->json(['message' => __('users.cannot_deactivate_self')], 422);
}
$user->update(['is_active' => false]);
return response()->json(['message' => __('users.deactivated')]);
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}
@@ -0,0 +1,109 @@
<?php
namespace App\Http\Controllers\Web\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreRequest;
use App\Models\AiAgent;
use App\Models\Store;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* Administrative store management (admin only).
*
* Provides the server-rendered CRUD screens where administrators create
* and edit stores, defining their exact 3D placement in the virtual city
* through a Three.js position picker.
*/
class StoreAdminController extends Controller
{
/**
* Paginated listing of every store with search and type filtering.
*/
public function index(Request $request): View
{
$query = Store::query()->with(['owner:id,name', 'agent:id,name'])->orderBy('name');
if ($search = $request->query('search')) {
$query->where('name', 'like', "%{$search}%");
}
if ($type = $request->query('type')) {
$query->where('type', $type);
}
$types = Store::query()->select('type')->distinct()->orderBy('type')->pluck('type');
return view('admin.stores.index', [
'stores' => $query->paginate(12)->withQueryString(),
'types' => $types,
'currentSearch' => $search,
'currentType' => $type,
]);
}
/**
* Show the store creation form with the 3D position picker.
*/
public function create(): View
{
return view('admin.stores.form', [
'store' => new Store(['is_open' => true, 'pos_x' => 0, 'pos_y' => 0, 'pos_z' => 0, 'width' => 8, 'height' => 6, 'depth' => 8]),
'owners' => User::query()->orderBy('name')->get(['id', 'name', 'email']),
'agents' => AiAgent::query()->orderBy('name')->get(['id', 'name', 'type']),
'isEdit' => false,
]);
}
/**
* Persist a newly created store.
*/
public function store(StoreRequest $request): RedirectResponse
{
$store = Store::create($request->validated());
return redirect()
->route('admin.stores.edit', $store)
->with('status', __('admin.stores.created'));
}
/**
* Show the store edit form pre-filled with the current 3D placement.
*/
public function edit(Store $store): View
{
return view('admin.stores.form', [
'store' => $store,
'owners' => User::query()->orderBy('name')->get(['id', 'name', 'email']),
'agents' => AiAgent::query()->orderBy('name')->get(['id', 'name', 'type']),
'isEdit' => true,
]);
}
/**
* Update an existing store.
*/
public function update(StoreRequest $request, Store $store): RedirectResponse
{
$store->update($request->validated());
return redirect()
->route('admin.stores.index')
->with('status', __('admin.stores.updated'));
}
/**
* Delete a store from the city registry.
*/
public function destroy(Store $store): RedirectResponse
{
$store->delete();
return redirect()
->route('admin.stores.index')
->with('status', __('stores.deleted'));
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Http\Requests\LoginRequest;
use App\Http\Requests\RegisterRequest;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
/**
* Web authentication controllers (session based).
*
* Complements the token-based API with a traditional server-rendered
* login and registration flow including a math CAPTCHA.
*/
class AuthController extends Controller
{
/**
* Show the login form.
*/
public function showLogin(): View
{
return view('auth.login', [
'captcha' => self::makeCaptcha(),
]);
}
/**
* Handle the login form submission.
*/
public function login(LoginRequest $request): RedirectResponse
{
$credentials = $request->only('email', 'password');
$remember = $request->boolean('remember');
if (! Auth::attempt($credentials, $remember)) {
return back()->withErrors([
'email' => __('auth.failed'),
])->onlyInput('email');
}
$user = Auth::user();
if (! $user->is_active) {
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login')->withErrors([
'email' => __('auth.disabled'),
]);
}
$user->forceFill(['last_login_at' => now()])->save();
$request->session()->regenerate();
// Seed the session locale from the user's stored preference so the
// SetLocale middleware renders the UI in their chosen language.
if (in_array($user->locale, ['pt_BR', 'en', 'es'], true)) {
$request->session()->put('locale', $user->locale);
}
return redirect()->intended(route('dashboard'));
}
/**
* Show the registration form.
*/
public function showRegister(): View
{
return view('auth.register', [
'captcha' => self::makeCaptcha(),
]);
}
/**
* Handle the registration form submission.
*/
public function register(RegisterRequest $request): RedirectResponse
{
$data = $request->validated();
$user = User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => $data['password'],
'role' => User::ROLE_USER,
'locale' => $data['locale'] ?? 'pt_BR',
'is_active' => true,
]);
Auth::login($user);
$request->session()->regenerate();
return redirect()->route('terms.accept');
}
/**
* Log the user out of the web session.
*/
public function logout(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login');
}
/**
* Build a fresh math CAPTCHA challenge.
*
* @return array{a: int, b: int, question: string}
*/
private static function makeCaptcha(): array
{
$a = random_int(1, 9);
$b = random_int(1, 9);
session()->put('captcha_result', $a + $b);
session()->put('captcha_expires_at', now()->addMinutes(10)->getTimestamp());
return [
'a' => $a,
'b' => $b,
'question' => sprintf('%d + %d = ?', $a, $b),
];
}
}
+204
View File
@@ -0,0 +1,204 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\AiAgent;
use App\Models\Store;
use Illuminate\Http\Request;
use Illuminate\Http\StreamedResponse;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
/**
* NPC chat endpoints consumed by the 3D city viewer.
*
* The POST endpoint accepts a user message, enqueues the OpenRouter call
* asynchronously and immediately returns 202 so the UI can show a
* "thinking" state. The GET endpoint streams agent replies as SSE events.
* Only simulated city context (never personal data) is sent upstream,
* as required by RULES.md.
*/
class ChatController extends Controller
{
/**
* Send a message to an agent and trigger an asynchronous reply.
*/
public function send(Request $request, AiAgent $agent): Response
{
$data = $request->validate([
'message' => ['required', 'string', 'max:2000'],
]);
// Persist the conversation in the agent's last_decision for audit
// and stream the pending state to any connected SSE listener.
$history = $agent->last_decision ?? [];
$history[] = ['role' => 'user', 'content' => $data['message'], 'at' => now()->toIso8601String()];
$agent->update([
'last_decision' => array_slice($history, -20),
'status' => 'thinking',
]);
self::publish($agent, ['type' => 'thinking', 'agent_id' => $agent->id, 'status' => 'thinking']);
// Fire the OpenRouter call without blocking the HTTP response.
dispatch(function () use ($agent, $data): void {
self::think($agent, $data['message']);
});
return response()->json([
'status' => 'accepted',
'agent_id' => $agent->id,
'message' => __('city.chat_accepted'),
], 202);
}
/**
* Stream agent status and replies as Server-Sent Events.
*/
public function stream(Request $request, AiAgent $agent): StreamedResponse
{
$request->session()->save();
return response()->stream(function () use ($agent): void {
echo "retry: 3000\n\n";
flush();
$last = null;
while (true) {
if (connection_aborted()) {
break;
}
// Poll the cache for a message published by think().
$payload = cache()->pull("chat.{$agent->id}");
if ($payload !== null) {
$json = json_encode($payload, JSON_UNESCAPED_UNICODE);
echo "event: message\n";
echo "data: {$json}\n\n";
flush();
$last = $payload;
}
// Heartbeat keeps proxies from closing the stream.
echo ": ping\n\n";
flush();
sleep(2);
}
}, 200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
'X-Accel-Buffering' => 'no',
]);
}
/**
* Call OpenRouter with the agent persona and simulated city context.
*
* Never includes personal data — only city state (RULES.md 2.2).
*/
private static function think(AiAgent $agent, string $userMessage): void
{
try {
$apiKey = config('ai.openrouter.api_key');
if (! filled($apiKey)) {
self::fail($agent, 'OPENROUTER_API_KEY not configured');
return;
}
// Simulated city context only — no PII (RULES.md 2.2).
$cityContext = [
'stores' => Store::query()
->get(['name', 'type', 'is_open'])
->map(fn ($s) => ['name' => $s->name, 'type' => $s->type, 'open' => $s->is_open])
->all(),
'agent_name' => $agent->name,
'agent_type' => $agent->type,
'current_goal' => $agent->current_goal,
];
$system = ($agent->system_prompt ?: "Você é {$agent->name}, um habitante da cidade virtual City10.")
."\n\nContexto simulado da cidade: ".json_encode($cityContext, JSON_UNESCAPED_UNICODE)
."\nResponda de forma curta e natural, em português. Não invente dados pessoais de usuários.";
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$apiKey,
'HTTP-Referer' => config('ai.openrouter.app_url'),
'X-Title' => config('ai.openrouter.app_name'),
])->timeout((int) config('ai.openrouter.timeout', 30))
->post(config('ai.openrouter.base_url').'/chat/completions', [
'model' => $agent->model,
'messages' => [
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $userMessage],
],
'max_tokens' => 300,
]);
if ($response->failed()) {
self::fail($agent, 'HTTP '.$response->status());
return;
}
$reply = $response->json('choices.0.message.content') ?? __('city.chat_error');
$history = $agent->last_decision ?? [];
$history[] = ['role' => 'assistant', 'content' => $reply, 'at' => now()->toIso8601String()];
$agent->update([
'last_decision' => array_slice($history, -20),
'status' => 'active',
'error_count' => 0,
'last_think_at' => now(),
]);
self::publish($agent, [
'type' => 'reply',
'agent_id' => $agent->id,
'name' => $agent->name,
'reply' => $reply,
'status' => 'active',
]);
} catch (\Throwable $e) {
Log::error('NPC think failed', ['agent' => $agent->id, 'error' => $e->getMessage()]);
self::fail($agent, $e->getMessage());
}
}
/**
* Record a failure with exponential backoff (RULES.md 2.4).
*/
private static function fail(AiAgent $agent, string $reason): void
{
$errors = (int) $agent->error_count + 1;
$backoff = min(300, 2 ** min($errors, 8));
$agent->update([
'status' => $errors >= 5 ? 'error' : 'active',
'error_count' => $errors,
]);
self::publish($agent, [
'type' => 'error',
'agent_id' => $agent->id,
'reply' => __('city.chat_error'),
'status' => $agent->status,
'retry_after' => $backoff,
]);
}
/**
* Publish a message for the SSE stream to pick up.
*/
private static function publish(AiAgent $agent, array $payload): void
{
cache()->put("chat.{$agent->id}", $payload, now()->addMinutes(5));
}
}
@@ -0,0 +1,123 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\AiAgent;
use App\Models\Store;
use Illuminate\Http\JsonResponse;
use Illuminate\View\View;
/**
* 3D city viewer.
*
* Serves the Three.js scene page plus a lightweight JSON payload of stores
* and agents. The JSON endpoint reuses the web session so the browser can
* fetch world data without a separate API token.
*/
class CityViewerController extends Controller
{
/**
* Render the city viewer page.
*/
public function __invoke(): View
{
return view('city.viewer');
}
/**
* Return the world snapshot consumed by the viewer scene.
*/
public function data(): JsonResponse
{
$stores = Store::query()
->get(['id', 'name', 'type', 'pos_x', 'pos_y', 'pos_z', 'width', 'height', 'depth', 'is_open'])
->map(fn (Store $store): array => [
'id' => $store->id,
'name' => $store->name,
'type' => $store->type,
'position' => [(float) $store->pos_x, (float) $store->pos_y, (float) $store->pos_z],
'size' => [(float) $store->width, (float) $store->height, (float) $store->depth],
'is_open' => $store->is_open,
]);
$agents = AiAgent::query()
->where('status', '!=', 'paused')
->get(['id', 'name', 'type', 'status', 'pos_x', 'pos_y', 'pos_z', 'rotation_y', 'last_decision'])
->map(fn (AiAgent $agent): array => [
'id' => $agent->id,
'name' => $agent->name,
'type' => $agent->type,
'status' => $agent->status,
'position' => [(float) $agent->pos_x, (float) $agent->pos_y, (float) $agent->pos_z],
'rotation_y' => (float) $agent->rotation_y,
'last_decision' => $agent->last_decision,
]);
return response()->json([
'generated_at' => now()->toIso8601String(),
'stores' => $stores,
'agents' => $agents,
'layout' => self::cityLayout($stores->all()),
])->header('Cache-Control', 'no-store');
}
/**
* Derive a deterministic street layout from store positions.
*
* Returns road segments and block rectangles so the frontend can build
* a consistent grid-based city without additional database tables.
*
* @param array<int, array<string, mixed>> $stores
* @return array<string, mixed>
*/
private static function cityLayout(array $stores): array
{
if ($stores === []) {
return ['roads' => [], 'blocks' => []];
}
$xs = array_map(fn ($s) => $s['position'][0], $stores);
$zs = array_map(fn ($s) => $s['position'][2], $stores);
$minX = floor(min($xs) / 40) * 40 - 40;
$maxX = ceil(max($xs) / 40) * 40 + 40;
$minZ = floor(min($zs) / 40) * 40 - 40;
$maxZ = ceil(max($zs) / 40) * 40 + 40;
$roads = [];
// Vertical roads every 40 units.
for ($x = $minX; $x <= $maxX; $x += 40) {
$roads[] = [
'from' => [(float) $x, 0.0, (float) $minZ],
'to' => [(float) $x, 0.0, (float) $maxZ],
'width' => 8.0,
'axis' => 'z',
];
}
// Horizontal roads every 40 units.
for ($z = $minZ; $z <= $maxZ; $z += 40) {
$roads[] = [
'from' => [(float) $minX, 0.0, (float) $z],
'to' => [(float) $maxX, 0.0, (float) $z],
'width' => 8.0,
'axis' => 'x',
];
}
// Blocks = cells between roads.
$blocks = [];
for ($x = $minX; $x < $maxX; $x += 40) {
for ($z = $minZ; $z < $maxZ; $z += 40) {
$blocks[] = [
'min' => [(float) $x + 5.0, 0.0, (float) $z + 5.0],
'max' => [(float) $x + 35.0, 0.0, (float) $z + 35.0],
];
}
}
return ['roads' => $roads, 'blocks' => $blocks];
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* Dashboard controller for the authenticated web area.
*
* Phase 1 renders an overview panel; the full admin console and 3D city
* viewer are delivered in subsequent phases.
*/
class DashboardController extends Controller
{
/**
* Show the user dashboard overview.
*/
public function __invoke(Request $request): View
{
$user = $request->user();
return view('dashboard.index', [
'user' => $user,
'isAdmin' => $user->isAdmin(),
'storeCount' => $user->stores()->count(),
]);
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
/**
* Language switcher controller.
*
* Persists the selected locale (pt_BR, en, es) in the session and in the
* user's profile when authenticated, so the whole UI is rendered in the
* language chosen from the dashboard or the public pages.
*/
class LanguageController extends Controller
{
/**
* Switch the application locale for the current visitor.
*/
public function switch(Request $request): RedirectResponse
{
$locale = $request->input('locale');
if (! in_array($locale, ['pt_BR', 'en', 'es'], true)) {
return back();
}
App::setLocale($locale);
$request->session()->put('locale', $locale);
if ($user = $request->user()) {
$user->update(['locale' => $locale]);
}
// Persist the cookie preference (1 year) used on subsequent visits.
$response = back();
$response->withCookie(cookie('city10-locale', $locale, 60 * 24 * 365, '/', null, false, true));
return $response;
}
}
@@ -0,0 +1,120 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Http\Requests\ConsentRequest;
use App\Models\LegalDocument;
use App\Models\UserConsent;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
/**
* Web controllers for legal documents and consent management.
*
* Renders the Terms of Use, Privacy Policy and Cookie Policy and records
* the auditable acceptance required by the Brazilian LGPD before the
* user can access the main platform features.
*/
class LegalController extends Controller
{
/**
* Show the current version of a legal document.
*/
public function show(string $type): View
{
$document = LegalDocument::query()
->where('type', $type)
->where('is_current', true)
->firstOrFail();
$locale = app()->getLocale();
return view('legal.show', [
'document' => $document,
'title' => $document->title[$locale] ?? ($document->title['pt_BR'] ?? $document->type),
'body' => $document->body[$locale] ?? ($document->body['pt_BR'] ?? ''),
]);
}
/**
* Show the first-access terms acceptance screen.
*/
public function acceptForm(Request $request): View|RedirectResponse
{
$user = $request->user();
$current = LegalDocument::query()->where('is_current', true)->orderBy('type')->get();
if ($current->isEmpty()) {
return redirect()->route('dashboard');
}
$acceptedIds = $user->consents()->pluck('legal_document_id');
$pending = $current->reject(fn (LegalDocument $doc) => $acceptedIds->contains($doc->id));
if ($pending->isEmpty()) {
return redirect()->route('dashboard');
}
$locale = app()->getLocale();
$documents = $pending->map(fn (LegalDocument $doc): array => [
'type' => $doc->type,
'version' => $doc->version,
'title' => $doc->title[$locale] ?? ($doc->title['pt_BR'] ?? $doc->type),
'body' => $doc->body[$locale] ?? ($doc->body['pt_BR'] ?? ''),
])->values();
return view('legal.accept', [
'documents' => $documents,
]);
}
/**
* Record the acceptance of all current legal documents.
*/
public function accept(ConsentRequest $request): RedirectResponse
{
$user = $request->user();
$documents = LegalDocument::query()->where('is_current', true)->get();
DB::transaction(function () use ($user, $documents, $request): void {
foreach ($documents as $document) {
UserConsent::updateOrCreate(
[
'user_id' => $user->id,
'legal_document_id' => $document->id,
],
[
'accepted_at' => now(),
'ip_address' => $request->ip(),
'user_agent' => (string) $request->userAgent(),
'source' => 'web_form',
],
);
}
});
return redirect()->route('dashboard')->with('status', __('legal.accepted'));
}
/**
* Display the consent audit history of the authenticated user.
*/
public function history(Request $request): View
{
$history = $request->user()
->consents()
->with('document:id,type,version')
->orderByDesc('accepted_at')
->get();
return view('legal.history', [
'history' => $history,
]);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Http\Middleware;
use App\Models\LegalDocument;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Blocks access until the user accepted all current legal documents.
*
* Implements the LGPD requirement that the Terms of Use, Privacy Policy
* and Cookie Policy must be accepted on first access before the main
* platform features can be used.
*/
class EnsureTermsAccepted
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if ($user === null) {
return $next($request);
}
$currentIds = LegalDocument::query()->where('is_current', true)->pluck('id');
if ($currentIds->isEmpty()) {
return $next($request);
}
$acceptedIds = $user->consents()->pluck('legal_document_id');
$pending = $currentIds->diff($acceptedIds);
if ($pending->isNotEmpty()) {
if ($request->expectsJson()) {
return response()->json([
'message' => __('legal.accept_required'),
'pending' => $pending->values(),
], 428);
}
return redirect()->route('terms.accept');
}
return $next($request);
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Ensures the authenticated account is still active.
*
* Blocks disabled (deactivated) accounts from accessing any protected
* route, even when a previously issued token or session remains valid.
*/
class EnsureUserIsActive
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if ($user !== null && ! $user->is_active) {
if ($request->expectsJson()) {
return response()->json([
'message' => __('auth.disabled'),
], 403);
}
auth()->guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login')->withErrors([
'email' => __('auth.disabled'),
]);
}
return $next($request);
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Restricts access to platform administrators.
*
* Returns 403 for non-admin users, protecting the admin-only endpoints
* (user management, agent registry, store registry administration).
*/
class EnsureUserIsAdmin
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if ($user === null || ! $user->isAdmin()) {
if ($request->expectsJson()) {
return response()->json(['message' => __('auth.forbidden')], 403);
}
abort(403, __('auth.forbidden'));
}
return $next($request);
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Symfony\Component\HttpFoundation\Response;
/**
* Applies the visitor's preferred locale on each request.
*
* Reads the locale from the session (set at login or when the user switches
* language) or from the city10-locale cookie, falling back to the default.
* This keeps the whole interface in the language chosen by the user.
*/
class SetLocale
{
/**
* Supported UI locales.
*
* @var list<string>
*/
public const SUPPORTED = ['pt_BR', 'en', 'es'];
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$locale = ($request->hasSession() ? $request->session()->get('locale') : null)
?: $request->cookie('city10-locale')
?: config('app.locale');
if (in_array($locale, self::SUPPORTED, true)) {
App::setLocale($locale);
}
return $next($request);
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* Validates creation and update payloads for AI agents.
*
* Enforces the allow-list of free OpenRouter models defined in
* config/ai.php so that no paid model can ever be activated.
*/
class AiAgentRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
// Authorization is enforced by admin role middleware in routes.
return $this->user()?->isAdmin() ?? false;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$allowed = config('ai.allowed_models', []);
return [
'name' => ['required', 'string', 'max:120'],
'type' => ['required', 'string', 'max:40'],
'model' => ['required', 'string', Rule::in($allowed)],
'system_prompt' => ['nullable', 'string', 'max:8000'],
'current_goal' => ['nullable', 'string', 'max:2000'],
'status' => ['nullable', Rule::in(['active', 'paused', 'thinking', 'error'])],
'pos_x' => ['nullable', 'numeric', 'between:-100000,100000'],
'pos_y' => ['nullable', 'numeric', 'between:-100000,100000'],
'pos_z' => ['nullable', 'numeric', 'between:-100000,100000'],
'rotation_y' => ['nullable', 'numeric', 'between:-6.2832,6.2832'],
];
}
/**
* Get custom messages for validation errors.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'model.in' => __('validation.model_not_allowed'),
];
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* Validates the submission of legal document acceptances (LGPD consent).
*/
class ConsentRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'accepted_terms' => ['accepted'],
'accepted_privacy' => ['accepted'],
'accepted_cookies' => ['accepted'],
];
}
/**
* Get custom messages for validation errors.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'accepted_terms.accepted' => __('legal.must_accept_terms'),
'accepted_privacy.accepted' => __('legal.must_accept_privacy'),
'accepted_cookies.accepted' => __('legal.must_accept_cookies'),
];
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Http\Requests;
use App\Http\Requests\Traits\ValidatesMathCaptcha;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
/**
* Validates credentials submitted through the login form.
*/
class LoginRequest extends FormRequest
{
use ValidatesMathCaptcha;
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'email' => ['required', 'email', 'max:255'],
'password' => ['required', 'string'],
'remember' => ['nullable', 'boolean'],
'captcha' => ['required', 'integer'],
];
}
/**
* Get custom messages for validation errors.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'captcha.required' => __('validation.captcha_required'),
'captcha.integer' => __('validation.captcha_required'),
];
}
/**
* Configure the validator instance.
*
* @param Validator $validator
*/
public function withValidator($validator): void
{
$validator->after(function ($validator): void {
if (! $this->validateMathCaptcha()) {
$validator->errors()->add('captcha', __('validation.captcha_failed'));
}
});
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Http\Requests;
use App\Http\Requests\Traits\ValidatesMathCaptcha;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\Validator;
/**
* Validates new account registrations submitted through the public form.
*/
class RegisterRequest extends FormRequest
{
use ValidatesMathCaptcha;
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:120'],
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
'password' => ['required', 'confirmed', Password::min(8)],
'locale' => ['nullable', Rule::in(['pt_BR', 'en', 'es'])],
'captcha' => ['required', 'integer'],
];
}
/**
* Configure the validator instance.
*
* @param Validator $validator
*/
public function withValidator($validator): void
{
$validator->after(function ($validator): void {
if (! $this->validateMathCaptcha()) {
$validator->errors()->add('captcha', __('validation.captcha_failed'));
}
});
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* Validates creation and update payloads for city stores.
*/
class StoreRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
// Authorization is enforced by role middleware in the route file.
return $this->user() !== null;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$storeId = $this->route('store')?->id ?? $this->route('store');
return [
'name' => ['required', 'string', 'max:160'],
'description' => ['nullable', 'string', 'max:2000'],
'type' => ['required', 'string', 'max:60'],
'owner_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
'agent_id' => ['nullable', 'integer', Rule::exists('ai_agents', 'id')],
'pos_x' => ['required', 'numeric', 'between:-100000,100000'],
'pos_y' => ['required', 'numeric', 'between:-100000,100000'],
'pos_z' => ['required', 'numeric', 'between:-100000,100000'],
'width' => ['nullable', 'numeric', 'min:0.1', 'max:1000'],
'height' => ['nullable', 'numeric', 'min:0.1', 'max:1000'],
'depth' => ['nullable', 'numeric', 'min:0.1', 'max:1000'],
'is_open' => ['nullable', 'boolean'],
];
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Http\Requests\Traits;
use Illuminate\Support\Facades\Session;
/**
* Simple arithmetic CAPTCHA implemented with pure Laravel primitives.
*
* Generates a sum of two random integers, stores the expected result in
* the session and validates the user answer. It introduces no external
* dependency and is therefore safe from third-party licensing concerns.
*/
trait ValidatesMathCaptcha
{
/**
* Generate a new math challenge and persist it in the session.
*
* @return array{a: int, b: int, question: string}
*/
protected static function generateMathCaptcha(): array
{
$a = random_int(1, 9);
$b = random_int(1, 9);
Session::put('captcha_result', $a + $b);
// Sliding expiry: challenge is valid for 10 minutes.
Session::put('captcha_expires_at', now()->addMinutes(10)->getTimestamp());
return [
'a' => $a,
'b' => $b,
'question' => sprintf('%d + %d = ?', $a, $b),
];
}
/**
* Validate the submitted CAPTCHA answer against the session value.
*/
protected function validateMathCaptcha(): bool
{
$expected = Session::get('captcha_result');
$expiresAt = Session::get('captcha_expires_at');
// Invalidate the challenge after it is consumed.
Session::forget(['captcha_result', 'captcha_expires_at']);
if ($expected === null || $expiresAt === null) {
return false;
}
if (now()->getTimestamp() > (int) $expiresAt) {
return false;
}
return (int) $this->input('captcha') === (int) $expected;
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace App\Models;
use Database\Factories\AiAgentFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Autonomous AI agent entity.
*
* Drives NPCs and managers of the virtual city through the OpenRouter.ai
* API. Only allow-listed free models may be selected by administrators.
*/
#[Fillable([
'name', 'type', 'model', 'system_prompt', 'current_goal', 'last_decision',
'status', 'error_count', 'pos_x', 'pos_y', 'pos_z', 'rotation_y', 'last_think_at',
])]
class AiAgent extends Model
{
/** @use HasFactory<AiAgentFactory> */
use HasFactory;
/**
* Attributes that should be cast to native types.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'last_decision' => 'array',
'last_think_at' => 'datetime',
'pos_x' => 'float',
'pos_y' => 'float',
'pos_z' => 'float',
'rotation_y' => 'float',
'error_count' => 'integer',
];
}
/**
* Stores managed by this agent (shopkeepers, mayors, etc.).
*
* @return HasMany<Store, $this>
*/
public function stores(): HasMany
{
return $this->hasMany(Store::class, 'agent_id');
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
/**
* Versioned legal document (Terms of Use, Privacy Policy, Cookie Policy).
*
* Bodies are stored per language so that the UI can render the version
* matching the user's selected locale at acceptance time.
*/
#[Fillable(['type', 'version', 'title', 'body', 'is_current', 'published_at'])]
class LegalDocument extends Model
{
/**
* Document type: Terms of Use.
*
* @var string
*/
public const TYPE_TERMS = 'terms';
/**
* Document type: Privacy Policy (LGPD).
*
* @var string
*/
public const TYPE_PRIVACY = 'privacy';
/**
* Document type: Cookie Policy.
*
* @var string
*/
public const TYPE_COOKIES = 'cookies';
/**
* Attributes that should be cast to native types.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'title' => 'array',
'body' => 'array',
'is_current' => 'boolean',
'published_at' => 'datetime',
];
}
/**
* Users that accepted this document version.
*
* @return BelongsToMany<User, $this>
*/
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class, 'user_consents')
->withPivot(['accepted_at', 'ip_address', 'user_agent', 'source'])
->withTimestamps();
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace App\Models;
use Database\Factories\StoreFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Physical store placed inside the 3D city.
*
* Each store has an exact (x, y, z) world coordinate, footprint dimensions
* and an optional AI shopkeeper agent controlling its commercial behaviour.
*/
#[Fillable([
'name', 'description', 'type', 'owner_id', 'agent_id',
'pos_x', 'pos_y', 'pos_z', 'width', 'height', 'depth',
'is_open', 'revenue',
])]
class Store extends Model
{
/** @use HasFactory<StoreFactory> */
use HasFactory;
/**
* Owner (shopkeeper) account.
*
* @return BelongsTo<User, $this>
*/
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_id');
}
/**
* AI agent controlling the store, when assigned.
*
* @return BelongsTo<AiAgent, $this>
*/
public function agent(): BelongsTo
{
return $this->belongsTo(AiAgent::class, 'agent_id');
}
/**
* Get the attributes that should be cast to native types.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'pos_x' => 'float',
'pos_y' => 'float',
'pos_z' => 'float',
'width' => 'float',
'height' => 'float',
'depth' => 'float',
'revenue' => 'float',
'is_open' => 'boolean',
];
}
}
+113
View File
@@ -0,0 +1,113 @@
<?php
namespace App\Models;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
/**
* Application user entity.
*
* Supports role based access control (admin/user) and records the
* acceptance of versioned legal documents (Terms, Privacy, Cookies)
* required by the Brazilian LGPD.
*/
#[Fillable(['name', 'email', 'password', 'role', 'locale', 'is_active'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasApiTokens, HasFactory, Notifiable;
/**
* Role granted to platform administrators.
*
* @var string
*/
public const ROLE_ADMIN = 'admin';
/**
* Role granted to regular citizens of the virtual city.
*
* @var string
*/
public const ROLE_USER = 'user';
/**
* Stores owned by this user.
*
* @return HasMany<Store, $this>
*/
public function stores(): HasMany
{
return $this->hasMany(Store::class, 'owner_id');
}
/**
* Legal consents recorded for this user.
*
* @return HasMany<UserConsent, $this>
*/
public function consents(): HasMany
{
return $this->hasMany(UserConsent::class);
}
/**
* Legal documents this user has accepted.
*
* @return BelongsToMany<LegalDocument, $this>
*/
public function acceptedDocuments(): BelongsToMany
{
return $this->belongsToMany(LegalDocument::class, 'user_consents')
->withPivot(['accepted_at', 'ip_address', 'user_agent', 'source'])
->withTimestamps();
}
/**
* Determine whether the user holds administrator privileges.
*/
public function isAdmin(): bool
{
return $this->role === self::ROLE_ADMIN;
}
/**
* Check whether the user accepted every current legal document version.
*/
public function hasAcceptedCurrentTerms(): bool
{
$currentIds = LegalDocument::query()->where('is_current', true)->pluck('id');
if ($currentIds->isEmpty()) {
return true;
}
$acceptedIds = $this->consents()->pluck('legal_document_id');
return $currentIds->diff($acceptedIds)->isEmpty();
}
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'last_login_at' => 'datetime',
'password' => 'hashed',
'is_active' => 'boolean',
];
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Immutable audit record of a user consent event.
*
* Captures which legal document version was accepted, when, from which IP
* and through which channel, enabling full LGPD accountability.
*/
#[Fillable(['user_id', 'legal_document_id', 'accepted_at', 'ip_address', 'user_agent', 'source'])]
class UserConsent extends Model
{
/**
* Attributes that should be cast to native types.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'accepted_at' => 'datetime',
];
}
/**
* User that granted the consent.
*
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Legal document version that was accepted.
*
* @return BelongsTo<LegalDocument, $this>
*/
public function document(): BelongsTo
{
return $this->belongsTo(LegalDocument::class, 'legal_document_id');
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
/**
* Application service provider.
*
* Registers and boots framework services. Locale resolution is handled by
* the SetLocale middleware so it runs after the session is started.
*/
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}
Executable
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);
+49
View File
@@ -0,0 +1,49 @@
<?php
use App\Http\Middleware\EnsureTermsAccepted;
use App\Http\Middleware\EnsureUserIsActive;
use App\Http\Middleware\EnsureUserIsAdmin;
use App\Http\Middleware\SetLocale;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
// Alias custom middleware so they can be referenced by short name.
$middleware->alias([
'active' => EnsureUserIsActive::class,
'admin' => EnsureUserIsAdmin::class,
'terms' => EnsureTermsAccepted::class,
]);
// Apply "active" (account status) and "locale" (language) to web.
$middleware->web(append: [
EnsureUserIsActive::class,
SetLocale::class,
]);
// The cookie-consent flag is written by client-side JS (plain value),
// so it must not be encrypted/decrypted by the framework.
$middleware->encryptCookies(except: [
'city10-consent',
'city10-locale',
]);
// Apply "locale" to API requests too (after auth so user is known).
$middleware->api(append: [
SetLocale::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
);
})->create();
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+7
View File
@@ -0,0 +1,7 @@
<?php
use App\Providers\AppServiceProvider;
return [
AppServiceProvider::class,
];
Executable
+90
View File
@@ -0,0 +1,90 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "laravel/laravel",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"php": "^8.3",
"laravel/framework": "^13.17",
"laravel/sanctum": "^4.3",
"laravel/tinker": "^3.0"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pail": "^1.2.5",
"laravel/pao": "^1.0.6",
"laravel/pint": "^1.27",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^12.5.12"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"setup": [
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
"@php artisan migrate --force",
"npm install --ignore-scripts",
"npm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"@php artisan dev"
],
"test": [
"@php artisan config:clear --ansi @no_additional_args",
"@php artisan test"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
},
"platform": {
"php": "8.3.6"
}
},
"minimum-stability": "stable",
"prefer-stable": true
}
Generated Executable
+8557
View File
File diff suppressed because it is too large Load Diff
Executable
+78
View File
@@ -0,0 +1,78 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| OpenRouter.ai Integration
|--------------------------------------------------------------------------
|
| Credentials and behaviour settings used by the AI agents to take
| strategic decisions through the OpenRouter.ai chat completions API.
| The API key must be provided via the OPENROUTER_API_KEY env variable
| and must never be committed to version control.
|
*/
'openrouter' => [
'api_key' => env('OPENROUTER_API_KEY'),
'base_url' => env('OPENROUTER_BASE_URL', 'https://openrouter.ai/api/v1'),
'timeout' => (int) env('OPENROUTER_TIMEOUT', 30),
'app_name' => env('APP_NAME', 'City10'),
'app_url' => env('APP_URL', 'http://localhost:16980'),
],
/*
|--------------------------------------------------------------------------
| Allow-listed Free Models
|--------------------------------------------------------------------------
|
| Only the free models listed below may be selected for AI agents.
| Administrators cannot register agents with models outside this list,
| which keeps operating costs at zero and enforces the platform policy.
|
*/
'allowed_models' => [
'poolside/laguna-m.1:free',
'cohere/north-mini-code:free',
'deepseek/deepseek-v4-flash-0731:free',
'qwen/qwen3.8-27b:free',
'nvidia/nemotron-3-ultra-550b-a55b:free',
'nvidia/nemotron-3-super-120b-a12b:free',
'google/gemma-4-31b-it:free',
'google/gemma-4-26b-a4b-it:free',
'openai/gpt-oss-20b:free',
'nvidia/nemotron-nano-12b-v2-vl:free',
'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',
],
/*
|--------------------------------------------------------------------------
| Default Model
|--------------------------------------------------------------------------
|
| Model assigned to newly created agents when none is specified.
|
*/
'default_model' => env('AI_DEFAULT_MODEL', 'deepseek/deepseek-v4-flash-0731'),
/*
|--------------------------------------------------------------------------
| Agent Decision Cadence (seconds)
|--------------------------------------------------------------------------
|
| Minimum interval between two reasoning calls for the same agent,
| protecting the upstream API from excessive usage.
|
*/
'think_interval' => (int) env('AI_THINK_INTERVAL', 60),
];
Executable
+126
View File
@@ -0,0 +1,126 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache", "array"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
];
+117
View File
@@ -0,0 +1,117 @@
<?php
use App\Models\User;
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', User::class),
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];
+136
View File
@@ -0,0 +1,136 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
|
*/
'default' => env('CACHE_STORE', 'database'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "storage", "octane",
| "session", "failover", "null"
|
*/
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/locks'),
],
'storage' => [
'driver' => 'storage',
'disk' => env('CACHE_STORAGE_DISK'),
'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
| stores, there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
/*
|--------------------------------------------------------------------------
| Serializable Classes
|--------------------------------------------------------------------------
|
| This value determines the classes that can be unserialized from cache
| storage. By default, no PHP classes will be unserialized from your
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
*/
'serializable_classes' => false,
];
+184
View File
@@ -0,0 +1,184 @@
<?php
use Illuminate\Support\Str;
use Pdo\Mysql;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
|
*/
'default' => env('DB_CONNECTION', 'sqlite'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Below are all of the database connections defined for your application.
| An example configuration is provided for each database system which
| is supported by Laravel. You're free to add / remove connections.
|
*/
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
'transaction_mode' => 'DEFERRED',
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run on the database.
|
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as Memcached. You may define your connection settings here.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
'persistent' => env('REDIS_PERSISTENT', false),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
],
];
+80
View File
@@ -0,0 +1,80 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
|
*/
'default' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Below you may configure as many filesystem disks as necessary, and you
| may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference.
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
*/
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
'report' => false,
],
],
/*
|--------------------------------------------------------------------------
| Symbolic Links
|--------------------------------------------------------------------------
|
| Here you may configure the symbolic links that will be created when the
| `storage:link` Artisan command is executed. The array keys should be
| the locations of the links and the values should be their targets.
|
*/
'links' => [
public_path('storage') => storage_path('app/public'),
],
];
+140
View File
@@ -0,0 +1,140 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that is utilized to write
| messages to your logs. The value provided here should match one of
| the channels present in the list of "channels" configured below.
|
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Deprecations Log Channel
|--------------------------------------------------------------------------
|
| This option controls the log channel that should be used to log warnings
| regarding deprecated PHP and library features. This allows you to get
| your application ready for upcoming major versions of dependencies.
|
*/
'deprecations' => [
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
],
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Laravel
| utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use.
|
| Available drivers: "single", "daily", "monthly", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack"
|
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'max_files' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
'monthly' => [
'driver' => 'monthly',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'max_files' => 3,
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
],
'processors' => [PsrLogMessageProcessor::class],
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'handler_with' => [
'stream' => 'php://stderr',
],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
],
];
+118
View File
@@ -0,0 +1,118 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send all email
| messages unless another mailer is explicitly specified when sending
| the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
|
*/
'default' => env('MAIL_MAILER', 'log'),
/*
|--------------------------------------------------------------------------
| Mailer Configurations
|--------------------------------------------------------------------------
|
| Here you may configure all of the mailers used by your application plus
| their respective settings. Several examples have been configured for
| you and you are free to add your own as your application requires.
|
| Laravel supports a variety of mail "transport" drivers that can be used
| when delivering an email. You may specify which one you're using for
| your mailers below. You may also add additional mailers if needed.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "resend", "log", "array",
| "failover", "roundrobin"
|
*/
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'scheme' => env('MAIL_SCHEME'),
'url' => env('MAIL_URL'),
'host' => env('MAIL_HOST', '127.0.0.1'),
'port' => env('MAIL_PORT', 2525),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
],
'ses' => [
'transport' => 'ses',
],
'postmark' => [
'transport' => 'postmark',
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
// 'client' => [
// 'timeout' => 5,
// ],
],
'resend' => [
'transport' => 'resend',
],
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
],
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
'retry_after' => 60,
],
'roundrobin' => [
'transport' => 'roundrobin',
'mailers' => [
'ses',
'postmark',
],
'retry_after' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Global "From" Address
|--------------------------------------------------------------------------
|
| You may wish for all emails sent by your application to be sent from
| the same address. Here you may specify a name and address that is
| used globally for all emails that are sent by your application.
|
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
],
];
+129
View File
@@ -0,0 +1,129 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Queue Connection Name
|--------------------------------------------------------------------------
|
| Laravel's queue supports a variety of backends via a single, unified
| API, giving you convenient access to each backend using identical
| syntax for each. The default queue connection is defined below.
|
*/
'default' => env('QUEUE_CONNECTION', 'database'),
/*
|--------------------------------------------------------------------------
| Queue Connections
|--------------------------------------------------------------------------
|
| Here you may configure the connection options for every queue backend
| used by your application. An example configuration is provided for
| each backend supported by Laravel. You're also free to add more.
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
| "deferred", "background", "failover", "null"
|
*/
'connections' => [
'sync' => [
'driver' => 'sync',
],
'database' => [
'driver' => 'database',
'connection' => env('DB_QUEUE_CONNECTION'),
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
'after_commit' => false,
],
'beanstalkd' => [
'driver' => 'beanstalkd',
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
'queue' => env('BEANSTALKD_QUEUE', 'default'),
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
'block_for' => 0,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'after_commit' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => false,
],
'deferred' => [
'driver' => 'deferred',
],
'background' => [
'driver' => 'background',
],
'failover' => [
'driver' => 'failover',
'connections' => [
'database',
'deferred',
],
],
],
/*
|--------------------------------------------------------------------------
| Job Batching
|--------------------------------------------------------------------------
|
| The following options configure the database and table that store job
| batching information. These options can be updated to any database
| connection and table which has been defined by your application.
|
*/
'batching' => [
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'job_batches',
],
/*
|--------------------------------------------------------------------------
| Failed Queue Jobs
|--------------------------------------------------------------------------
|
| These options configure the behavior of failed queue job logging so you
| can control how and where failed jobs are stored. Laravel ships with
| support for storing failed jobs in a simple file or in a database.
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
*/
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'failed_jobs',
],
];
+87
View File
@@ -0,0 +1,87 @@
<?php
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Laravel\Sanctum\Http\Middleware\AuthenticateSession;
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort(),
// Sanctum::currentRequestHost(),
))),
/*
|--------------------------------------------------------------------------
| Sanctum Guards
|--------------------------------------------------------------------------
|
| This array contains the authentication guards that will be checked when
| Sanctum is trying to authenticate a request. If none of these guards
| are able to authenticate the request, Sanctum will use the bearer
| token that's present on an incoming request for authentication.
|
*/
'guard' => ['web'],
/*
|--------------------------------------------------------------------------
| Expiration Minutes
|--------------------------------------------------------------------------
|
| This value controls the number of minutes until an issued token will be
| considered expired. This will override any values set in the token's
| "expires_at" attribute, but first-party sessions are not affected.
|
*/
'expiration' => null,
/*
|--------------------------------------------------------------------------
| Token Prefix
|--------------------------------------------------------------------------
|
| Sanctum can prefix new tokens in order to take advantage of numerous
| security scanning initiatives maintained by open source platforms
| that notify developers if they commit tokens into repositories.
|
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
*/
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Sanctum Middleware
|--------------------------------------------------------------------------
|
| When authenticating your first-party SPA with Sanctum you may need to
| customize some of the middleware Sanctum uses while processing the
| request. You may change the middleware listed below as required.
|
*/
'middleware' => [
'authenticate_session' => AuthenticateSession::class,
'encrypt_cookies' => EncryptCookies::class,
'validate_csrf_token' => ValidateCsrfToken::class,
],
];
+38
View File
@@ -0,0 +1,38 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Third Party Services
|--------------------------------------------------------------------------
|
| This file is for storing the credentials for third party services such
| as Resend, Postmark, AWS, and more. This file provides the de facto
| location for this type of information, allowing packages to have
| a conventional file to locate the various service credentials.
|
*/
'postmark' => [
'key' => env('POSTMARK_API_KEY'),
],
'resend' => [
'key' => env('RESEND_API_KEY'),
],
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
'slack' => [
'notifications' => [
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
],
],
];
+233
View File
@@ -0,0 +1,233 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Session Driver
|--------------------------------------------------------------------------
|
| This option determines the default session driver that is utilized for
| incoming requests. Laravel supports a variety of storage options to
| persist session data. Database storage is a great default choice.
|
| Supported: "file", "cookie", "database", "memcached",
| "redis", "dynamodb", "array"
|
*/
'driver' => env('SESSION_DRIVER', 'database'),
/*
|--------------------------------------------------------------------------
| Session Lifetime
|--------------------------------------------------------------------------
|
| Here you may specify the number of minutes that you wish the session
| to be allowed to remain idle before it expires. If you want them
| to expire immediately when the browser is closed then you may
| indicate that via the expire_on_close configuration option.
|
*/
'lifetime' => (int) env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
/*
|--------------------------------------------------------------------------
| Session Encryption
|--------------------------------------------------------------------------
|
| This option allows you to easily specify that all of your session data
| should be encrypted before it's stored. All encryption is performed
| automatically by Laravel and you may use the session like normal.
|
*/
'encrypt' => env('SESSION_ENCRYPT', false),
/*
|--------------------------------------------------------------------------
| Session File Location
|--------------------------------------------------------------------------
|
| When utilizing the "file" session driver, the session files are placed
| on disk. The default storage location is defined here; however, you
| are free to provide another location where they should be stored.
|
*/
'files' => storage_path('framework/sessions'),
/*
|--------------------------------------------------------------------------
| Session Database Connection
|--------------------------------------------------------------------------
|
| When using the "database" or "redis" session drivers, you may specify a
| connection that should be used to manage these sessions. This should
| correspond to a connection in your database configuration options.
|
*/
'connection' => env('SESSION_CONNECTION'),
/*
|--------------------------------------------------------------------------
| Session Database Table
|--------------------------------------------------------------------------
|
| When using the "database" session driver, you may specify the table to
| be used to store sessions. Of course, a sensible default is defined
| for you; however, you're welcome to change this to another table.
|
*/
'table' => env('SESSION_TABLE', 'sessions'),
/*
|--------------------------------------------------------------------------
| Session Cache Store
|--------------------------------------------------------------------------
|
| When using one of the framework's cache driven session backends, you may
| define the cache store which should be used to store the session data
| between requests. This must match one of your defined cache stores.
|
| Affects: "dynamodb", "memcached", "redis"
|
*/
'store' => env('SESSION_STORE'),
/*
|--------------------------------------------------------------------------
| Session Sweeping Lottery
|--------------------------------------------------------------------------
|
| Some session drivers must manually sweep their storage location to get
| rid of old sessions from storage. Here are the chances that it will
| happen on a given request. By default, the odds are 2 out of 100.
|
*/
'lottery' => [2, 100],
/*
|--------------------------------------------------------------------------
| Session Cookie Name
|--------------------------------------------------------------------------
|
| Here you may change the name of the session cookie that is created by
| the framework. Typically, you should not need to change this value
| since doing so does not grant a meaningful security improvement.
|
*/
'cookie' => env(
'SESSION_COOKIE',
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
),
/*
|--------------------------------------------------------------------------
| Session Cookie Path
|--------------------------------------------------------------------------
|
| The session cookie path determines the path for which the cookie will
| be regarded as available. Typically, this will be the root path of
| your application, but you're free to change this when necessary.
|
*/
'path' => env('SESSION_PATH', '/'),
/*
|--------------------------------------------------------------------------
| Session Cookie Domain
|--------------------------------------------------------------------------
|
| This value determines the domain and subdomains the session cookie is
| available to. By default, the cookie will be available to the root
| domain without subdomains. Typically, this shouldn't be changed.
|
*/
'domain' => env('SESSION_DOMAIN'),
/*
|--------------------------------------------------------------------------
| HTTPS Only Cookies
|--------------------------------------------------------------------------
|
| By setting this option to true, session cookies will only be sent back
| to the server if the browser has a HTTPS connection. This will keep
| the cookie from being sent to you when it can't be done securely.
|
*/
'secure' => env('SESSION_SECURE_COOKIE'),
/*
|--------------------------------------------------------------------------
| HTTP Access Only
|--------------------------------------------------------------------------
|
| Setting this value to true will prevent JavaScript from accessing the
| value of the cookie and the cookie will only be accessible through
| the HTTP protocol. It's unlikely you should disable this option.
|
*/
'http_only' => env('SESSION_HTTP_ONLY', true),
/*
|--------------------------------------------------------------------------
| Same-Site Cookies
|--------------------------------------------------------------------------
|
| This option determines how your cookies behave when cross-site requests
| take place, and can be used to mitigate CSRF attacks. By default, we
| will set this value to "lax" to permit secure cross-site requests.
|
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
| Supported: "lax", "strict", "none", null
|
*/
'same_site' => env('SESSION_SAME_SITE', 'lax'),
/*
|--------------------------------------------------------------------------
| Partitioned Cookies
|--------------------------------------------------------------------------
|
| Setting this value to true will tie the cookie to the top-level site for
| a cross-site context. Partitioned cookies are accepted by the browser
| when flagged "secure" and the Same-Site attribute is set to "none".
|
*/
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
/*
|--------------------------------------------------------------------------
| Session Serialization
|--------------------------------------------------------------------------
|
| This value controls the serialization strategy for session data, which
| is JSON by default. Setting this to "php" allows the storage of PHP
| objects in the session but can make an application vulnerable to
| "gadget chain" serialization attacks if the APP_KEY is leaked.
|
| Supported: "json", "php"
|
*/
'serialization' => 'json',
];
+1
View File
@@ -0,0 +1 @@
*.sqlite*
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends Factory<User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
'role' => 'user',
'locale' => 'pt_BR',
'is_active' => true,
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
/**
* Indicate that the account holds administrator privileges.
*/
public function admin(): static
{
return $this->state(fn (array $attributes) => [
'role' => 'admin',
]);
}
/**
* Indicate that the account has been deactivated.
*/
public function inactive(): static
{
return $this->state(fn (array $attributes) => [
'is_active' => false,
]);
}
}
@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->bigInteger('expiration')->index();
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->bigInteger('expiration')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};
@@ -0,0 +1,59 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedSmallInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->string('connection');
$table->string('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
$table->index(['connection', 'queue', 'failed_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('failed_jobs');
}
};
@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('personal_access_tokens', function (Blueprint $table) {
$table->id();
$table->morphs('tokenable');
$table->text('name');
$table->string('token', 64)->unique();
$table->text('abilities')->nullable();
$table->timestamp('last_used_at')->nullable();
$table->timestamp('expires_at')->nullable()->index();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('personal_access_tokens');
}
};
@@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Adds role-based access control columns to the users table.
*
* Distinguishes administrators ("admin") from regular users ("user")
* so that API endpoints and the admin panel can be protected per role.
*/
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
// Role used for authorization: "admin" or "user".
$table->string('role', 20)->default('user')->after('password')->index();
// Preferred UI language: pt_BR, en, or es.
$table->string('locale', 5)->default('pt_BR')->after('role');
// Soft-delete flag so accounts can be disabled without data loss.
$table->boolean('is_active')->default(true)->after('locale');
// Timestamp of the latest successful authentication (audit aid).
$table->timestamp('last_login_at')->nullable()->after('is_active');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->dropColumn(['role', 'locale', 'is_active', 'last_login_at']);
});
}
};
@@ -0,0 +1,70 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Creates the "ai_agents" table.
*
* Each AI agent represents an autonomous NPC or manager entity inside the
* virtual city. Decisions are delegated to models served by OpenRouter.ai,
* restricted to an allow-list of free models configured by the administrator.
*/
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('ai_agents', function (Blueprint $table): void {
$table->id();
// Human readable label, e.g. "Prefeito", "Lojista Padaria Central".
$table->string('name', 120);
// Agent archetype: mayor, shopkeeper, pedestrian, traffic_officer, etc.
$table->string('type', 40)->index();
// Free OpenRouter model identifier used for reasoning.
$table->string('model', 120)->default('deepseek/deepseek-v4-flash-0731');
// System prompt describing the agent persona and constraints.
$table->text('system_prompt')->nullable();
// Current macro goal in natural language (persisted AI state).
$table->text('current_goal')->nullable();
// JSON snapshot of the latest decision payload returned by the model.
$table->json('last_decision')->nullable();
// Operational state: active, paused, thinking, error.
$table->string('status', 20)->default('active')->index();
// Number of consecutive failures (used for backoff/circuit breaker).
$table->unsignedInteger('error_count')->default(0);
// 3D anchor position of the agent inside the city scene.
$table->decimal('pos_x', 12, 4)->default(0);
$table->decimal('pos_y', 12, 4)->default(0);
$table->decimal('pos_z', 12, 4)->default(0);
// Rotation on the Y axis (radians) for 3D rendering.
$table->decimal('rotation_y', 10, 6)->default(0);
$table->timestamp('last_think_at')->nullable();
$table->timestamps();
$table->index(['type', 'status']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('ai_agents');
}
};
@@ -0,0 +1,67 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Creates the "stores" table.
*
* Stores are physical shop buildings placed in the 3D city at an exact
* (x, y, z) coordinate and are operated by an optional AI shopkeeper agent.
*/
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('stores', function (Blueprint $table): void {
$table->id();
// Store display name, e.g. "Padaria Central".
$table->string('name', 160);
// Free text description shown in the city UI.
$table->text('description')->nullable();
// Store category: bakery, market, hardware, restaurant, etc.
$table->string('type', 60)->index();
// Owner account (regular user acting as shopkeeper).
$table->foreignId('owner_id')->nullable()->constrained('users')->nullOnDelete();
// AI shopkeeper agent driving prices and decisions (optional).
$table->foreignId('agent_id')->nullable()->constrained('ai_agents')->nullOnDelete();
// 3D placement inside the virtual city scene.
$table->decimal('pos_x', 12, 4)->default(0);
$table->decimal('pos_y', 12, 4)->default(0);
$table->decimal('pos_z', 12, 4)->default(0);
// Building footprint dimensions in world units (used by the renderer).
$table->decimal('width', 10, 4)->default(4);
$table->decimal('height', 10, 4)->default(4);
$table->decimal('depth', 10, 4)->default(4);
// Commercial state of the store.
$table->boolean('is_open')->default(true)->index();
// Current revenue in virtual currency (updated by AI/players).
$table->decimal('revenue', 14, 2)->default(0);
$table->timestamps();
$table->index(['pos_x', 'pos_y', 'pos_z']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('stores');
}
};
@@ -0,0 +1,77 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Creates legal document and consent tables.
*
* Stores versioned legal documents (Terms of Use, Privacy Policy and Cookie
* Policy, all aligned with the Brazilian LGPD) together with an immutable
* audit trail of each user consent event.
*/
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('legal_documents', function (Blueprint $table): void {
$table->id();
// Document kind: terms, privacy, cookies.
$table->string('type', 20)->index();
// Version identifier, e.g. "1.0.0" (incremented on edits).
$table->string('version', 20);
// Title localized per language key stored in JSON: {"pt_BR":..,"en":..,"es":..}
$table->json('title');
// Full document body per language (markdown/HTML-safe text).
$table->json('body');
// Whether this version is the one new users must accept.
$table->boolean('is_current')->default(true)->index();
$table->timestamp('published_at')->nullable();
$table->timestamps();
// A given type+version pair can only exist once.
$table->unique(['type', 'version']);
});
Schema::create('user_consents', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('legal_document_id')->constrained('legal_documents')->cascadeOnDelete();
// Moment the user accepted this specific document version.
$table->timestamp('accepted_at');
// Client IP and user agent captured at acceptance (LGPD audit).
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
// Consent channel, e.g. "web_form", "api".
$table->string('source', 30)->default('web_form');
$table->timestamps();
// One consent per user per document version (idempotent audit).
$table->unique(['user_id', 'legal_document_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('user_consents');
Schema::dropIfExists('legal_documents');
}
};
+128
View File
@@ -0,0 +1,128 @@
<?php
namespace Database\Seeders;
use App\Models\AiAgent;
use App\Models\Store;
use App\Models\User;
use Illuminate\Database\Seeder;
/**
* Seeds a starter set of AI agents and stores for the virtual city.
*
* Provides the Mayor, a few shopkeepers and pedestrian agents together
* with representative buildings so that the 3D scene has content to
* render on first load.
*/
class CityContentSeeder extends Seeder
{
/**
* Seed AI agents and stores.
*/
public function run(): void
{
$shopkeeper = User::where('email', 'user@sistema.net')->first();
// --- Mayor: coordinates the city at a macro level -----------------
$mayor = AiAgent::updateOrCreate(
['name' => 'Prefeito Augusto'],
[
'type' => 'mayor',
'model' => config('ai.default_model'),
'system_prompt' => 'Você é o Prefeito da cidade virtual City10. Tome decisões macro sobre infraestrutura, tráfego e qualidade de vida dos cidadãos.',
'current_goal' => 'Manter a cidade segura e próspera',
'status' => 'active',
'pos_x' => 0, 'pos_y' => 0, 'pos_z' => 0,
],
);
// --- Shopkeepers --------------------------------------------------
$baker = AiAgent::updateOrCreate(
['name' => 'Seu João — Padaria Central'],
[
'type' => 'shopkeeper',
'model' => config('ai.default_model'),
'system_prompt' => 'Você é o dono da Padaria Central. Gerencie estoque, preços e atendimento com simpatia.',
'current_goal' => 'Aumentar as vendas da manhã',
'status' => 'active',
'pos_x' => 12, 'pos_y' => 0, 'pos_z' => -8,
],
);
// --- Pedestrians --------------------------------------------------
AiAgent::updateOrCreate(
['name' => 'Nara'],
[
'type' => 'pedestrian',
'model' => config('ai.default_model'),
'system_prompt' => 'Você é a Nara, uma pedestre amigável. Converse brevemente com quem se aproximar.',
'current_goal' => 'Caminhar até a praça',
'status' => 'active',
'pos_x' => 20, 'pos_y' => 0, 'pos_z' => 10,
],
);
AiAgent::updateOrCreate(
['name' => 'Wilson'],
[
'type' => 'pedestrian',
'model' => config('ai.default_model'),
'system_prompt' => 'Você é o Wilson, um pedestre que trabalha na loja de material de construção.',
'current_goal' => 'Voltar para casa',
'status' => 'active',
'pos_x' => 6, 'pos_y' => 0, 'pos_z' => 4,
],
);
AiAgent::updateOrCreate(
['name' => 'Amanda'],
[
'type' => 'pedestrian',
'model' => config('ai.default_model'),
'system_prompt' => 'Você é a Amanda, criadora de conteúdo. Gosta de conversar sobre arte e moda.',
'current_goal' => 'Fotografe a praça para o feed',
'status' => 'active',
'pos_x' => 30, 'pos_y' => 0, 'pos_z' => 6,
],
);
// --- Stores -------------------------------------------------------
Store::updateOrCreate(
['name' => 'Padaria Central'],
[
'description' => 'Pães frescos e cafés desde 1998.',
'type' => 'bakery',
'owner_id' => $shopkeeper?->id,
'agent_id' => $baker->id,
'pos_x' => 12, 'pos_y' => 0, 'pos_z' => -12,
'width' => 8, 'height' => 6, 'depth' => 8,
'is_open' => true,
],
);
Store::updateOrCreate(
['name' => 'Mercado Municipal'],
[
'description' => 'Produtos frescos e itens do dia a dia.',
'type' => 'market',
'owner_id' => $shopkeeper?->id,
'agent_id' => $mayor->id,
'pos_x' => -18, 'pos_y' => 0, 'pos_z' => 14,
'width' => 14, 'height' => 8, 'depth' => 12,
'is_open' => true,
],
);
Store::updateOrCreate(
['name' => 'Loja de Materiais da Esquina'],
[
'description' => 'Material de construção e ferramentas.',
'type' => 'hardware',
'owner_id' => $shopkeeper?->id,
'pos_x' => 8, 'pos_y' => 0, 'pos_z' => 6,
'width' => 10, 'height' => 7, 'depth' => 10,
'is_open' => true,
],
);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
/**
* Master seeder orchestrating all initial dataset loaders.
*
* Order matters: legal documents and users are created first so that
* foreign keys from agents and stores resolve correctly.
*/
class DatabaseSeeder extends Seeder
{
use WithoutModelEvents;
/**
* Seed the application's database.
*/
public function run(): void
{
$this->call([
LegalDocumentSeeder::class,
UserSeeder::class,
CityContentSeeder::class,
]);
}
}
+442
View File
@@ -0,0 +1,442 @@
<?php
namespace Database\Seeders;
use App\Models\LegalDocument;
use Illuminate\Database\Seeder;
/**
* Seeds the initial version (1.0.0) of the three legal documents.
*
* Content is provided in Portuguese (Brazil), English and Spanish and
* covers the requirements of the Brazilian LGPD (Lei nº 13.709/2018):
* data collection, storage, processing, sharing, deletion rights and the
* cookie policy with consent management.
*/
class LegalDocumentSeeder extends Seeder
{
/**
* Seed the legal documents table.
*/
public function run(): void
{
$publishedAt = now();
LegalDocument::updateOrCreate(
['type' => LegalDocument::TYPE_TERMS, 'version' => '1.0.0'],
[
'title' => [
'pt_BR' => 'Termos de Uso e Política de Privacidade',
'en' => 'Terms of Use and Privacy Policy',
'es' => 'Términos de Uso y Política de Privacidad',
],
'body' => [
'pt_BR' => $this->termsPtBr(),
'en' => $this->termsEn(),
'es' => $this->termsEs(),
],
'is_current' => true,
'published_at' => $publishedAt,
],
);
LegalDocument::updateOrCreate(
['type' => LegalDocument::TYPE_PRIVACY, 'version' => '1.0.0'],
[
'title' => [
'pt_BR' => 'Política de Privacidade (LGPD)',
'en' => 'Privacy Policy (LGPD)',
'es' => 'Política de Privacidad (LGPD)',
],
'body' => [
'pt_BR' => $this->privacyPtBr(),
'en' => $this->privacyEn(),
'es' => $this->privacyEs(),
],
'is_current' => true,
'published_at' => $publishedAt,
],
);
LegalDocument::updateOrCreate(
['type' => LegalDocument::TYPE_COOKIES, 'version' => '1.0.0'],
[
'title' => [
'pt_BR' => 'Política de Cookies',
'en' => 'Cookie Policy',
'es' => 'Política de Cookies',
],
'body' => [
'pt_BR' => $this->cookiesPtBr(),
'en' => $this->cookiesEn(),
'es' => $this->cookiesEs(),
],
'is_current' => true,
'published_at' => $publishedAt,
],
);
}
/**
* Terms of Use body in Portuguese (Brazil).
*/
private function termsPtBr(): string
{
return <<<'TXT'
# Termos de Uso — City10
**Versão 1.0.0** — Vigência a partir da data de aceite pelo usuário.
## 1. Objeto
O City10 é uma plataforma de simulação urbana 3D com agentes de inteligência artificial, operada pela Syspanel (contato@syspanel.com.br). Ao criar uma conta e utilizar a plataforma, o usuário declara ter lido e concordado com estes Termos e com a Política de Privacidade.
## 2. Cadastro e Conta
2.1. O usuário deve fornecer informações verdadeiras (nome e e-mail).
2.2. A conta é pessoal e intransferível. O usuário é responsável pela guarda de sua senha.
2.3. Contas administrativas possuem privilégios adicionais de gestão da cidade e dos agentes de IA.
## 3. Uso Aceitável
3.1. É vedado utilizar a plataforma para fins ilícitos, ofensivos ou que violem direitos de terceiros.
3.2. É vedado tentar contornar mecanismos de segurança, exceder limites de uso da API ou inserir conteúdo malicioso.
3.3. Os agentes de IA operam com modelos de linguagem de terceiros (OpenRouter.ai). As decisões simuladas por esses agentes são ficção e não constituem aconselhamento profissional.
## 4. Propriedade Intelectual
4.1. O software, marca City10 e respectivos código-fonte são propriedade da Syspanel, regidos pela Licença Proprietária anexa (LICENSE).
4.2. O conteúdo gerado pelo usuário permanece de sua titulariedade, concedendo à plataforma a licença mínima necessária para exibição.
## 5. Limitação de Responsabilidade
5.1. A plataforma é fornecida "no estado em que se encontra". A Syspanel não garante disponibilidade ininterrupta.
5.2. A Syspanel não se responsabiliza por decisões tomadas com base em simulações produzidas pelos agentes de IA.
## 6. Encerramento
6.1. A Syspanel pode suspender ou encerrar contas que violem estes Termos, mediante notificação quando possível.
6.2. O usuário pode solicitar a exclusão de seus dados a qualquer momento (direito de eliminação, art. 18, VI, LGPD).
## 7. Foro
Fica eleito o foro da comarca de Santa Rita do Sapucaí — MG, Brasil, para dirimir controvérsias decorrentes destes Termos.
TXT;
}
/**
* Terms of Use body in English.
*/
private function termsEn(): string
{
return <<<'TXT'
# Terms of Use — City10
**Version 1.0.0** — Effective from the date of user acceptance.
## 1. Purpose
City10 is a 3D urban simulation platform with artificial intelligence agents, operated by Syspanel (contato@syspanel.com.br). By creating an account and using the platform, the user acknowledges having read and agreed to these Terms and to the Privacy Policy.
## 2. Registration and Account
2.1. Users must provide truthful information (name and email).
2.2. Accounts are personal and non-transferable. The user is responsible for safeguarding their password.
2.3. Administrative accounts hold additional privileges to manage the city and its AI agents.
## 3. Acceptable Use
3.1. Using the platform for unlawful, offensive purposes or that infringe third-party rights is prohibited.
3.2. Circumventing security mechanisms, exceeding API usage limits or injecting malicious content is prohibited.
3.3. AI agents run on third-party language models (OpenRouter.ai). Agent decisions are fictional and do not constitute professional advice.
## 4. Intellectual Property
4.1. The software, the City10 brand and its source code are property of Syspanel, governed by the proprietary License attached (LICENSE).
4.2. User-generated content remains owned by the user, who grants the platform the minimal licence required for display.
## 5. Limitation of Liability
5.1. The platform is provided "as is". Syspanel does not guarantee uninterrupted availability.
5.2. Syspanel is not liable for decisions taken based on simulations produced by AI agents.
## 6. Termination
6.1. Syspanel may suspend or terminate accounts that violate these Terms, with notice when possible.
6.2. Users may request deletion of their data at any time (right of erasure, art. 18, VI, LGPD).
## 7. Governing Law
The courts of Santa Rita do Sapucaí — MG, Brazil, are elected to settle disputes arising from these Terms.
TXT;
}
/**
* Terms of Use body in Spanish.
*/
private function termsEs(): string
{
return <<<'TXT'
# Términos de Uso — City10
**Versión 1.0.0** — Vigente desde la fecha de aceptación por el usuario.
## 1. Objeto
City10 es una plataforma de simulación urbana 3D con agentes de inteligencia artificial, operada por Syspanel (contato@syspanel.com.br). Al crear una cuenta y utilizar la plataforma, el usuario declara haber leído y aceptado estos Términos y la Política de Privacidad.
## 2. Registro y Cuenta
2.1. El usuario debe proporcionar información verídica (nombre y correo electrónico).
2.2. La cuenta es personal e intransferible. El usuario es responsable de la custodia de su contraseña.
2.3. Las cuentas administrativas poseen privilegios adicionales de gestión de la ciudad y de los agentes de IA.
## 3. Uso Aceptable
3.1. Está prohibido utilizar la plataforma con fines ilícitos, ofensivos o que violen derechos de terceros.
3.2. Está prohibido eludir mecanismos de seguridad, exceder límites de uso de la API o inyectar contenido malicioso.
3.3. Los agentes de IA operan con modelos de lenguaje de terceros (OpenRouter.ai). Las decisiones simuladas son ficción y no constituyen asesoramiento profesional.
## 4. Propiedad Intelectual
4.1. El software, la marca City10 y su código fuente son propiedad de Syspanel, regidos por la Licencia Proprietaria adjunta (LICENSE).
4.2. El contenido generado por el usuario sigue siendo de su titularidad, concediendo a la plataforma la licencia mínima necesaria para su exhibición.
## 5. Limitación de Responsabilidad
5.1. La plataforma se ofrece "tal cual". Syspanel no garantiza disponibilidad ininterrumpida.
5.2. Syspanel no se responsabiliza por decisiones tomadas en base a simulaciones producidas por los agentes de IA.
## 6. Terminación
6.1. Syspanel puede suspender o terminar cuentas que violen estos Términos, con notificación cuando sea posible.
6.2. El usuario puede solicitar la eliminación de sus datos en cualquier momento (derecho de supresión, art. 18, VI, LGPD).
## 7. Ley Aplicable
Se elige el foro de Santa Rita do Sapucaí — MG, Brasil, para resolver controversias derivadas de estos Términos.
TXT;
}
/**
* Privacy Policy body in Portuguese (Brazil).
*/
private function privacyPtBr(): string
{
return <<<'TXT'
# Política de Privacidade — City10 (LGPD)
**Versão 1.0.0** — Em conformidade com a Lei nº 13.709/2018 (LGPD).
## 1. Controlador dos Dados
Syspanel — contato@syspanel.com.br — Santa Rita do Sapucaí/MG, Brasil.
## 2. Dados Pessoais Coletados
2.1. **Dados de cadastro:** nome, e-mail e senha (armazenada em formato irreversível — hash bcrypt).
2.2. **Dados de consentimento:** data/hora, versão dos documentos aceitos, endereço IP e user agent (auditoria LGPD).
2.3. **Dados de uso:** registros de acesso (logs) e preferência de idioma.
## 3. Finalidades e Bases Legais
3.1. **Execução do contrato** (art. 7º, V): prestação da plataforma, autenticação e funcionamento da cidade 3D.
3.2. **Consentimento** (art. 7º, I): aceite dos Termos, Política de Privacidade e Política de Cookies.
3.3. **Legítimo interesse** (art. 7º, IX): segurança, prevenção a fraudes e melhoria do serviço, com testes de balanceamento.
## 4. Compartilhamento
4.1. Os dados são compartilhados exclusivamente com: (i) provedor de infraestrutura ( hospedagem em Docker ); (ii) OpenRouter.ai, exclusivamente com prompts contendo contexto simulado da cidade — nunca dados cadastrais do usuário.
4.2. Não vendemos dados pessoais a terceiros.
## 5. Transferência Internacional
O serviço de modelos de IA (OpenRouter.ai) pode processar prompts fora do Brasil. Nesses casos, adotamos salvaguardas contratuais adequadas.
## 6. Tempo de Conservação
6.1. Dados de cadastro: enquanto a conta estiver ativa + 6 meses após encerramento.
6.2. Registros de consentimento: pelo prazo mínimo de 5 anos (art. 37, LGPD — dever de guarda de registros).
## 7. Direitos do Titular (art. 18)
O usuário pode solicitar: confirmação de tratamento; acesso; correção; anonimização, bloqueio ou eliminação; portabilidade; informação sobre compartilhamento; revogação do consentimento. Solicitações: contato@syspanel.com.br, respondidas em até 15 dias.
## 8. Segurança
Utilizamos hash de senhas (bcrypt), controle de acesso por papel (roles), tokens de API revogáveis e trilhas de auditoria.
## 9. Encarregado de Dados (DPO)
contato@syspanel.com.br — canal direto para o Encarregado pelo tratamento de dados pessoais.
TXT;
}
/**
* Privacy Policy body in English.
*/
private function privacyEn(): string
{
return <<<'TXT'
# Privacy Policy — City10 (LGPD)
**Version 1.0.0** — Compliant with Brazilian Law nº 13.709/2018 (LGPD).
## 1. Data Controller
Syspanel — contato@syspanel.com.br — Santa Rita do Sapucaí/MG, Brazil.
## 2. Personal Data Collected
2.1. **Registration data:** name, email and password (stored irreversibly as a bcrypt hash).
2.2. **Consent data:** date/time, accepted document versions, IP address and user agent (LGPD audit trail).
2.3. **Usage data:** access logs and language preference.
## 3. Purposes and Legal Bases
3.1. **Contract performance** (art. 7, V): platform delivery, authentication and 3D city operation.
3.2. **Consent** (art. 7, I): acceptance of Terms, Privacy Policy and Cookie Policy.
3.3. **Legitimate interest** (art. 7, IX): security, fraud prevention and service improvement.
## 4. Sharing
4.1. Data is shared exclusively with: (i) the infrastructure provider (Docker hosting); (ii) OpenRouter.ai, strictly with prompts containing simulated city context — never user registration data.
4.2. We do not sell personal data to third parties.
## 5. International Transfer
The AI model service (OpenRouter.ai) may process prompts outside Brazil. Adequate contractual safeguards are applied.
## 6. Retention Period
6.1. Registration data: while the account is active + 6 months after termination.
6.2. Consent records: minimum of 5 years (art. 37, LGPD — record-keeping duty).
## 7. Data Subject Rights (art. 18)
Users may request: confirmation of processing; access; correction; anonymisation, blocking or deletion; portability; information on sharing; withdrawal of consent. Requests: contato@syspanel.com.br, answered within 15 days.
## 8. Security
We use password hashing (bcrypt), role-based access control, revocable API tokens and audit trails.
## 9. Data Protection Officer (DPO)
contato@syspanel.com.br — direct channel to the officer responsible for personal data processing.
TXT;
}
/**
* Privacy Policy body in Spanish.
*/
private function privacyEs(): string
{
return <<<'TXT'
# Política de Privacidad — City10 (LGPD)
**Versión 1.0.0** — En conformidad con la Ley nº 13.709/2018 (LGPD).
## 1. Controlador de los Datos
Syspanel — contato@syspanel.com.br — Santa Rita do Sapucaí/MG, Brasil.
## 2. Datos Personales Recabados
2.1. **Datos de registro:** nombre, correo electrónico y contraseña (almmacenada de forma irreversible — hash bcrypt).
2.2. **Datos de consentimiento:** fecha/hora, versión de los documentos aceptados, dirección IP y user agent (auditoría LGPD).
2.3. **Datos de uso:** registros de acceso (logs) e idioma preferido.
## 3. Finalidades y Bases Legales
3.1. **Ejecución del contrato** (art. 7, V): prestación de la plataforma, autenticación y funcionamiento de la ciudad 3D.
3.2. **Consentimiento** (art. 7, I): aceptación de los Términos, Política de Privacidad y Política de Cookies.
3.3. **Interés legítimo** (art. 7, IX): seguridad, prevención de fraudes y mejora del servicio.
## 4. Compartición
4.1. Los datos se comparten exclusivamente con: (i) el proveedor de infraestructura (hospedaje en Docker); (ii) OpenRouter.ai, estrictamente con prompts que contienen contexto simulado de la ciudad — nunca datos del usuario.
4.2. No vendemos datos personales a terceros.
## 5. Transferencia Internacional
El servicio de modelos de IA (OpenRouter.ai) puede procesar prompts fuera de Brasil. Se aplican salvaguardas contractuales adecuadas.
## 6. Plazo de Conservación
6.1. Datos de registro: mientras la cuenta esté activa + 6 meses tras la finalización.
6.2. Registros de consentimiento: plazo mínimo de 5 años (art. 37, LGPD).
## 7. Derechos del Titular (art. 18)
El usuario puede solicitar: confirmación del tratamiento; acceso; corrección; anonimización, bloqueo o eliminación; portabilidad; información sobre compartición; revocación del consentimiento. Solicitudes: contato@syspanel.com.br, respondidas en hasta 15 días.
## 8. Seguridad
Utilizamos hash de contraseñas (bcrypt), control de acceso por rol, tokens de API revocables y rastros de auditoría.
## 9. Encargado de Datos (DPO)
contato@syspanel.com.br — canal directo con el encargado del tratamiento de datos personales.
TXT;
}
/**
* Cookie Policy body in Portuguese (Brazil).
*/
private function cookiesPtBr(): string
{
return <<<'TXT'
# Política de Cookies — City10
**Versão 1.0.0**
## 1. O que são cookies
Cookies são pequenos arquivos de texto armazenados no navegador durante a navegação, essenciais para o funcionamento de sessões e preferências.
## 2. Cookies Utilizados
| Cookie | Tipo | Finalidade | Duração | Consentimento |
|---|---|---|---|---|
| city10-session | Estritamente necessário | Manter a sessão autenticada (CSRF + login) | Sessão / 120 min | Isento (necessário) |
| XSRF-TOKEN | Estritamente necessário | Proteção contra ataques CSRF | Sessão | Isento (necessário) |
| city10-locale | Desempenho | Guardar o idioma escolhido pelo usuário | 1 ano | Sim |
| city10-consent | Estritamente necessário | Registrar a versão dos termos aceitos | 1 ano | Isento (necessário) |
| city10-analytics | Analítico | Métricas agregadas de uso (futuro) | 90 dias | Sim |
## 3. Gestão do Consentimento
3.1. Ao acessar a plataforma, o usuário visualiza o banner de cookies com opções de **Aceitar todos**, **Recusar opcionais** e **Personalizar**.
3.2. Cookies estritamente necessários não podem ser desativados, pois sem eles a sessão e a proteção CSRF deixam de funcionar.
3.3. A escolha é armazenada no cookie `city10-consent` e pode ser alterada a qualquer momento em **Preferências de Privacidade** no dashboard.
## 4. Base Legal
Tratamento de cookies não necessários ocorre com base no consentimento (art. 7º, I, LGPD e Marco Civil da Internet, Lei nº 12.965/2014).
## 5. Exclusão
O usuário pode remover os cookies a qualquer momento pelas configurações do navegador, ciente de que recursos personalizados voltarão aos padrões.
TXT;
}
/**
* Cookie Policy body in English.
*/
private function cookiesEn(): string
{
return <<<'TXT'
# Cookie Policy — City10
**Version 1.0.0**
## 1. What cookies are
Cookies are small text files stored in the browser during navigation, essential for session handling and preferences.
## 2. Cookies Used
| Cookie | Type | Purpose | Duration | Consent |
|---|---|---|---|---|
| city10-session | Strictly necessary | Keep the authenticated session (CSRF + login) | Session / 120 min | Exempt (necessary) |
| XSRF-TOKEN | Strictly necessary | CSRF attack protection | Session | Exempt (necessary) |
| city10-locale | Performance | Store the user's chosen language | 1 year | Yes |
| city10-consent | Strictly necessary | Record the accepted terms version | 1 year | Exempt (necessary) |
| city10-analytics | Analytics | Aggregated usage metrics (future) | 90 days | Yes |
## 3. Consent Management
3.1. On first access the user sees the cookie banner with **Accept all**, **Reject optional** and **Customise** options.
3.2. Strictly necessary cookies cannot be disabled because sessions and CSRF protection depend on them.
3.3. The choice is stored in the `city10-consent` cookie and can be changed at any time under **Privacy Preferences** in the dashboard.
## 4. Legal Basis
Processing of non-essential cookies relies on consent (art. 7, I, LGPD and the Brazilian Internet Civil Framework, Law nº 12.965/2014).
## 5. Deletion
Users may remove cookies at any time through browser settings, understanding that customised features will return to defaults.
TXT;
}
/**
* Cookie Policy body in Spanish.
*/
private function cookiesEs(): string
{
return <<<'TXT'
# Política de Cookies — City10
**Versión 1.0.0**
## 1. Qué son las cookies
Las cookies son pequeños archivos de texto almacenados en el navegador durante la navegación, esenciales para el funcionamiento de sesiones y preferencias.
## 2. Cookies Utilizadas
| Cookie | Tipo | Finalidad | Duración | Consentimiento |
|---|---|---|---|---|
| city10-session | Estrictamente necesario | Mantener la sesión autenticada (CSRF + login) | Sesión / 120 min | Exento (necesario) |
| XSRF-TOKEN | Estrictamente necesario | Protección contra ataques CSRF | Sesión | Exento (necesario) |
| city10-locale | Rendimiento | Guardar el idioma elegido por el usuario | 1 año | Sí |
| city10-consent | Estrictamente necesario | Registrar la versión de los términos aceptados | 1 año | Exento (necesario) |
| city10-analytics | Analítico | Métricas agregadas de uso (futuro) | 90 días | Sí |
## 3. Gestión del Consentimiento
3.1. Al acceder a la plataforma, el usuario visualiza el banner de cookies con opciones de **Aceptar todos**, **Rechazar opcionales** y **Personalizar**.
3.2. Las cookies estrictamente necesarias no pueden desactivarse, ya que sin ellas la sesión y la protección CSRF dejan de funcionar.
3.3. La elección se almacena en la cookie `city10-consent` y puede modificarse en **Preferencias de Privacidad** del dashboard.
## 4. Base Legal
El tratamiento de cookies no necesarias se basa en el consentimiento (art. 7, I, LGPD y Marco Civil de Internet, Ley nº 12.965/2014).
## 5. Eliminación
El usuario puede eliminar las cookies en cualquier momento desde la configuración del navegador, sabiendo que las funciones personalizadas volverán a los valores predeterminados.
TXT;
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Seeder;
/**
* Seeds the initial platform accounts.
*
* Creates the administrator and a regular test user with the credentials
* provided for the development environment. Passwords are stored using
* Laravel's bcrypt hashing through the model's "hashed" cast.
*/
class UserSeeder extends Seeder
{
/**
* Seed the users table.
*/
public function run(): void
{
User::updateOrCreate(
['email' => 'marcocosta@gmx.com'],
[
'name' => 'Marco Costa',
'password' => 'Mega886ax',
'role' => User::ROLE_ADMIN,
'locale' => 'pt_BR',
'is_active' => true,
'email_verified_at' => now(),
],
);
User::updateOrCreate(
['email' => 'user@sistema.net'],
[
'name' => 'Usuário de Testes',
'password' => 'User123',
'role' => User::ROLE_USER,
'locale' => 'pt_BR',
'is_active' => true,
'email_verified_at' => now(),
],
);
}
}
+107
View File
@@ -0,0 +1,107 @@
{
"Login": "Log in",
"Logout": "Log out",
"Register": "Register",
"Dashboard": "Dashboard",
"Email": "Email",
"Password": "Password",
"Password Confirmation": "Confirm Password",
"Remember Me": "Remember me",
"Name": "Name",
"Save": "Save",
"Cancel": "Cancel",
"Delete": "Delete",
"Edit": "Edit",
"Create": "Create",
"Search": "Search",
"Back": "Back",
"Welcome": "Welcome to City10",
"auth.failed": "These credentials do not match our records.",
"auth.disabled": "This account has been disabled. Please contact an administrator.",
"auth.forbidden": "You do not have permission to access this resource.",
"auth.logged_out": "You have been logged out successfully.",
"auth.throttle": "Too many attempts. Please try again in :seconds seconds.",
"legal.accept_required": "You must accept the Terms of Use and Privacy Policy before continuing.",
"legal.accepted": "Terms accepted successfully.",
"legal.must_accept_terms": "You must accept the Terms of Use.",
"legal.must_accept_privacy": "You must accept the Privacy Policy.",
"legal.must_accept_cookies": "You must accept the Cookie Policy.",
"validation.captcha_required": "Please solve the math verification.",
"validation.captcha_failed": "The math verification answer is incorrect.",
"validation.model_not_allowed": "The selected AI model is not in the allowed list.",
"users.cannot_deactivate_self": "You cannot deactivate your own account.",
"users.deactivated": "Account deactivated successfully.",
"stores.deleted": "Store deleted successfully.",
"agents.deleted": "Agent deleted successfully.",
"welcome.tagline": "A living 3D city in your browser, with autonomous AI agents — Mayor, pedestrians, shopkeepers and vehicles — deciding in real time through OpenRouter.ai.",
"welcome.card1.title": "🌐 Real-Time 3D City",
"welcome.card1.desc": "Explore streets, avenues, bridges, rivers, squares and buildings rendered with Three.js right in your browser.",
"welcome.card2.title": "🤖 AI Agents",
"welcome.card2.desc": "The mayor, pedestrians and shopkeepers make strategic decisions using free OpenRouter.ai models.",
"welcome.card3.title": "🔒 Secure & LGPD",
"welcome.card3.desc": "Differentiated authentication (Admin/User), math CAPTCHA and auditable terms consent compliant with Brazilian law.",
"welcome.city.title": "🗺️ City Elements (Phase 1)",
"welcome.city.desc": "The database already models houses, buildings, schools, parks, squares, traffic lights, streets, avenues, bridges, airports, rivers, industries, stores and shopkeepers — all with 3D coordinates (X, Y, Z) ready for the rendering engine.",
"welcome.no_account": "Don't have an account?",
"welcome.has_account": "Already have an account?",
"nav.city": "3D City",
"nav.admin": "Admin",
"dashboard.open_viewer": "Open 3D viewer",
"dashboard.viewer_desc": "Explore the city ground, stores and agents with a controllable camera.",
"dashboard.stores_title": "Stores",
"dashboard.stores_desc": "Stores registered in the 3D city.",
"dashboard.legal_title": "Consents",
"dashboard.consent_history": "View acceptance history",
"legal.terms_label": "Terms",
"legal.privacy_label": "Privacy",
"legal.cookies_label": "Cookies",
"admin.stores.title": "Manage Stores",
"admin.stores.manage": "Manage stores",
"admin.stores.admin_desc": "Create stores and define their 3D position in the city.",
"admin.stores.new": "New store",
"admin.stores.created": "Store created successfully.",
"admin.stores.updated": "Store updated successfully.",
"admin.stores.edit_title": "Edit store",
"admin.stores.create_title": "New store",
"admin.stores.empty": "No stores registered yet.",
"admin.stores.position": "3D position in the city",
"admin.stores.position_hint": "Click the ground to set X/Z, or type the coordinates. Use controls to rotate/zoom.",
"admin.stores.pos_x": "Position X",
"admin.stores.pos_y": "Height Y (ground = 0)",
"admin.stores.pos_z": "Position Z",
"admin.stores.dimensions": "Building dimensions",
"admin.stores.width": "Width",
"admin.stores.height": "Height",
"admin.stores.depth": "Depth",
"admin.stores.type": "Store type",
"admin.stores.description": "Description",
"admin.stores.owner": "Owner (user)",
"admin.stores.agent": "AI agent (shopkeeper)",
"admin.stores.is_open": "Store open",
"admin.stores.no_owner": "No owner",
"admin.stores.no_agent": "No agent",
"admin.stores.actions": "Actions",
"admin.stores.coords": "Coordinates",
"admin.stores.confirm_delete": "Delete this store from the city?",
"city.title": "3D City Viewer",
"city.loading": "Loading city…",
"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.refresh": "Reload",
"city.selected": "Selected",
"city.open": "Open",
"city.closed": "Closed",
"city.hint_fps": "WASD to walk · Mouse to look · Esc to exit · Click an NPC to chat",
"city.position": "POSITION",
"city.on_ground": "on ground",
"city.triangles": "collision tris",
"city.chat_placeholder": "Type your message…",
"city.chat_thinking": "thinking…",
"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.chat_send": "Send",
"city.chat_with": "Chatting with"
}
+107
View File
@@ -0,0 +1,107 @@
{
"Login": "Iniciar sesión",
"Logout": "Cerrar sesión",
"Register": "Registrarse",
"Dashboard": "Panel",
"Email": "Correo electrónico",
"Password": "Contraseña",
"Password Confirmation": "Confirmar contraseña",
"Remember Me": "Recuérdame",
"Name": "Nombre",
"Save": "Guardar",
"Cancel": "Cancelar",
"Delete": "Eliminar",
"Edit": "Editar",
"Create": "Crear",
"Search": "Buscar",
"Back": "Volver",
"Welcome": "Bienvenido a City10",
"auth.failed": "Las credenciales proporcionadas no coinciden con nuestros registros.",
"auth.disabled": "Esta cuenta ha sido desactivada. Contacte al administrador.",
"auth.forbidden": "No tiene permiso para acceder a este recurso.",
"auth.logged_out": "Sesión cerrada exitosamente.",
"auth.throttle": "Demasiados intentos. Intente de nuevo en :seconds segundos.",
"legal.accept_required": "Debe aceptar los Términos de Uso y la Política de Privacidad antes de continuar.",
"legal.accepted": "Términos aceptados exitosamente.",
"legal.must_accept_terms": "Debe aceptar los Términos de Uso.",
"legal.must_accept_privacy": "Debe aceptar la Política de Privacidad.",
"legal.must_accept_cookies": "Debe aceptar la Política de Cookies.",
"validation.captcha_required": "Resuelva la verificación matemática.",
"validation.captcha_failed": "La respuesta de la verificación matemática es incorrecta.",
"validation.model_not_allowed": "El modelo de IA seleccionado no está en la lista de permitidos.",
"users.cannot_deactivate_self": "No puede desactivar su propia cuenta.",
"users.deactivated": "Cuenta desactivada exitosamente.",
"stores.deleted": "Tienda eliminada exitosamente.",
"agents.deleted": "Agente eliminado exitosamente.",
"welcome.tagline": "Una ciudad 3D viva en tu navegador, con agentes de IA autónomos — Alcalde, peatones, comerciantes y vehículos — decidiendo en tiempo real a través de OpenRouter.ai.",
"welcome.card1.title": "🌐 Ciudad 3D en Tiempo Real",
"welcome.card1.desc": "Explora calles, avenidas, puentes, ríos, plazas y edificios renderizados con Three.js directamente en tu navegador.",
"welcome.card2.title": "🤖 Agentes de IA",
"welcome.card2.desc": "El alcalde, los peatones y los comerciantes toman decisiones estratégicas usando modelos gratuitos de OpenRouter.ai.",
"welcome.card3.title": "🔒 Seguro y LGPD",
"welcome.card3.desc": "Autenticación diferenciada (Admin/User), CAPTCHA matemático y consentimiento auditado de términos conforme la legislación brasileña.",
"welcome.city.title": "🗺️ Elementos de la Ciudad (Fase 1)",
"welcome.city.desc": "La base de datos ya modela casas, edificios, escuelas, parques, plazas, semáforos, calles, avenidas, puentes, aeropuertos, ríos, industrias, tiendas y comerciantes — todos con coordenadas 3D (X, Y, Z) listas para el motor de renderizado.",
"welcome.no_account": "¿No tienes cuenta?",
"welcome.has_account": "¿Ya tienes cuenta?",
"nav.city": "Ciudad 3D",
"nav.admin": "Administración",
"dashboard.open_viewer": "Abrir visor 3D",
"dashboard.viewer_desc": "Explora el suelo de la ciudad, tiendas y agentes con cámara controlable.",
"dashboard.stores_title": "Tiendas",
"dashboard.stores_desc": "Tiendas registradas en la ciudad 3D.",
"dashboard.legal_title": "Consentimientos",
"dashboard.consent_history": "Ver historial de aceptaciones",
"legal.terms_label": "Términos",
"legal.privacy_label": "Privacidad",
"legal.cookies_label": "Cookies",
"admin.stores.title": "Gestionar Tiendas",
"admin.stores.manage": "Gestionar tiendas",
"admin.stores.admin_desc": "Crea tiendas y define su posición 3D en la ciudad.",
"admin.stores.new": "Nueva tienda",
"admin.stores.created": "Tienda creada exitosamente.",
"admin.stores.updated": "Tienda actualizada exitosamente.",
"admin.stores.edit_title": "Editar tienda",
"admin.stores.create_title": "Nueva tienda",
"admin.stores.empty": "Aún no hay tiendas registradas.",
"admin.stores.position": "Posición 3D en la ciudad",
"admin.stores.position_hint": "Haz clic en el suelo para fijar X/Z o escribe las coordenadas. Usa los controles para rotar/zoom.",
"admin.stores.pos_x": "Posición X",
"admin.stores.pos_y": "Altura Y (suelo = 0)",
"admin.stores.pos_z": "Posición Z",
"admin.stores.dimensions": "Dimensiones del edificio",
"admin.stores.width": "Ancho",
"admin.stores.height": "Alto",
"admin.stores.depth": "Profundidad",
"admin.stores.type": "Tipo de tienda",
"admin.stores.description": "Descripción",
"admin.stores.owner": "Dueño (usuario)",
"admin.stores.agent": "Agente de IA (comerciante)",
"admin.stores.is_open": "Tienda abierta",
"admin.stores.no_owner": "Sin dueño",
"admin.stores.no_agent": "Sin agente",
"admin.stores.actions": "Acciones",
"admin.stores.coords": "Coordenadas",
"admin.stores.confirm_delete": "¿Eliminar esta tienda de la ciudad?",
"city.title": "Visor 3D de la Ciudad",
"city.loading": "Cargando ciudad…",
"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.refresh": "Recargar",
"city.selected": "Seleccionada",
"city.open": "Abierta",
"city.closed": "Cerrada",
"city.hint_fps": "WASD para caminar · Ratón para mirar · Esc para salir · Clic en un NPC para chatear",
"city.position": "POSICIÓN",
"city.on_ground": "en el suelo",
"city.triangles": "tris de colisión",
"city.chat_placeholder": "Escribe tu mensaje…",
"city.chat_thinking": "pensando…",
"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.chat_send": "Enviar",
"city.chat_with": "Chateando con"
}
+107
View File
@@ -0,0 +1,107 @@
{
"Login": "Entrar",
"Logout": "Sair",
"Register": "Cadastrar",
"Dashboard": "Painel",
"Email": "E-mail",
"Password": "Senha",
"Password Confirmation": "Confirmar Senha",
"Remember Me": "Lembrar de mim",
"Name": "Nome",
"Save": "Salvar",
"Cancel": "Cancelar",
"Delete": "Excluir",
"Edit": "Editar",
"Create": "Criar",
"Search": "Buscar",
"Back": "Voltar",
"Welcome": "Bem-vindo ao City10",
"auth.failed": "As credenciais fornecidas não correspondem aos nossos registros.",
"auth.disabled": "Esta conta foi desativada. Contate o administrador.",
"auth.forbidden": "Você não tem permissão para acessar este recurso.",
"auth.logged_out": "Sessão encerrada com sucesso.",
"auth.throttle": "Muitas tentativas. Tente novamente em :seconds segundos.",
"legal.accept_required": "Você precisa aceitar os Termos de Uso e a Política de Privacidade antes de continuar.",
"legal.accepted": "Termos aceitos com sucesso.",
"legal.must_accept_terms": "Você precisa aceitar os Termos de Uso.",
"legal.must_accept_privacy": "Você precisa aceitar a Política de Privacidade.",
"legal.must_accept_cookies": "Você precisa aceitar a Política de Cookies.",
"validation.captcha_required": "Resolva a verificação matemática.",
"validation.captcha_failed": "Resposta da verificação matemática incorreta.",
"validation.model_not_allowed": "O modelo de IA selecionado não está na lista de permitidos.",
"users.cannot_deactivate_self": "Você não pode desativar sua própria conta.",
"users.deactivated": "Conta desativada com sucesso.",
"stores.deleted": "Loja excluída com sucesso.",
"agents.deleted": "Agente excluído com sucesso.",
"welcome.tagline": "Uma cidade 3D viva no navegador, com agentes de IA autônomos — Prefeito, pedestres, lojistas e veículos — decidindo em tempo real através do OpenRouter.ai.",
"welcome.card1.title": "🌐 Cidade 3D em Tempo Real",
"welcome.card1.desc": "Explore ruas, avenidas, pontes, rios, praças e edifícios renderizados com Three.js direto no navegador.",
"welcome.card2.title": "🤖 Agentes de IA",
"welcome.card2.desc": "Prefeito, pedestres e lojistas tomam decisões estratégicas através de modelos gratuitos do OpenRouter.ai.",
"welcome.card3.title": "🔒 Seguro & LGPD",
"welcome.card3.desc": "Autenticação diferenciada (Admin/User), CAPTCHA matemático e consentimento auditável de termos conforme a legislação brasileira.",
"welcome.city.title": "🗺️ Elementos da Cidade (Fase 1)",
"welcome.city.desc": "A base de dados já modela casas, edifícios, escolas, parques, praças, semáforos, ruas, avenidas, pontes, aeroportos, rios, indústrias, lojas e lojistas — todos com coordenadas 3D (X, Y, Z) prontas para o motor de renderização.",
"welcome.no_account": "Não tem conta?",
"welcome.has_account": "Já tem conta?",
"nav.city": "Cidade 3D",
"nav.admin": "Administração",
"dashboard.open_viewer": "Abrir visualizador 3D",
"dashboard.viewer_desc": "Explore o chão da cidade, lojas e agentes com câmera controlável.",
"dashboard.stores_title": "Lojas",
"dashboard.stores_desc": "Lojas cadastradas na cidade 3D.",
"dashboard.legal_title": "Consentimentos",
"dashboard.consent_history": "Ver histórico de aceites",
"legal.terms_label": "Termos",
"legal.privacy_label": "Privacidade",
"legal.cookies_label": "Cookies",
"admin.stores.title": "Gerenciar Lojas",
"admin.stores.manage": "Gerenciar lojas",
"admin.stores.admin_desc": "Cadastre lojas e defina a posição 3D delas na cidade.",
"admin.stores.new": "Nova loja",
"admin.stores.created": "Loja criada com sucesso.",
"admin.stores.updated": "Loja atualizada com sucesso.",
"admin.stores.edit_title": "Editar loja",
"admin.stores.create_title": "Nova loja",
"admin.stores.empty": "Nenhuma loja cadastrada ainda.",
"admin.stores.position": "Posição 3D na cidade",
"admin.stores.position_hint": "Clique no chão para definir X/Z, ou digite as coordenadas. Use os controles para girar/zoom.",
"admin.stores.pos_x": "Posição X",
"admin.stores.pos_y": "Altura Y (chão = 0)",
"admin.stores.pos_z": "Posição Z",
"admin.stores.dimensions": "Dimensões do prédio",
"admin.stores.width": "Largura",
"admin.stores.height": "Altura",
"admin.stores.depth": "Profundidade",
"admin.stores.type": "Tipo de loja",
"admin.stores.description": "Descrição",
"admin.stores.owner": "Dono (usuário)",
"admin.stores.agent": "Agente de IA (lojista)",
"admin.stores.is_open": "Loja aberta",
"admin.stores.no_owner": "Sem dono",
"admin.stores.no_agent": "Sem agente",
"admin.stores.actions": "Ações",
"admin.stores.coords": "Coordenadas",
"admin.stores.confirm_delete": "Excluir esta loja da cidade?",
"city.title": "Visualizador 3D da Cidade",
"city.loading": "Carregando cidade…",
"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.refresh": "Recarregar",
"city.selected": "Selecionada",
"city.open": "Aberta",
"city.closed": "Fechada",
"city.hint_fps": "WASD para andar · Mouse para olhar · Esc para sair · Clique no NPC para conversar",
"city.position": "POSIÇÃO",
"city.on_ground": "no chão",
"city.triangles": "tris de colisão",
"city.chat_placeholder": "Digite sua mensagem…",
"city.chat_thinking": "pensando…",
"city.chat_responding": "respondendo…",
"city.chat_accepted": "Mensagem enviada ao agente.",
"city.chat_error": "Desculpe, não consegui responder agora. Tente novamente.",
"city.chat_send": "Enviar",
"city.chat_with": "Conversando com"
}
Executable
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "https://www.schemastore.org/package.json",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"concurrently": "^10.0.3",
"laravel-vite-plugin": "^3.1",
"tailwindcss": "^4.0.0",
"vite": "^8.0.0"
},
"optionalDependencies": {
"@laravel/multiplex": "^0.4.1"
}
}
Executable
+36
View File
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="DB_URL" value=""/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
</php>
</phpunit>
+25
View File
@@ -0,0 +1,25 @@
<IfModule mod_rewrite.c>
<IfModule mod_negotiation.c>
Options -MultiViews -Indexes
</IfModule>
RewriteEngine On
# Handle Authorization Header
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
# Handle X-XSRF-Token Header
RewriteCond %{HTTP:x-xsrf-token} .
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
# Redirect Trailing Slashes If Not A Folder...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# Send Requests To Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>
View File
+20
View File
@@ -0,0 +1,20 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
// Determine if the application is in maintenance mode...
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
require $maintenance;
}
// Register the Composer autoloader...
require __DIR__.'/../vendor/autoload.php';
// Bootstrap Laravel and handle the request...
/** @var Application $app */
$app = require_once __DIR__.'/../bootstrap/app.php';
$app->handleRequest(Request::capture());
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow:
+21
View File
@@ -0,0 +1,21 @@
The MIT License
Copyright © 2010-2023 three.js authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
File diff suppressed because one or more lines are too long
+9
View File
@@ -0,0 +1,9 @@
@import 'tailwindcss';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
}
+1
View File
@@ -0,0 +1 @@
//
+376
View File
@@ -0,0 +1,376 @@
@extends('layouts.app')
@section('title', $isEdit ? __('admin.stores.edit_title') : __('admin.stores.create_title'))
@section('content')
<div style="max-width: 1100px; margin: 0 auto;">
<p style="margin-top:0;"><a href="{{ route('admin.stores.index') }}">← {{ __('Back') }}</a></p>
<h1 style="color:#fff; margin:.5rem 0 1.25rem;">
🏪 {{ $isEdit ? __('admin.stores.edit_title') : __('admin.stores.create_title') }}
</h1>
<form method="POST"
action="{{ $isEdit ? route('admin.stores.update', $store) : route('admin.stores.store') }}">
@csrf
@if ($isEdit) @method('PUT') @endif
<div style="display:grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr); gap:1.25rem; align-items:start;">
{{-- Data form --}}
<div class="card">
<div class="field">
<label for="name">{{ __('Name') }}</label>
<input id="name" type="text" name="name" value="{{ old('name', $store->name) }}" required maxlength="160">
@error('name')<div class="error">{{ $message }}</div>@enderror
</div>
<div class="field">
<label for="type">{{ __('admin.stores.type') }}</label>
<input id="type" type="text" name="type" value="{{ old('type', $store->type) }}" required maxlength="60"
placeholder="bakery, market, hardware, restaurant…">
@error('type')<div class="error">{{ $message }}</div>@enderror
</div>
<div class="field">
<label for="description">{{ __('admin.stores.description') }}</label>
<textarea id="description" name="description" rows="3" maxlength="2000"
style="width:100%; padding:.7rem .85rem; border-radius:.55rem; border:1px solid var(--border); background:#0b1220; color:var(--text); font-size:1rem; font-family:inherit;">{{ old('description', $store->description) }}</textarea>
@error('description')<div class="error">{{ $message }}</div>@enderror
</div>
<div class="field">
<label for="owner_id">{{ __('admin.stores.owner') }}</label>
<select id="owner_id" name="owner_id">
<option value="">{{ __('admin.stores.no_owner') }}</option>
@foreach ($owners as $owner)
<option value="{{ $owner->id }}" @selected((string) old('owner_id', $store->owner_id) === (string) $owner->id)>
{{ $owner->name }} ({{ $owner->email }})
</option>
@endforeach
</select>
@error('owner_id')<div class="error">{{ $message }}</div>@enderror
</div>
<div class="field">
<label for="agent_id">{{ __('admin.stores.agent') }}</label>
<select id="agent_id" name="agent_id">
<option value="">{{ __('admin.stores.no_agent') }}</option>
@foreach ($agents as $agent)
<option value="{{ $agent->id }}" @selected((string) old('agent_id', $store->agent_id) === (string) $agent->id)>
{{ $agent->name }} ({{ $agent->type }})
</option>
@endforeach
</select>
@error('agent_id')<div class="error">{{ $message }}</div>@enderror
</div>
<div class="field" style="display:flex; align-items:center; gap:.55rem;">
<input type="checkbox" id="is_open" name="is_open" value="1"
@checked(old('is_open', $store->is_open ?? true)) style="width:1.1rem; height:1.1rem;">
<label for="is_open" style="margin:0;">{{ __('admin.stores.is_open') }}</label>
</div>
@error('is_open')<div class="error">{{ $message }}</div>@enderror
{{-- Numeric 3D coordinates (kept in sync with the picker) --}}
<fieldset style="border:1px solid var(--border); border-radius:.7rem; padding:1rem; margin:1rem 0 0;">
<legend style="padding:0 .4rem; color:var(--muted); font-size:.85rem;">{{ __('admin.stores.position') }}</legend>
<div style="display:grid; grid-template-columns:repeat(3, 1fr); gap:.75rem;">
<div class="field" style="margin:0;">
<label for="pos_x">{{ __('admin.stores.pos_x') }}</label>
<input id="pos_x" type="number" name="pos_x" step="0.1" required
value="{{ old('pos_x', $store->pos_x ?? 0) }}">
</div>
<div class="field" style="margin:0;">
<label for="pos_y">{{ __('admin.stores.pos_y') }}</label>
<input id="pos_y" type="number" name="pos_y" step="0.1" required
value="{{ old('pos_y', $store->pos_y ?? 0) }}">
</div>
<div class="field" style="margin:0;">
<label for="pos_z">{{ __('admin.stores.pos_z') }}</label>
<input id="pos_z" type="number" name="pos_z" step="0.1" required
value="{{ old('pos_z', $store->pos_z ?? 0) }}">
</div>
</div>
@error('pos_x')<div class="error">{{ $message }}</div>@enderror
@error('pos_y')<div class="error">{{ $message }}</div>@enderror
@error('pos_z')<div class="error">{{ $message }}</div>@enderror
<div style="display:grid; grid-template-columns:repeat(3, 1fr); gap:.75rem; margin-top:.75rem;">
<div class="field" style="margin:0;">
<label for="width">{{ __('admin.stores.width') }}</label>
<input id="width" type="number" name="width" step="0.1" min="0.1"
value="{{ old('width', $store->width ?? 8) }}">
</div>
<div class="field" style="margin:0;">
<label for="height">{{ __('admin.stores.height') }}</label>
<input id="height" type="number" name="height" step="0.1" min="0.1"
value="{{ old('height', $store->height ?? 6) }}">
</div>
<div class="field" style="margin:0;">
<label for="depth">{{ __('admin.stores.depth') }}</label>
<input id="depth" type="number" name="depth" step="0.1" min="0.1"
value="{{ old('depth', $store->depth ?? 8) }}">
</div>
</div>
@error('width')<div class="error">{{ $message }}</div>@enderror
@error('height')<div class="error">{{ $message }}</div>@enderror
@error('depth')<div class="error">{{ $message }}</div>@enderror
</fieldset>
<div style="display:flex; gap:.75rem; margin-top:1.25rem;">
<button type="submit" class="btn btn-primary" style="flex:1; padding:.8rem;">{{ __('Save') }}</button>
<a href="{{ route('admin.stores.index') }}" class="btn" style="padding:.8rem;">{{ __('Cancel') }}</a>
</div>
</div>
{{-- 3D position picker --}}
<div class="card" style="padding:1rem;">
<p style="margin:0 0 .6rem; color:var(--muted); font-size:.85rem;">{{ __('admin.stores.position_hint') }}</p>
<div id="picker"
data-pos-x="{{ old('pos_x', $store->pos_x ?? 0) }}"
data-pos-y="{{ old('pos_y', $store->pos_y ?? 0) }}"
data-pos-z="{{ old('pos_z', $store->pos_z ?? 0) }}"
data-width="{{ old('width', $store->width ?? 8) }}"
data-height="{{ old('height', $store->height ?? 6) }}"
data-depth="{{ old('depth', $store->depth ?? 8) }}"
style="position:relative; width:100%; height:440px; border-radius:.75rem; overflow:hidden; background:#0b1220; border:1px solid var(--border);">
</div>
<p id="picker-readout" style="margin:.6rem 0 0; font-family:ui-monospace, monospace; font-size:.85rem; color:#7dd3fc;"></p>
</div>
</div>
</form>
</div>
@endsection
@push('scripts')
<script type="module">
import * as THREE from '/vendor/three/three.module.min.js';
const host = document.getElementById('picker');
const readout = document.getElementById('picker-readout');
const fX = document.getElementById('pos_x');
const fY = document.getElementById('pos_y');
const fZ = document.getElementById('pos_z');
const fW = document.getElementById('width');
const fH = document.getElementById('height');
const fD = document.getElementById('depth');
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b1220);
scene.fog = new THREE.Fog(0x0b1220, 80, 260);
const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
host.appendChild(renderer.domElement);
// Lights
scene.add(new THREE.AmbientLight(0xffffff, 0.65));
const sun = new THREE.DirectionalLight(0xffffff, 0.9);
sun.position.set(30, 60, 20);
scene.add(sun);
// Ground plane (raycast target)
const groundMat = new THREE.MeshStandardMaterial({ color: 0x14532d, roughness: 1 });
const ground = new THREE.Mesh(new THREE.PlaneGeometry(400, 400), groundMat);
ground.rotation.x = -Math.PI / 2;
ground.name = 'ground';
scene.add(ground);
const grid = new THREE.GridHelper(400, 80, 0x334155, 0x1f2937);
grid.position.y = 0.01;
scene.add(grid);
// Preview box for the store being edited
const boxMat = new THREE.MeshStandardMaterial({ color: 0x0ea5e9, transparent: true, opacity: 0.85 });
const box = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), boxMat);
scene.add(box);
// Outline wireframe for clarity
const wire = new THREE.LineSegments(
new THREE.EdgesGeometry(new THREE.BoxGeometry(1, 1, 1)),
new THREE.LineBasicMaterial({ color: 0xffffff })
);
scene.add(wire);
// Marker pin at origin of the box
const pin = new THREE.Mesh(
new THREE.SphereGeometry(0.5, 16, 16),
new THREE.MeshBasicMaterial({ color: 0xf59e0b })
);
scene.add(pin);
// Existing stores as translucent ghosts (fetched from world data)
async function loadExistingStores() {
try {
const res = await fetch('{{ route('city.data') }}', { headers: { 'Accept': 'application/json' } });
if (!res.ok) return;
const data = await res.json();
const ghostMat = new THREE.MeshStandardMaterial({ color: 0x64748b, transparent: true, opacity: 0.4 });
(data.stores || []).forEach(s => {
const [sx, sy, sz] = s.position;
const [w, h, d] = s.size;
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), ghostMat);
m.position.set(sx, sy + h / 2, sz);
scene.add(m);
});
} catch (e) { /* non-fatal */ }
}
loadExistingStores();
// Camera orbit state
const target = new THREE.Vector3(0, 0, 0);
let radius = 45, theta = Math.PI / 4, phi = Math.PI / 3.2;
function updateCamera() {
const sinPhi = Math.sin(phi);
camera.position.set(
target.x + radius * sinPhi * Math.sin(theta),
target.y + radius * Math.cos(phi),
target.z + radius * sinPhi * Math.cos(theta)
);
camera.lookAt(target);
}
function syncBox() {
const px = parseFloat(fX.value) || 0;
const py = parseFloat(fY.value) || 0;
const pz = parseFloat(fZ.value) || 0;
const w = Math.max(0.1, parseFloat(fW.value) || 1);
const h = Math.max(0.1, parseFloat(fH.value) || 1);
const d = Math.max(0.1, parseFloat(fD.value) || 1);
box.scale.set(w, h, d);
box.position.set(px, py + h / 2, pz);
wire.scale.set(w, h, d);
wire.position.copy(box.position);
pin.position.set(px, py + 0.5, pz);
readout.textContent = `X ${px.toFixed(2)} · Y ${py.toFixed(2)} · Z ${pz.toFixed(2)} | ${w.toFixed(1)}×${h.toFixed(1)}×${d.toFixed(1)}`;
updateCamera();
}
// Sync inputs -> box
[fX, fY, fZ, fW, fH, fD].forEach(el => el.addEventListener('input', syncBox));
// Raycast click on ground to set X/Z
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
function pointerToGround(ev) {
const rect = renderer.domElement.getBoundingClientRect();
mouse.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const hit = raycaster.intersectObject(ground, false)[0];
if (hit) {
fX.value = hit.point.x.toFixed(2);
fZ.value = hit.point.z.toFixed(2);
syncBox();
}
}
// Orbit controls (drag rotate, wheel zoom, right-drag pan)
let dragging = false, panning = false, lastX = 0, lastY = 0, moved = false;
renderer.domElement.addEventListener('pointerdown', (e) => {
moved = false;
lastX = e.clientX; lastY = e.clientY;
if (e.button === 2 || e.shiftKey) { panning = true; }
else { dragging = true; }
renderer.domElement.setPointerCapture(e.pointerId);
});
renderer.domElement.addEventListener('pointermove', (e) => {
if (!dragging && !panning) return;
const dx = e.clientX - lastX, dy = e.clientY - lastY;
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) moved = true;
lastX = e.clientX; lastY = e.clientY;
if (dragging) {
theta -= dx * 0.005;
phi = Math.min(Math.PI * 0.49, Math.max(0.12, phi - dy * 0.005));
updateCamera();
} else if (panning) {
const right = new THREE.Vector3().setFromMatrixColumn(camera.matrix, 0);
const up = new THREE.Vector3().setFromMatrixColumn(camera.matrix, 1);
target.addScaledVector(right, -dx * radius * 0.0016);
target.addScaledVector(up, dy * radius * 0.0016);
updateCamera();
}
});
renderer.domElement.addEventListener('pointerup', (e) => {
// Treat as click (place marker) only if it wasn't a drag/pan.
if (moved) {
dragging = panning = false;
return;
}
const wasPanning = panning;
dragging = panning = false;
if (!wasPanning && e.button === 0) pointerToGround(e);
});
renderer.domElement.addEventListener('wheel', (e) => {
e.preventDefault();
radius = Math.min(240, Math.max(8, radius + e.deltaY * 0.05));
updateCamera();
}, { passive: false });
renderer.domElement.addEventListener('contextmenu', (e) => e.preventDefault());
// Basic touch support: 1 finger orbit, 2 finger pinch zoom
let touchDist = 0;
renderer.domElement.addEventListener('touchstart', (e) => {
if (e.touches.length === 2) {
touchDist = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY
);
}
}, { passive: true });
renderer.domElement.addEventListener('touchmove', (e) => {
if (e.touches.length === 2) {
const d = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY
);
radius = Math.min(240, Math.max(8, radius + (touchDist - d) * 0.15));
touchDist = d;
updateCamera();
e.preventDefault();
}
}, { passive: false });
// Resize
function resize() {
const w = host.clientWidth, h = host.clientHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h, false);
updateCamera();
}
new ResizeObserver(resize).observe(host);
resize();
syncBox();
(function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
})();
</script>
@endpush
@push('styles')
<style>
@media (max-width: 860px) {
form > div[style*="grid-template-columns"] { grid-template-columns: 1fr !important; }
#picker { height: 340px !important; }
}
</style>
@endpush
@@ -0,0 +1,80 @@
@extends('layouts.app')
@section('title', __('admin.stores.title'))
@section('content')
<div style="max-width: 1100px; margin: 0 auto;">
<div style="display:flex; justify-content:space-between; align-items:center; gap:1rem; flex-wrap:wrap; margin-bottom:1.25rem;">
<h1 style="margin:0; color:#fff;">🏪 {{ __('admin.stores.title') }}</h1>
<a href="{{ route('admin.stores.create') }}" class="btn btn-primary">+ {{ __('admin.stores.new') }}</a>
</div>
<form method="GET" action="{{ route('admin.stores.index') }}" class="card" style="margin-bottom:1.25rem; padding:1rem;">
<div style="display:flex; gap:.75rem; flex-wrap:wrap; align-items:flex-end;">
<div class="field" style="margin:0; flex:1; min-width:180px;">
<label for="search">{{ __('Search') }}</label>
<input id="search" type="text" name="search" value="{{ $currentSearch }}" placeholder="{{ __('Search') }}">
</div>
<div class="field" style="margin:0; min-width:160px;">
<label for="type">{{ __('admin.stores.type') }}</label>
<select id="type" name="type">
<option value="">{{ __('Cancel') }}</option>
@foreach ($types as $t)
<option value="{{ $t }}" @selected($currentType === $t)>{{ $t }}</option>
@endforeach
</select>
</div>
<button type="submit" class="btn btn-primary">{{ __('Search') }}</button>
</div>
</form>
@if ($stores->isEmpty())
<div class="card" style="text-align:center; color:var(--muted);">{{ __('admin.stores.empty') }}</div>
@else
<div style="display:grid; grid-template-columns:repeat(auto-fill, minmax(300px, 1fr)); gap:1rem;">
@foreach ($stores as $store)
<div class="card" style="padding:1.15rem;">
<div style="display:flex; justify-content:space-between; gap:.5rem; align-items:flex-start;">
<div>
<h3 style="margin:0 0 .35rem; color:#fff; font-size:1.05rem;">{{ $store->name }}</h3>
<span class="badge {{ $store->is_open ? 'badge-user' : 'badge-admin' }}">{{ $store->type }}</span>
@if (! $store->is_open)
<span class="badge" style="background:rgba(239,68,68,.2); color:#fecaca;">{{ __('city.closed') }}</span>
@endif
</div>
</div>
<p style="color:var(--muted); font-size:.8rem; margin:.75rem 0 .35rem;">{{ __('admin.stores.coords') }}:</p>
<p style="margin:0; font-family:ui-monospace, monospace; font-size:.85rem; color:#7dd3fc;">
X {{ number_format($store->pos_x, 2) }} · Y {{ number_format($store->pos_y, 2) }} · Z {{ number_format($store->pos_z, 2) }}
</p>
<p style="margin:.25rem 0 0; font-family:ui-monospace, monospace; font-size:.8rem; color:var(--muted);">
{{ __('admin.stores.width') }} {{ number_format($store->width, 1) }} ·
{{ __('admin.stores.height') }} {{ number_format($store->height, 1) }} ·
{{ __('admin.stores.depth') }} {{ number_format($store->depth, 1) }}
</p>
<p style="color:var(--muted); font-size:.85rem; margin:.75rem 0 0;">
{{ __('admin.stores.owner') }}: {{ $store->owner?->name ?? __('admin.stores.no_owner') }}<br>
{{ __('admin.stores.agent') }}: {{ $store->agent?->name ?? __('admin.stores.no_agent') }}
</p>
<div style="display:flex; gap:.5rem; margin-top:1rem;">
<a href="{{ route('admin.stores.edit', $store) }}" class="btn" style="flex:1; text-align:center;">{{ __('Edit') }}</a>
<form method="POST" action="{{ route('admin.stores.destroy', $store) }}" style="margin:0;"
onsubmit="return confirm('{{ __('admin.stores.confirm_delete') }}')">
@csrf
@method('DELETE')
<button type="submit" class="btn" style="color:var(--danger); border-color:rgba(239,68,68,.5);">{{ __('Delete') }}</button>
</form>
</div>
</div>
@endforeach
</div>
<div style="margin-top:1.5rem; display:flex; justify-content:center; gap:.5rem; flex-wrap:wrap;">
{{ $stores->links() }}
</div>
@endif
</div>
@endsection
+62
View File
@@ -0,0 +1,62 @@
@extends('layouts.app')
@section('title', __('Login'))
@section('content')
<div style="max-width: 420px; margin: 0 auto;">
<div class="card">
<h2 style="margin-top:0; color:#fff;">{{ __('Login') }}</h2>
<form method="POST" action="{{ route('login') }}">
@csrf
<div class="field">
<label for="email">{{ __('Email') }}</label>
<input id="email" type="email" name="email" value="{{ old('email') }}" required autofocus autocomplete="username">
@error('email')<div class="error">{{ $message }}</div>@enderror
</div>
<div class="field">
<label for="password">{{ __('Password') }}</label>
<div class="pw-wrap">
<input id="password" type="password" name="password" required autocomplete="current-password">
<button type="button" class="eye-toggle" data-eye="password" aria-label="Mostrar senha">👁️</button>
</div>
@error('password')<div class="error">{{ $message }}</div>@enderror
</div>
<div class="field" style="display:flex; align-items:center; gap:.5rem;">
<input type="checkbox" id="remember" name="remember" value="1" @checked(old('remember'))>
<label for="remember" style="margin:0;">{{ __('Remember Me') }}</label>
</div>
<div class="field">
<label for="captcha">{{ $captcha['question'] }}</label>
<input id="captcha" type="number" name="captcha" required inputmode="numeric">
@error('captcha')<div class="error">{{ $message }}</div>@enderror
</div>
<button type="submit" class="btn btn-primary" style="width:100%; padding:.8rem;">{{ __('Login') }}</button>
</form>
<p style="margin-top:1rem; font-size:.9rem; color:var(--muted);">
{{ __('welcome.no_account') }} <a href="{{ route('register') }}">{{ __('Register') }}</a>
</p>
</div>
</div>
@endsection
@push('scripts')
<script>
// Eye icon toggles password visibility on the login form.
document.querySelectorAll('.eye-toggle').forEach(function (btn) {
btn.addEventListener('click', function () {
var input = document.getElementById(btn.dataset.eye);
if (!input) return;
var showing = input.type === 'text';
input.type = showing ? 'password' : 'text';
btn.textContent = showing ? '👁️' : '🙈';
});
});
</script>
@endpush
+80
View File
@@ -0,0 +1,80 @@
@extends('layouts.app')
@section('title', __('Register'))
@section('content')
<div style="max-width: 460px; margin: 0 auto;">
<div class="card">
<h2 style="margin-top:0; color:#fff;">{{ __('Register') }}</h2>
<form method="POST" action="{{ route('register') }}">
@csrf
<div class="field">
<label for="name">{{ __('Name') }}</label>
<input id="name" type="text" name="name" value="{{ old('name') }}" required autofocus maxlength="120">
@error('name')<div class="error">{{ $message }}</div>@enderror
</div>
<div class="field">
<label for="email">{{ __('Email') }}</label>
<input id="email" type="email" name="email" value="{{ old('email') }}" required autocomplete="username">
@error('email')<div class="error">{{ $message }}</div>@enderror
</div>
<div class="field">
<label for="password">{{ __('Password') }}</label>
<div class="pw-wrap">
<input id="password" type="password" name="password" required autocomplete="new-password">
<button type="button" class="eye-toggle" data-eye="password" aria-label="Mostrar senha">👁️</button>
</div>
@error('password')<div class="error">{{ $message }}</div>@enderror
</div>
<div class="field">
<label for="password_confirmation">{{ __('Password Confirmation') }}</label>
<div class="pw-wrap">
<input id="password_confirmation" type="password" name="password_confirmation" required autocomplete="new-password">
<button type="button" class="eye-toggle" data-eye="password_confirmation" aria-label="Confirmar senha">👁️</button>
</div>
</div>
<div class="field">
<label for="locale">Idioma / Language / Idioma</label>
<select id="locale" name="locale">
<option value="pt_BR" @selected(old('locale', 'pt_BR') === 'pt_BR')>Português (Brasil)</option>
<option value="en" @selected(old('locale') === 'en')>English</option>
<option value="es" @selected(old('locale') === 'es')>Español</option>
</select>
</div>
<div class="field">
<label for="captcha">{{ $captcha['question'] }}</label>
<input id="captcha" type="number" name="captcha" required inputmode="numeric">
@error('captcha')<div class="error">{{ $message }}</div>@enderror
</div>
<button type="submit" class="btn btn-primary" style="width:100%; padding:.8rem;">{{ __('Register') }}</button>
</form>
<p style="margin-top:1rem; font-size:.9rem; color:var(--muted);">
{{ __('welcome.has_account') }} <a href="{{ route('login') }}">{{ __('Login') }}</a>
</p>
</div>
</div>
@endsection
@push('scripts')
<script>
// Eye icons toggle password visibility on the registration form.
document.querySelectorAll('.eye-toggle').forEach(function (btn) {
btn.addEventListener('click', function () {
var input = document.getElementById(btn.dataset.eye);
if (!input) return;
var showing = input.type === 'text';
input.type = showing ? 'password' : 'text';
btn.textContent = showing ? '👁️' : '🙈';
});
});
</script>
@endpush
+995
View File
@@ -0,0 +1,995 @@
@extends('layouts.app')
@section('title', __('city.title'))
@section('content')
<div style="max-width: 1280px; margin: 0 auto;">
<div style="display:flex; justify-content:space-between; align-items:center; gap:1rem; flex-wrap:wrap; margin-bottom:1rem;">
<h1 style="margin:0; color:#fff;">🏙️ {{ __('city.title') }}</h1>
<div style="display:flex; gap:.6rem; align-items:center; flex-wrap:wrap;">
<span id="stats" style="color:var(--muted); font-size:.9rem;"></span>
<button type="button" id="reload" class="btn">{{ __('city.refresh') }}</button>
</div>
</div>
<div id="scene"
style="position:relative; width:100%; height:min(78vh, 720px); border-radius:1rem; overflow:hidden;
background:#0b1220; border:1px solid var(--border); box-shadow:0 20px 50px rgba(0,0,0,.45);">
<div id="loading" style="position:absolute; inset:0; display:grid; place-items:center; color:var(--muted);">
{{ __('city.loading') }}
</div>
<div id="error" style="position:absolute; inset:0; display:none; place-items:center; color:#fecaca;">
{{ __('city.error') }}
</div>
{{-- Click-to-start overlay (pointer lock gate) --}}
<div id="start-gate"
style="position:absolute; inset:0; display:grid; place-items:center; background:rgba(11,18,32,.75);
cursor:pointer; z-index:10; text-align:center;">
<div>
<div style="font-size:3rem; margin-bottom:.5rem;">🎮</div>
<p style="color:#fff; font-size:1.1rem; margin:0 0 .3rem;">{{ __('city.hint_fps') }}</p>
</div>
</div>
{{-- HUD bottom-left: POSIÇÃO panel (like example images) --}}
<div id="hud-pos"
style="position:absolute; left:.6rem; bottom:.6rem; z-index:5; display:none;
background:rgba(10,15,28,.88); border:1px solid rgba(148,163,184,.25); border-radius:.4rem;
padding:.5rem .7rem; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:.72rem;
color:#94a3b8; line-height:1.55; min-width:170px; pointer-events:none;">
<div style="color:#e2e8f0; font-weight:700; letter-spacing:.06em; font-size:.65rem; margin-bottom:.2rem;">{{ __('city.position') }}</div>
<div id="pos-xyz">x:0.0 y:1.7 z:0.0</div>
<div id="pos-ground">—</div>
<div id="pos-tris">—</div>
</div>
{{-- HUD bottom-right: selected / hint --}}
<div id="hud-hint"
style="position:absolute; right:.6rem; bottom:.6rem; z-index:5; display:flex; gap:.5rem;
pointer-events:none; flex-wrap:wrap; justify-content:flex-end;">
<span id="selected" style="background:rgba(15,23,42,.85); border:1px solid var(--border); border-radius:.5rem;
padding:.35rem .6rem; font-size:.8rem; color:#7dd3fc; display:none;"></span>
<span id="hint" style="background:rgba(15,23,42,.85); border:1px solid var(--border); border-radius:.5rem;
padding:.35rem .6rem; font-size:.75rem; color:var(--muted);">{{ __('city.hint') }}</span>
</div>
{{-- Chat panel (bottom-right, like example images) --}}
<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;">
<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);">
<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;">
<div id="chat-name" style="font-weight:700; font-size:.85rem; color:#fff; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;"></div>
<div id="chat-status" style="font-size:.7rem; color:var(--muted);"></div>
</div>
</div>
<div style="display:flex; gap:.3rem;">
<span id="chat-time" style="font-size:.65rem; color:var(--muted); align-self:center;"></span>
<button type="button" id="chat-close"
style="background:transparent; border:none; color:var(--muted); cursor:pointer; font-size:1rem; padding:0 .2rem;"
aria-label="Close">×</button>
</div>
</div>
<div id="chat-messages"
style="padding:.6rem .7rem; overflow-y:auto; max-height:260px; display:flex; flex-direction:column;
gap:.5rem; font-size:.78rem; line-height:1.5;"></div>
<form id="chat-form"
style="display:flex; gap:.4rem; padding:.5rem .6rem; border-top:1px solid var(--border); background:rgba(11,18,32,.7);">
<input id="chat-input" type="text" maxlength="500" required
placeholder="{{ __('city.chat_placeholder') }}"
style="flex:1; min-width:0; padding:.45rem .6rem; border-radius:.4rem; border:1px solid var(--border);
background:#0b1220; color:var(--text); font-size:.8rem;">
<button type="submit" class="btn btn-primary" style="padding:.4rem .7rem; font-size:.75rem;">
{{ __('city.chat_send') }}
</button>
</form>
</div>
</div>
</div>
@endsection
@push('styles')
<style>
#scene canvas { display: block; touch-action: none; }
#chat-messages::-webkit-scrollbar { width: 6px; }
#chat-messages::-webkit-scrollbar-thumb { background: #334155; border-radius: 3px; }
.chat-bubble {
padding:.4rem .6rem; border-radius:.5rem; max-width:92%; word-break:break-word;
}
.chat-bubble.user {
background:rgba(14,165,233,.22); border:1px solid rgba(14,165,233,.4); color:#bae6fd; align-self:flex-end;
}
.chat-bubble.npc {
background:rgba(51,65,85,.55); border:1px solid rgba(148,163,184,.2); color:#e2e8f0; align-self:flex-start;
}
.chat-bubble.meta {
background:transparent; border:none; color:var(--muted); font-size:.7rem; align-self:center;
}
@media (max-width: 640px) {
#scene { height: 62vh !important; }
#hint { display: none; }
#chat-panel { width: calc(100% - 1.2rem); right: .6rem; left: .6rem; }
}
</style>
@endpush
@push('scripts')
<script type="module">
import * as THREE from '/vendor/three/three.module.min.js';
const host = document.getElementById('scene');
const loadingEl = document.getElementById('loading');
const errorEl = document.getElementById('error');
const startGate = document.getElementById('start-gate');
const statsEl = document.getElementById('stats');
const selectedEl = document.getElementById('selected');
const reloadBtn = document.getElementById('reload');
const posPanel = document.getElementById('hud-pos');
const posXYZ = document.getElementById('pos-xyz');
const posGround = document.getElementById('pos-ground');
const posTris = document.getElementById('pos-tris');
const dataUrl = @json(route('city.data'));
const csrf = @json(csrf_token());
const i18n = {
stores: @json(__('city.stores_count')),
agents: @json(__('city.agents_count')),
open: @json(__('city.open')),
closed: @json(__('city.closed')),
selected: @json(__('city.selected')),
position: @json(__('city.position')),
onGround: @json(__('city.on_ground')),
tris: @json(__('city.triangles')),
chatThinking: @json(__('city.chat_thinking')),
chatResponding: @json(__('city.chat_responding')),
chatWith: @json(__('city.chat_with')),
chatSend: @json(__('city.chat_send')),
chatError: @json(__('city.chat_error')),
};
// ------------------------------------------------------------------
// Scene setup — daytime sky, fog, lighting like the example images
// ------------------------------------------------------------------
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x87ceeb); // sky blue
scene.fog = new THREE.Fog(0x87ceeb, 120, 600);
const camera = new THREE.PerspectiveCamera(72, 1, 0.1, 2000);
camera.position.set(0, 1.7, 0); // eye height 1.7m
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
host.appendChild(renderer.domElement);
// Lights
scene.add(new THREE.HemisphereLight(0xbfdfff, 0x556b2f, 0.55));
scene.add(new THREE.AmbientLight(0xffffff, 0.35));
const sun = new THREE.DirectionalLight(0xfff2d9, 1.15);
sun.position.set(80, 140, 60);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.left = -200;
sun.shadow.camera.right = 200;
sun.shadow.camera.top = 200;
sun.shadow.camera.bottom = -200;
sun.shadow.camera.far = 500;
scene.add(sun);
// ------------------------------------------------------------------
// Texture helpers (canvas-generated — no external assets required)
// ------------------------------------------------------------------
function canvasTexture(size, draw, repeatX = 1, repeatY = 1) {
const c = document.createElement('canvas');
c.width = size; c.height = size;
const ctx = c.getContext('2d');
draw(ctx, size);
const tex = new THREE.CanvasTexture(c);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(repeatX, repeatY);
tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
return tex;
}
// Asphalt with subtle noise + double white center lines
const asphaltTex = canvasTexture(512, (ctx, s) => {
ctx.fillStyle = '#1a1a1e';
ctx.fillRect(0, 0, s, s);
// noise
for (let i = 0; i < 9000; i++) {
const v = 20 + Math.random() * 30;
ctx.fillStyle = `rgb(${v},${v},${v + 4})`;
ctx.fillRect(Math.random() * s, Math.random() * s, 1, 1);
}
// double white lines down the center
ctx.fillStyle = '#e8e8e8';
ctx.fillRect(s * 0.44, 0, 4, s);
ctx.fillRect(s * 0.54, 0, 4, s);
// dashed side lines
ctx.fillStyle = '#d0d0d0';
for (let y = 0; y < s; y += 48) {
ctx.fillRect(s * 0.08, y, 4, 26);
ctx.fillRect(s * 0.90, y, 4, 26);
}
});
// Sidewalk — light grey tiles
const sidewalkTex = canvasTexture(256, (ctx, s) => {
ctx.fillStyle = '#b8bcc4';
ctx.fillRect(0, 0, s, s);
const tile = 64;
ctx.strokeStyle = '#9aa0a8';
ctx.lineWidth = 2;
for (let i = 0; i <= s; i += tile) {
ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i, s); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0, i); ctx.lineTo(s, i); ctx.stroke();
}
// subtle stains
for (let i = 0; i < 40; i++) {
ctx.fillStyle = `rgba(120,125,130,${Math.random() * 0.15})`;
ctx.beginPath();
ctx.arc(Math.random() * s, Math.random() * s, Math.random() * 20, 0, Math.PI * 2);
ctx.fill();
}
}, 4, 1);
// Building facade — mustard/yellow with dark window grid
function facadeTexture(baseColor = '#c9a84c', floorCount = 6, windowsPerFloor = 4) {
const w = 512, h = 512;
const c = document.createElement('canvas');
c.width = w; c.height = h;
const ctx = c.getContext('2d');
ctx.fillStyle = baseColor;
ctx.fillRect(0, 0, w, h);
const floorH = h / floorCount;
const winW = w / (windowsPerFloor * 2 + 1);
const winH = floorH * 0.5;
for (let f = 0; f < floorCount; f++) {
// dark horizontal band between floors
ctx.fillStyle = 'rgba(30,30,30,0.85)';
ctx.fillRect(0, f * floorH + floorH - 4, w, 4);
for (let wi = 0; wi < windowsPerFloor; wi++) {
const x = (wi * 2 + 1) * winW * 0.5;
const wx = wi * (w / windowsPerFloor) + w / windowsPerFloor * 0.25;
const wy = f * floorH + floorH * 0.18;
const ww = (w / windowsPerFloor) * 0.5;
const wh = winH;
// window frame (dark)
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(wx - 3, wy - 3, ww + 6, wh + 6);
// glass
ctx.fillStyle = Math.random() > 0.5 ? '#7fb3d5' : '#5a8ab0';
ctx.fillRect(wx, wy, ww, wh);
// reflection
ctx.fillStyle = 'rgba(255,255,255,0.15)';
ctx.fillRect(wx, wy, ww * 0.4, wh * 0.4);
}
}
// vertical pilasters
ctx.fillStyle = 'rgba(40,40,40,0.7)';
ctx.fillRect(0, 0, 6, h);
ctx.fillRect(w - 6, 0, 6, h);
const tex = new THREE.CanvasTexture(c);
tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
return tex;
}
// Door texture for ground floor
function doorTexture() {
const c = document.createElement('canvas');
c.width = 128; c.height = 256;
const ctx = c.getContext('2d');
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(0, 0, 128, 256);
ctx.fillStyle = '#3a2f1a';
ctx.fillRect(8, 8, 112, 240);
ctx.fillStyle = '#c9a84c';
ctx.beginPath(); ctx.arc(96, 130, 6, 0, Math.PI * 2); ctx.fill();
const tex = new THREE.CanvasTexture(c);
return tex;
}
// ------------------------------------------------------------------
// City layout from server payload
// ------------------------------------------------------------------
let cityData = null;
const colliders = []; // AABB boxes {minX,maxX,minY,maxY,minZ,maxZ}
const worldGroup = new THREE.Group();
scene.add(worldGroup);
const storeMeshes = [];
const agentMeshes = [];
function clearGroup(g) {
while (g.children.length) {
const c = g.children.pop();
c.traverse?.(o => { o.geometry?.dispose?.(); o.material?.map?.dispose?.(); o.material?.dispose?.(); });
}
}
function addCollider(cx, cy, cz, w, h, d) {
colliders.push({
minX: cx - w / 2, maxX: cx + w / 2,
minY: cy, maxY: cy + h,
minZ: cz - d / 2, maxZ: cz + d / 2,
});
}
function buildCity(data) {
clearGroup(worldGroup);
colliders.length = 0;
storeMeshes.length = 0;
agentMeshes.length = 0;
const layout = data.layout || { roads: [], blocks: [] };
const stores = data.stores || [];
const agents = data.agents || [];
// ---- Ground ----
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(2000, 2000),
new THREE.MeshStandardMaterial({ color: 0x555f4a, roughness: 1 })
);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -0.05;
ground.receiveShadow = true;
worldGroup.add(ground);
// ---- Roads (asphalt strips with center lines) ----
const roadMat = new THREE.MeshStandardMaterial({ map: asphaltTex, roughness: 0.9, metalness: 0.05 });
const walkMat = new THREE.MeshStandardMaterial({ map: sidewalkTex.clone(), roughness: 0.95 });
walkMat.map.repeat.set(4, 1);
layout.roads.forEach(r => {
const [x1,, z1] = r.from;
const [x2,, z2] = r.to;
const w = r.width;
const len = Math.hypot(x2 - x1, z2 - z1);
const mx = (x1 + x2) / 2, mz = (z1 + z2) / 2;
// Asphalt
const geo = r.axis === 'z'
? new THREE.PlaneGeometry(w, len)
: new THREE.PlaneGeometry(len, w);
const mesh = new THREE.Mesh(geo, roadMat);
mesh.rotation.x = -Math.PI / 2;
mesh.position.set(mx, 0.01, mz);
mesh.receiveShadow = true;
worldGroup.add(mesh);
// Sidewalks on both sides (2m each)
const swWidth = 2;
const offset = w / 2 + swWidth / 2;
[-1, 1].forEach(side => {
const sg = r.axis === 'z'
? new THREE.PlaneGeometry(swWidth, len)
: new THREE.PlaneGeometry(len, swWidth);
const sm = new THREE.Mesh(sg, walkMat);
sm.rotation.x = -Math.PI / 2;
if (r.axis === 'z') {
sm.position.set(mx + side * offset, 0.12, mz);
} else {
sm.position.set(mx, 0.12, mz + side * offset);
}
sm.receiveShadow = true;
worldGroup.add(sm);
});
});
// ---- Sidewalk curb edges (thin boxes for depth) ----
layout.roads.forEach(r => {
const [x1,, z1] = r.from;
const [x2,, z2] = r.to;
const w = r.width;
const len = Math.hypot(x2 - x1, z2 - z1);
const mx = (x1 + x2) / 2, mz = (z1 + z2) / 2;
const offset = w / 2 + 0.1;
[-1, 1].forEach(side => {
const curbGeo = r.axis === 'z'
? new THREE.BoxGeometry(0.2, 0.14, len)
: new THREE.BoxGeometry(len, 0.14, 0.2);
const curb = new THREE.Mesh(curbGeo, new THREE.MeshStandardMaterial({ color: 0x888c94, roughness: 0.9 }));
if (r.axis === 'z') {
curb.position.set(mx + side * offset, 0.07, mz);
} else {
curb.position.set(mx, 0.07, mz + side * offset);
}
worldGroup.add(curb);
});
});
// ---- Street lamps every ~30 units along roads ----
const lampPoleMat = new THREE.MeshStandardMaterial({ color: 0x2a2a2e, roughness: 0.7, metalness: 0.5 });
const lampHeadMat = new THREE.MeshStandardMaterial({ color: 0xfff8dc, emissive: 0x444422, emissiveIntensity: 0.4 });
layout.roads.forEach(r => {
const [x1,, z1] = r.from;
const [x2,, z2] = r.to;
const len = Math.hypot(x2 - x1, z2 - z1);
const w = r.width;
const spacing = 30;
const count = Math.max(1, Math.floor(len / spacing));
for (let i = 0; i <= count; i++) {
const t = i / count;
const lx = x1 + (x2 - x1) * t;
const lz = z1 + (z2 - z1) * t;
// offset to sidewalk edge
const ox = r.axis === 'z' ? w / 2 + 1.4 : 0;
const oz = r.axis === 'x' ? w / 2 + 1.4 : 0;
const side = (i % 2 === 0) ? 1 : -1;
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.1, 4.5, 6), lampPoleMat);
pole.position.set(lx + ox * side, 2.25, lz + oz * side);
pole.castShadow = true;
worldGroup.add(pole);
// arm
const arm = new THREE.Mesh(new THREE.BoxGeometry(ox ? 1.4 : 0.15, 0.1, oz ? 1.4 : 0.15), lampPoleMat);
arm.position.set(lx + ox * side * 0.7, 4.5, lz + oz * side * 0.7);
worldGroup.add(arm);
// head
const head = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.22, 0.5), lampHeadMat);
head.position.set(lx + ox * side * 1.3, 4.4, lz + oz * side * 1.3);
worldGroup.add(head);
}
});
// ---- Stores as buildings with facades ----
const palette = ['#c9a84c', '#b8763a', '#8a7a5a', '#9a9a8a', '#a08060', '#7a8a6a'];
stores.forEach((s, idx) => {
const [x, y, z] = s.position;
const [w, h, d] = s.size;
const color = palette[idx % palette.length];
const floors = Math.max(2, Math.round(h / 3));
const facadeTex = facadeTexture(color, floors, Math.max(2, Math.round(w / 3)));
const geo = new THREE.BoxGeometry(w, h, d);
const mats = [
new THREE.MeshStandardMaterial({ map: facadeTex, roughness: 0.85 }), // +x
new THREE.MeshStandardMaterial({ map: facadeTex, roughness: 0.85 }), // -x
new THREE.MeshStandardMaterial({ color: 0x3a3a3a, roughness: 0.9 }), // +y roof
new THREE.MeshStandardMaterial({ color: 0x2a2a2a, roughness: 0.9 }), // -y
new THREE.MeshStandardMaterial({ map: facadeTex, roughness: 0.85 }), // +z
new THREE.MeshStandardMaterial({ map: facadeTex, roughness: 0.85 }), // -z
];
const mesh = new THREE.Mesh(geo, mats);
mesh.position.set(x, y + h / 2, z);
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.userData = { store: s };
worldGroup.add(mesh);
storeMeshes.push(mesh);
addCollider(x, y, z, w, h, d);
// Roof cap
const roof = new THREE.Mesh(
new THREE.BoxGeometry(w + 0.4, 0.35, d + 0.4),
new THREE.MeshStandardMaterial({ color: 0x1e1e1e, roughness: 0.9 })
);
roof.position.set(x, y + h + 0.17, z);
roof.castShadow = true;
worldGroup.add(roof);
// Door on ground floor facing +Z
const door = new THREE.Mesh(
new THREE.BoxGeometry(1.4, 2.4, 0.1),
new THREE.MeshStandardMaterial({ map: doorTexture(), roughness: 0.7 })
);
door.position.set(x, y + 1.2, z + d / 2 + 0.05);
worldGroup.add(door);
// Ground marker ring (kept for selection feedback)
const ring = new THREE.Mesh(
new THREE.RingGeometry(Math.max(w, d) / 2 + 0.6, Math.max(w, d) / 2 + 1.1, 32),
new THREE.MeshBasicMaterial({ color: 0x0ea5e9, side: THREE.DoubleSide, transparent: true, opacity: 0.5 })
);
ring.rotation.x = -Math.PI / 2;
ring.position.set(x, 0.15, z);
worldGroup.add(ring);
});
// ---- Procedural filler buildings in empty blocks ----
const occupied = stores.map(s => ({ x: s.position[0], z: s.position[2] }));
(layout.blocks || []).forEach((b, bi) => {
const cx = (b.min[0] + b.max[0]) / 2;
const cz = (b.min[2] + b.max[2]) / 2;
const nearStore = occupied.some(o => Math.abs(o.x - cx) < 25 && Math.abs(o.z - cz) < 25);
if (nearStore) return;
const bw = 12 + (bi * 7) % 14;
const bd = 12 + (bi * 11) % 14;
const bh = 6 + (bi * 13) % 20;
const color = palette[(bi + 2) % palette.length];
const floors = Math.max(2, Math.round(bh / 3));
const tex = facadeTexture(color, floors, Math.max(2, Math.round(bw / 3)));
const geo = new THREE.BoxGeometry(bw, bh, bd);
const mats = Array.from({ length: 6 }, () => new THREE.MeshStandardMaterial({ map: tex, roughness: 0.85 }));
mats[2] = new THREE.MeshStandardMaterial({ color: 0x333333, roughness: 0.9 }); // roof
mats[3] = new THREE.MeshStandardMaterial({ color: 0x222222, roughness: 0.9 });
const m = new THREE.Mesh(geo, mats);
m.position.set(cx, bh / 2, cz);
m.castShadow = true;
m.receiveShadow = true;
worldGroup.add(m);
addCollider(cx, 0, cz, bw, bh, bd);
// small roof
const roof = new THREE.Mesh(
new THREE.BoxGeometry(bw + 0.3, 0.3, bd + 0.3),
new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.9 })
);
roof.position.set(cx, bh + 0.15, cz);
worldGroup.add(roof);
});
// ---- Agents as procedural humanoid characters ----
agents.forEach(a => {
const [x, y, z] = a.position;
const char = buildHumanoid(a);
char.position.set(x, y, z);
char.rotation.y = a.rotation_y || 0;
worldGroup.add(char);
agentMeshes.push(char);
});
statsEl.textContent = `${stores.length} ${i18n.stores} · ${agents.length} ${i18n.agents}`;
}
// ------------------------------------------------------------------
// Humanoid character builder (procedural, no external GLTF required)
// ------------------------------------------------------------------
function buildHumanoid(agent) {
const g = new THREE.Group();
const type = agent.type || 'pedestrian';
// Per-type palette
const configs = {
pedestrian: { skin: 0xf1c27d, hair: 0x3b2314, shirt: 0x556b8a, pants: 0x2c2c2c, scale: 1 },
shopkeeper: { skin: 0xe0ac69, hair: 0x1a1a1a, shirt: 0xf5e6d3, pants: 0x4a3728, scale: 1 },
mayor: { skin: 0xe0ac69, hair: 0x555555, shirt: 0x1a1a2e, pants: 0x111111, scale: 1.05 },
traffic_officer: { skin: 0xd2956b, hair: 0x1a1a1a, shirt: 0x2a4a2a, pants: 0x1a1a1a, scale: 1 },
};
const cfg = configs[type] || configs.pedestrian;
const s = cfg.scale;
const skinMat = new THREE.MeshStandardMaterial({ color: cfg.skin, roughness: 0.7 });
const hairMat = new THREE.MeshStandardMaterial({ color: cfg.hair, roughness: 0.6 });
const shirtMat = new THREE.MeshStandardMaterial({ color: cfg.shirt, roughness: 0.8 });
const pantsMat = new THREE.MeshStandardMaterial({ color: cfg.pants, roughness: 0.85 });
const shoeMat = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.7 });
// Head
const head = new THREE.Mesh(new THREE.SphereGeometry(0.28 * s, 12, 12), skinMat);
head.position.y = 1.62 * s;
head.castShadow = true;
g.add(head);
// Hair cap
const hair = new THREE.Mesh(new THREE.SphereGeometry(0.30 * s, 12, 8, 0, Math.PI * 2, 0, Math.PI * 0.55), hairMat);
hair.position.y = 1.65 * s;
hair.castShadow = true;
g.add(hair);
// Torso
const torso = new THREE.Mesh(new THREE.CapsuleGeometry(0.24 * s, 0.55 * s, 4, 8), shirtMat);
torso.position.y = 1.12 * s;
torso.castShadow = true;
g.add(torso);
// Arms
[-1, 1].forEach(side => {
const arm = new THREE.Mesh(new THREE.CapsuleGeometry(0.07 * s, 0.5 * s, 4, 6), shirtMat);
arm.position.set(side * 0.32 * s, 1.1 * s, 0);
arm.rotation.z = side * 0.18;
arm.castShadow = true;
g.add(arm);
// Hand
const hand = new THREE.Mesh(new THREE.SphereGeometry(0.08 * s, 8, 8), skinMat);
hand.position.set(side * 0.34 * s, 0.82 * s, 0);
g.add(hand);
});
// Legs
[-1, 1].forEach(side => {
const leg = new THREE.Mesh(new THREE.CapsuleGeometry(0.1 * s, 0.6 * s, 4, 6), pantsMat);
leg.position.set(side * 0.13 * s, 0.42 * s, 0);
leg.castShadow = true;
g.add(leg);
// Shoe
const shoe = new THREE.Mesh(new THREE.BoxGeometry(0.14 * s, 0.08 * s, 0.24 * s), shoeMat);
shoe.position.set(side * 0.13 * s, 0.04 * s, 0.05 * s);
shoe.castShadow = true;
g.add(shoe);
});
// Name label (billboard)
const label = makeLabel(agent.name, agent.status);
label.position.y = 2.15 * s;
g.add(label);
g.userData = { agent, label };
return g;
}
function makeLabel(name, status) {
const c = document.createElement('canvas');
c.width = 256; c.height = 64;
const ctx = c.getContext('2d');
ctx.fillStyle = 'rgba(10,15,28,0.85)';
ctx.roundRect ? ctx.roundRect(0, 0, 256, 64, 12) : ctx.rect(0, 0, 256, 64);
ctx.fill();
ctx.fillStyle = '#e2e8f0';
ctx.font = 'bold 22px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(name, 128, 30);
const dotColor = status === 'thinking' ? '#f59e0b' : status === 'error' ? '#ef4444' : '#22c55e';
ctx.fillStyle = dotColor;
ctx.beginPath(); ctx.arc(40, 48, 6, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#94a3b8';
ctx.font = '14px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(status || 'active', 56, 53);
const tex = new THREE.CanvasTexture(c);
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, depthTest: false }));
sprite.scale.set(3.2, 0.8, 1);
return sprite;
}
// ------------------------------------------------------------------
// CameraController — first person WASD + pointer lock + AABB collision
// ------------------------------------------------------------------
const player = {
pos: new THREE.Vector3(0, 1.7, 25),
vel: new THREE.Vector3(),
yaw: 0, // horizontal rotation
pitch: 0, // vertical look
speed: 6,
radius: 0.35,
eyeHeight: 1.7,
onGround: true,
};
const keys = {};
let locked = false;
renderer.domElement.addEventListener('click', () => {
if (!locked) renderer.domElement.requestPointerLock();
});
startGate.addEventListener('click', () => {
renderer.domElement.requestPointerLock();
});
document.addEventListener('pointerlockchange', () => {
locked = document.pointerLockElement === renderer.domElement;
startGate.style.display = locked ? 'none' : 'grid';
posPanel.style.display = locked ? 'block' : 'none';
});
document.addEventListener('mousemove', (e) => {
if (!locked) return;
player.yaw -= e.movementX * 0.0022;
player.pitch -= e.movementY * 0.0022;
player.pitch = Math.max(-Math.PI / 2 + 0.1, Math.min(Math.PI / 2 - 0.1, player.pitch));
});
document.addEventListener('keydown', (e) => { keys[e.code] = true; });
document.addEventListener('keyup', (e) => { keys[e.code] = false; });
function playerAABB(px, py, pz) {
const r = player.radius;
return {
minX: px - r, maxX: px + r,
minY: py - 1.5, maxY: py + 0.3,
minZ: pz - r, maxZ: pz + r,
};
}
function overlaps(a, b) {
return a.minX < b.maxX && a.maxX > b.minX &&
a.minZ < b.maxZ && a.maxZ > b.minZ &&
a.minY < b.maxY && a.maxY > b.minY;
}
function resolveCollision(px, py, pz) {
const box = playerAABB(px, py, pz);
let hit = false;
for (const c of colliders) {
if (overlaps(box, c)) { hit = true; break; }
}
return hit;
}
function updatePlayer(dt) {
if (!locked) return;
const forward = new THREE.Vector3(-Math.sin(player.yaw), 0, -Math.cos(player.yaw));
const right = new THREE.Vector3(Math.cos(player.yaw), 0, -Math.sin(player.yaw));
const wish = new THREE.Vector3();
if (keys['KeyW'] || keys['ArrowUp']) wish.add(forward);
if (keys['KeyS'] || keys['ArrowDown']) wish.sub(forward);
if (keys['KeyA'] || keys['ArrowLeft']) wish.sub(right);
if (keys['KeyD'] || keys['ArrowRight']) wish.add(right);
if (wish.lengthSq() > 0) wish.normalize();
const speed = keys['ShiftLeft'] ? player.speed * 1.6 : player.speed;
const step = wish.multiplyScalar(speed * dt);
// Axis-separated collision resolution
const tryX = player.pos.x + step.x;
if (!resolveCollision(tryX, player.pos.y, player.pos.z)) player.pos.x = tryX;
const tryZ = player.pos.z + step.z;
if (!resolveCollision(player.pos.x, player.pos.y, tryZ)) player.pos.z = tryZ;
// Simple gravity + ground
player.vel.y -= 18 * dt;
player.pos.y += player.vel.y * dt;
if (player.pos.y <= player.eyeHeight) {
player.pos.y = player.eyeHeight;
player.vel.y = 0;
player.onGround = true;
}
if (keys['Space'] && player.onGround) {
player.vel.y = 6;
player.onGround = false;
}
// World bounds
player.pos.x = Math.max(-400, Math.min(400, player.pos.x));
player.pos.z = Math.max(-400, Math.min(400, player.pos.z));
}
function updateCameraFromPlayer() {
camera.position.copy(player.pos);
const dir = new THREE.Vector3(
-Math.sin(player.yaw) * Math.cos(player.pitch),
Math.sin(player.pitch),
-Math.cos(player.yaw) * Math.cos(player.pitch)
);
camera.lookAt(camera.position.clone().add(dir));
}
// ------------------------------------------------------------------
// Click-to-interact: select store or open chat with NPC
// ------------------------------------------------------------------
const ray = new THREE.Raycaster();
const ndc = new THREE.Vector2();
let pointerDown = null;
renderer.domElement.addEventListener('pointerdown', (e) => {
pointerDown = { x: e.clientX, y: e.clientY, t: Date.now() };
});
renderer.domElement.addEventListener('pointerup', (e) => {
if (!pointerDown) return;
const dx = Math.abs(e.clientX - pointerDown.x);
const dy = Math.abs(e.clientY - pointerDown.y);
const dt = Date.now() - pointerDown.t;
pointerDown = null;
if (dx > 6 || dy > 6 || dt > 400) return; // drag, not click
const rect = renderer.domElement.getBoundingClientRect();
ndc.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
ndc.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
ray.setFromCamera(ndc, camera);
// Check agents first
const agentHits = ray.intersectObjects(agentMeshes, true);
if (agentHits.length) {
let obj = agentHits[0].object;
while (obj && !obj.userData?.agent) obj = obj.parent;
if (obj?.userData?.agent) {
openChat(obj.userData.agent);
return;
}
}
// Then stores
const storeHits = ray.intersectObjects(storeMeshes, false);
if (storeHits.length) {
const s = storeHits[0].object.userData.store;
selectedEl.style.display = 'inline-block';
selectedEl.textContent = `${i18n.selected}: ${s.name} · ${s.is_open ? i18n.open : i18n.closed}`;
} else {
selectedEl.style.display = 'none';
}
});
// ------------------------------------------------------------------
// ChatPanel — SSE stream + POST message
// ------------------------------------------------------------------
const chatPanel = document.getElementById('chat-panel');
const chatHeader = document.getElementById('chat-header');
const chatDot = document.getElementById('chat-dot');
const chatName = document.getElementById('chat-name');
const chatStatus = document.getElementById('chat-status');
const chatTime = document.getElementById('chat-time');
const chatClose = document.getElementById('chat-close');
const chatMessages = document.getElementById('chat-messages');
const chatForm = document.getElementById('chat-form');
const chatInput = document.getElementById('chat-input');
let currentAgent = null;
let eventSource = null;
function addBubble(text, who) {
const div = document.createElement('div');
div.className = `chat-bubble ${who}`;
div.textContent = text;
chatMessages.appendChild(div);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function setStatus(status) {
if (!currentAgent) return;
currentAgent.status = status;
const map = { thinking: ['#f59e0b', i18n.chatThinking], responding: ['#f59e0b', i18n.chatResponding], active: ['#22c55e', 'online'], error: ['#ef4444', 'error'] };
const [color, label] = map[status] || map.active;
chatDot.style.background = color;
chatStatus.textContent = label;
// update sprite label
const mesh = agentMeshes.find(m => m.userData?.agent?.id === currentAgent.id);
if (mesh) {
const old = mesh.userData.label;
if (old) {
mesh.remove(old);
old.material.map?.dispose();
}
const l = makeLabel(currentAgent.name, status);
l.position.y = old ? old.position.y : 2.15;
mesh.add(l);
mesh.userData.label = l;
}
}
function openChat(agent) {
currentAgent = agent;
chatPanel.style.display = 'flex';
chatName.textContent = agent.name;
chatTime.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
chatMessages.innerHTML = '';
// Replay history
const history = agent.last_decision || [];
history.forEach(m => {
if (m.role === 'user') addBubble(m.content, 'user');
else if (m.role === 'assistant') addBubble(m.content, 'npc');
});
setStatus(agent.status || 'active');
// Open SSE stream
if (eventSource) { eventSource.close(); eventSource = null; }
eventSource = new EventSource(`/city/chat/${agent.id}/stream`);
eventSource.addEventListener('message', (e) => {
try {
const payload = JSON.parse(e.data);
if (payload.type === 'thinking') {
setStatus('thinking');
addBubble(i18n.chatThinking, 'meta');
} else if (payload.type === 'reply') {
setStatus('active');
addBubble(payload.reply, 'npc');
chatTime.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (payload.type === 'error') {
setStatus('error');
addBubble(payload.reply || i18n.chatError, 'meta');
}
} catch (_) { /* ignore parse errors */ }
});
eventSource.onerror = () => { /* auto-retry handled by EventSource */ };
}
function closeChat() {
chatPanel.style.display = 'none';
currentAgent = null;
if (eventSource) { eventSource.close(); eventSource = null; }
}
chatClose.addEventListener('click', closeChat);
chatForm.addEventListener('submit', async (e) => {
e.preventDefault();
const msg = chatInput.value.trim();
if (!msg || !currentAgent) return;
chatInput.value = '';
addBubble(msg, 'user');
setStatus('thinking');
try {
await fetch(`/city/chat/${currentAgent.id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'Accept': 'application/json' },
body: JSON.stringify({ message: msg }),
});
} catch (_) {
addBubble(i18n.chatError, 'meta');
}
});
// ------------------------------------------------------------------
// HUD position update
// ------------------------------------------------------------------
function updateHUD() {
const g = player.pos.y <= player.eyeHeight + 0.01;
posXYZ.textContent = `x:${player.pos.x.toFixed(1)} y:${player.pos.y.toFixed(1)} z:${player.pos.z.toFixed(1)}`;
posGround.textContent = g
? `${i18n.onGround} · ${player.pos.y.toFixed(1)}m`
: `airborne · ${player.pos.y.toFixed(1)}m`;
posTris.textContent = `${colliders.length} ${i18n.tris}`;
}
// ------------------------------------------------------------------
// Resize + animation loop
// ------------------------------------------------------------------
function resize() {
const w = host.clientWidth, h = host.clientHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h, false);
}
new ResizeObserver(resize).observe(host);
resize();
let lastT = performance.now();
function animate() {
requestAnimationFrame(animate);
const now = performance.now();
const dt = Math.min(0.05, (now - lastT) / 1000);
lastT = now;
updatePlayer(dt);
updateCameraFromPlayer();
updateHUD();
renderer.render(scene, camera);
}
animate();
// ------------------------------------------------------------------
// Data loading
// ------------------------------------------------------------------
async function loadCity() {
loadingEl.style.display = 'grid';
errorEl.style.display = 'none';
try {
const res = await fetch(dataUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-store' });
if (!res.ok) throw new Error('HTTP ' + res.status);
cityData = await res.json();
buildCity(cityData);
// Seed agent last_decision from server
(cityData.agents || []).forEach(a => {
// placeholder — history is fetched lazily on openChat via SSE
});
loadingEl.style.display = 'none';
} catch (err) {
console.error(err);
loadingEl.style.display = 'none';
errorEl.style.display = 'grid';
}
}
reloadBtn.addEventListener('click', loadCity);
loadCity();
</script>
@endpush

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