Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c299a4ae21 | ||
|
|
a620f83dd8 | ||
|
|
18778b3d26 | ||
|
|
fa25dd7307 | ||
|
|
090b530481 |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 218 KiB After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 282 KiB After Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
Before Width: | Height: | Size: 223 KiB After Width: | Height: | Size: 223 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 203 KiB After Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 235 KiB After Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 261 KiB After Width: | Height: | Size: 261 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
@@ -0,0 +1 @@
|
|||||||
|
56096
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
23ba6490cfcbd10fdd1ca204a8a024301fb00ee50214d88fa21f31a360d74c23
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<div style="text-align:center;margin-bottom:1.5rem">
|
||||||
|
<h2>🏙️ City10 — Visão da Cidade 3D</h2>
|
||||||
|
<p class="subtitle">Fase 2: Transformar o grid atual em uma cidade viva com ruas, árvores, prédios variados e atmosfera</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>O que já existe hoje</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Chão verde com grid</li>
|
||||||
|
<li>Caixas coloridas representando lojas</li>
|
||||||
|
<li>Cápsulas amarelas para agentes</li>
|
||||||
|
<li>Órbita de câmera, zoom, clique para selecionar</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>O que vamos adicionar</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Ruas asfaltadas</strong> com calçadas e marcações de trânsito</li>
|
||||||
|
<li><strong>Quadras/blocos</strong> com prédios de fachada variada</li>
|
||||||
|
<li><strong>Árvores, postes, bancos, floreiras</strong> — objetos de cena</li>
|
||||||
|
<li><strong>Prédios residenciais/comerciais</strong> de preenchimento entre as lojas</li>
|
||||||
|
<li><strong>Ciclo dia/noite</strong> com iluminação dinâmica</li>
|
||||||
|
<li><strong>Mini-mapa</strong> no canto da tela</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr style="border-color:var(--border);margin:1.5rem 0">
|
||||||
|
|
||||||
|
<h2 style="text-align:center">Layout da Cidade</h2>
|
||||||
|
<p class="subtitle">Três abordagens para organizar ruas e quarteirões</p>
|
||||||
|
|
||||||
|
<div class="options" style="margin-top:1rem">
|
||||||
|
<div class="option" data-choice="grid" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">A</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>Grid Clássico</h3>
|
||||||
|
<p>Ruas formando quadras regulares. Lojas em posições fixas nas esquinas e frentes. Fácil de navegar, previsível, urbano.</p>
|
||||||
|
<p style="color:var(--muted);font-size:.85rem;margin-top:.5rem">✅ Mais simples de gerar, orientação clara</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="option" data-choice="organic" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">B</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>Orgânico</h3>
|
||||||
|
<p>Ruas curvas que conectam as lojas existentes. Praça central como ponto focal. Visual mais natural e europeu.</p>
|
||||||
|
<p style="color:var(--muted);font-size:.85rem;margin-top:.5rem">✅ Mais bonito, ❌ Mais complexo de gerar</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="option" data-choice="hybrid" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">C</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>Misto (Recomendado)</h3>
|
||||||
|
<p>Grid base com quarteirões de tamanhos variados, praça central, algumas vias diagonais. Combina organização com personalidade.</p>
|
||||||
|
<p style="color:var(--muted);font-size:.85rem;margin-top:.5rem">✅ 👍 Melhor equilíbrio entre beleza e implementação</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="margin-top:1.5rem;text-align:center;color:var(--muted)">Clique na sua opção preferida e me diga no terminal se quer ajustar algo.</p>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<div style="text-align:center;margin-bottom:1.5rem">
|
||||||
|
<h2>📐 Design — Cidade 3D Fase 2</h2>
|
||||||
|
<p class="subtitle">Arquitetura, componentes, fluxo de dados e testes — revise seção por seção</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<span class="label">1. Arquitetura</span>
|
||||||
|
<p>A cena Three.js passa de <strong>órbita isométrica</strong> para <strong>exploração a pé</strong>. O backend continua o mesmo (Sanctum + <code>/city/data</code>), mas o payload ganha: ruas, prédios gerados, e um streaming de mensagens de chat via <strong>SSE</strong> (Server-Sent Events) por agente — sem dependência nova.</p>
|
||||||
|
<pre style="background:#0b1220;border:1px solid var(--border);border-radius:.5rem;padding:.75rem;overflow:auto;font-size:.8rem">Frontend (Three.js)
|
||||||
|
├── CameraController — WASD + mouse, colisão AABB
|
||||||
|
├── CityGenerator — ruas, quadras, prédios de preenchimento
|
||||||
|
├── CharacterFactory — carrega modelos GLTF por tipo de agente
|
||||||
|
├── HudOverlay — painel POSIÇÃO + minimapa
|
||||||
|
└── ChatPanel — fluxo SSE /city/chat/{agent}
|
||||||
|
|
||||||
|
Backend (Laravel)
|
||||||
|
├── CityViewerController::data() — stores + agents + city layout
|
||||||
|
├── ChatController — SSE endpoint por agente
|
||||||
|
└── AgentThinkJob (queue) — chama OpenRouter, persiste last_decision</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<span class="label">2. Componentes</span>
|
||||||
|
<table style="width:100%;border-collapse:collapse;font-size:.9rem">
|
||||||
|
<tr style="border-bottom:1px solid var(--border)"><td style="padding:.5rem"><strong>CameraController</strong></td><td>Pointer-lock, WASD, gravidade simples, raycast contra colisores AABB de prédios</td></tr>
|
||||||
|
<tr style="border-bottom:1px solid var(--border)"><td style="padding:.5rem"><strong>CityGenerator</strong></td><td>Rua central + quadras laterais a partir das posições das lojas; prédios de preenchimento procedural (seed determinística)</td></tr>
|
||||||
|
<tr style="border-bottom:1px solid var(--border)"><td style="padding:.5rem"><strong>StreetMesh</strong></td><td>Asfalto (canvas texture com faixas), calçada de ladrilho, postes a cada N metros</td></tr>
|
||||||
|
<tr style="border-bottom:1px solid var(--border)"><td style="padding:.5rem"><strong>CharacterFactory</strong></td><td>GLTF por <code>agent.type</code>; fallback: cápsula texturizada. Animação Idle/Walk/Run</td></tr>
|
||||||
|
<tr style="border-bottom:1px solid var(--border)"><td style="padding:.5rem"><strong>HudOverlay</strong></td><td>Painel POSIÇÃO (x,y,z, triângulos) + minimapa 2D no canto</td></tr>
|
||||||
|
<tr><td style="padding:.5rem"><strong>ChatPanel</strong></td><td>Abre ao clicar no NPC; mostra nome, status (pensando/respondendo), mensagens; envia via POST e recebe via SSE</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<span class="label">3. Fluxo de dados</span>
|
||||||
|
<ol>
|
||||||
|
<li>Viewport carrega → <code>GET /city/data</code> → lojas + agentes + layout de ruas</li>
|
||||||
|
<li><code>CityGenerator</code> monta ruas e prédios; prédios de loja ganham fachada custom</li>
|
||||||
|
<li>Usuário anda (WASD) → colisão por AABB → câmera segue</li>
|
||||||
|
<li>Clica em NPC → <code>ChatPanel</code> abre → <code>GET /city/chat/{agent}/stream</code> (SSE)</li>
|
||||||
|
<li>Envia mensagem → <code>POST /city/chat/{agent}</code> → job enfileirado → OpenRouter → persiste <code>last_decision</code> → SSE emite resposta</li>
|
||||||
|
<li>HUD recalcula posição a cada frame</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<span class="label">4. Tratamento de erros</span>
|
||||||
|
<ul>
|
||||||
|
<li>SSE cai → reconexão automática com backoff, indicador "reconectando"</li>
|
||||||
|
<li>OpenRouter falha → <code>error_count++</code>, backoff exponencial, status vira <code>error</code> no painel</li>
|
||||||
|
<li>GLTF ausente → fallback silencioso para cápsula (nunca quebra a cena)</li>
|
||||||
|
<li>Colisão detecta NaN → reposiciona no spawn</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<span class="label">5. Testes</span>
|
||||||
|
<ul>
|
||||||
|
<li><strong>PHPUnit</strong>: <code>CityViewerController::data()</code> retorna layout; <code>ChatController</code> valida input e rate-limit; job persiste <code>last_decision</code></li>
|
||||||
|
<li><strong>JS</strong>: <code>CameraController</code> (colisão, WASD), <code>CityGenerator</code> (determinístico com seed), reconexão SSE</li>
|
||||||
|
<li><strong>Manual</strong>: navegar, colidir, conversar com NPC, verificar HUD</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr style="border-color:var(--border);margin:1.5rem 0">
|
||||||
|
|
||||||
|
<p style="text-align:center"><strong>Esse design está correto? Aprovo para escrever a especificação formal.</strong></p>
|
||||||
|
<div class="options">
|
||||||
|
<div class="option" data-choice="approve" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">✅</div>
|
||||||
|
<div class="content"><h3>Aprovar design</h3><p>Escrever spec → revisão → plano de implementação</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="option" data-choice="revise" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">✏️</div>
|
||||||
|
<div class="content"><h3>Revisar</h3><p>Quero mudar algo antes de seguir</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
After Width: | Height: | Size: 288 KiB |
|
After Width: | Height: | Size: 309 KiB |
|
After Width: | Height: | Size: 320 KiB |
|
After Width: | Height: | Size: 413 KiB |
@@ -0,0 +1,85 @@
|
|||||||
|
<div style="text-align:center;margin-bottom:1.5rem">
|
||||||
|
<h2>✅ Entendi o Visual Alvo</h2>
|
||||||
|
<p class="subtitle">Baseado nas 4 telas de exemplo — a diferença para o que existe hoje é enorme</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="split">
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">Hoje (existe)</div>
|
||||||
|
<div class="mockup-body" style="text-align:center;padding:1.5rem">
|
||||||
|
<div style="background:#14532d;border:1px solid #334155;border-radius:.5rem;height:100px;display:flex;align-items:center;justify-content:center;gap:8px">
|
||||||
|
<div style="width:20px;height:20px;background:#0ea5e9;border-radius:3px"></div>
|
||||||
|
<div style="width:25px;height:30px;background:#8b5cf6;border-radius:3px"></div>
|
||||||
|
<div style="width:15px;height:15px;background:#f59e0b;border-radius:999px"></div>
|
||||||
|
</div>
|
||||||
|
<p style="margin-top:.75rem;font-size:.85rem">Vista isométrica · caixas coloridas · cápsulas amarelas</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">Alvo (imagens de exemplo)</div>
|
||||||
|
<div class="mockup-body" style="text-align:center;padding:1.5rem">
|
||||||
|
<div style="background:linear-gradient(#87ceeb 0%, #87ceeb 35%, #333 36%, #333 100%);border:1px solid #555;border-radius:.5rem;height:100px;position:relative;overflow:hidden">
|
||||||
|
<div style="position:absolute;left:0;top:0;bottom:0;width:35%;background:linear-gradient(#d4a843,#8b6914);border-right:3px solid #222"></div>
|
||||||
|
<div style="position:absolute;right:0;top:0;bottom:0;width:35%;background:linear-gradient(#c49a3a,#7a5a12);border-left:3px solid #222"></div>
|
||||||
|
<div style="position:absolute;left:50%;bottom:0;transform:translateX(-50%);width:10px;height:100%;background:#222"></div>
|
||||||
|
<div style="position:absolute;left:30%;bottom:40%;width:15px;height:15px;background:#555;border-radius:50%"></div>
|
||||||
|
<div style="position:absolute;right:30%;bottom:40%;width:15px;height:15px;background:#555;border-radius:50%"></div>
|
||||||
|
</div>
|
||||||
|
<p style="margin-top:.75rem;font-size:.85rem">1ª pessoa · asfalto + faixas · prédios fachadas · personagens 3D</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr style="border-color:var(--border);margin:2rem 0">
|
||||||
|
|
||||||
|
<h3 style="text-align:center">Gap — o que precisa ser construído</h3>
|
||||||
|
|
||||||
|
<div class="options" style="margin-top:1rem" data-multiselect>
|
||||||
|
<div class="option" data-choice="fps-camera" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">1</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>Câmera 1ª pessoa / 3ª pessoa</h3>
|
||||||
|
<p>Substituir a órbita por WASD + mouse, com colisão contra prédios e pedestres</p>
|
||||||
|
<p style="color:var(--muted);font-size:.85rem;margin-top:.4rem">Bloqueante: sem isso, não tem a experiência das imagens</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="option" data-choice="street" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">2</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>Rua realista</h3>
|
||||||
|
<p>Asfalto escuro, faixas brancas, calçada de ladrilho, postes, semáforos</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="option" data-choice="buildings" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">3</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>Prédios com fachadas</h3>
|
||||||
|
<p>Janelas, portas, estrutura escura, fachadas amarelo/mostarda e marrom — não caixas planas</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="option" data-choice="characters" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">4</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>Personagens 3D estilo anime</h3>
|
||||||
|
<p>Modelos detalhados com cabelo, roupas, animação de andar — substituir cápsulas amarelas</p>
|
||||||
|
<p style="color:var(--muted);font-size:.85rem;margin-top:.4rem">Requer modelo 3D (GLTF) ou compra/asset pack</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="option" data-choice="hud" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">5</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>HUD — Painel POSIÇÃO</h3>
|
||||||
|
<p>Coordenadas x,y,z, no chão, triângulos de colisão no canto inferior esquerdo</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="option" data-choice="chat" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">6</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>Chat com NPCs (OpenRouter)</h3>
|
||||||
|
<p>Painel de diálogo com nome, status (pensando/respondendo), conversa em tempo real</p>
|
||||||
|
<p style="color:var(--muted);font-size:.85rem;margin-top:.4rem">Integração direta com agentes existentes no banco</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="margin-top:1.5rem;text-align:center;color:var(--muted)">Marque os itens que confirma estar no escopo da Fase 2 e me diga se algo está faltando ou sobrando.</p>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<div style="text-align:center;margin-bottom:1rem">
|
||||||
|
<h2>📷 Imagens de Referência</h2>
|
||||||
|
<p class="subtitle">As quatro telas de exemplo na raiz do projeto — me ajude a entender o que cada uma mostra</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cards">
|
||||||
|
<div class="card" data-choice="1" onclick="toggleSelect(this)">
|
||||||
|
<h3>Tela 1</h3>
|
||||||
|
<img src="/files/exemplo1.png" style="max-width:100%;border-radius:.6rem;border:1px solid var(--border)">
|
||||||
|
<p style="color:var(--muted);font-size:.85rem">Descreva: o que esta tela mostra?</p>
|
||||||
|
</div>
|
||||||
|
<div class="card" data-choice="2" onclick="toggleSelect(this)">
|
||||||
|
<h3>Tela 2</h3>
|
||||||
|
<img src="/files/exemplo2.png" style="max-width:100%;border-radius:.6rem;border:1px solid var(--border)">
|
||||||
|
<p style="color:var(--muted);font-size:.85rem">Descreva: o que esta tela mostra?</p>
|
||||||
|
</div>
|
||||||
|
<div class="card" data-choice="3" onclick="toggleSelect(this)">
|
||||||
|
<h3>Tela 3</h3>
|
||||||
|
<img src="/files/exemplo3.png" style="max-width:100%;border-radius:.6rem;border:1px solid var(--border)">
|
||||||
|
<p style="color:var(--muted);font-size:.85rem">Descreva: o que esta tela mostra?</p>
|
||||||
|
</div>
|
||||||
|
<div class="card" data-choice="4" onclick="toggleSelect(this)">
|
||||||
|
<h3>Tela 4</h3>
|
||||||
|
<img src="/files/exemplo4.png" style="max-width:100%;border-radius:.6rem;border:1px solid var(--border)">
|
||||||
|
<p style="color:var(--muted);font-size:.85rem">Descreva: o que esta tela mostra?</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="margin-top:1.5rem;text-align:center;color:var(--muted);font-weight:600">Por favor, no terminal, me diga o que cada imagem mostra para eu entender o visual que você quer atingir.</p>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"reason":"stop-server.sh","timestamp":1792266689}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Web\Admin\AdminWorldController;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints a one-shot world metrics report for operators and CI logs.
|
||||||
|
*/
|
||||||
|
class CityReport extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'city:report';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Print world metrics (citizens, economy, market, missions)';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$metrics = app(AdminWorldController::class)->metrics();
|
||||||
|
|
||||||
|
$this->info('City10 world report');
|
||||||
|
$this->line('Users: '.$metrics['users']['total']
|
||||||
|
.' (pending '.$metrics['users']['pending']
|
||||||
|
.', active '.$metrics['users']['active']
|
||||||
|
.', banned '.$metrics['users']['banned'].')');
|
||||||
|
$this->line('Cities: '.$metrics['cities']);
|
||||||
|
$this->line('Characters: '.$metrics['characters']['total']
|
||||||
|
.' (online '.$metrics['characters']['online']
|
||||||
|
.', hospital '.$metrics['characters']['hospitalized']
|
||||||
|
.', avg health '.$metrics['characters']['avg_health']
|
||||||
|
.', money '.$metrics['characters']['total_money'].')');
|
||||||
|
$this->line('World: '.$metrics['world']['stores'].' stores, '
|
||||||
|
.$metrics['world']['houses_complete'].' houses, '
|
||||||
|
.$metrics['world']['jobs'].' jobs, '
|
||||||
|
.$metrics['world']['messages'].' messages, '
|
||||||
|
.$metrics['world']['transaction_volume'].' coins traded');
|
||||||
|
$this->line('Market: '.$metrics['market']['listings'].' active listings');
|
||||||
|
$this->line('Missions: '.$metrics['missions']['active'].' active, '
|
||||||
|
.$metrics['missions']['completed'].' completed');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace App\Console\Commands;
|
|||||||
|
|
||||||
use App\Models\Building;
|
use App\Models\Building;
|
||||||
use App\Models\Character;
|
use App\Models\Character;
|
||||||
|
use App\Support\MissionTracker;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
@@ -102,6 +103,12 @@ class CityTick extends Command
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Temporary bus/train rides expire after ride_until.
|
||||||
|
if ($character->ride_kind !== null && $character->ride_until !== null
|
||||||
|
&& $character->ride_until->isPast()) {
|
||||||
|
$character->forceFill(['ride_kind' => null, 'ride_until' => null])->save();
|
||||||
|
}
|
||||||
|
|
||||||
$elapsedMin = 1.0;
|
$elapsedMin = 1.0;
|
||||||
if ($character->last_tick_at !== null) {
|
if ($character->last_tick_at !== null) {
|
||||||
$elapsedMin = max(0.5, $character->last_tick_at->diffInMinutes($now));
|
$elapsedMin = max(0.5, $character->last_tick_at->diffInMinutes($now));
|
||||||
@@ -124,7 +131,9 @@ class CityTick extends Command
|
|||||||
case Character::STATE_WORKING:
|
case Character::STATE_WORKING:
|
||||||
if ($character->job_id !== null) {
|
if ($character->job_id !== null) {
|
||||||
$salary = (float) ($character->job?->salary ?? 0);
|
$salary = (float) ($character->job?->salary ?? 0);
|
||||||
$character->money = round((float) $character->money + ($salary / 30) * $elapsedMin, 2);
|
// Education boosts take-home pay up to +50% at 100% education.
|
||||||
|
$bonus = 1 + (min(100, (int) $character->education) / 200);
|
||||||
|
$character->money = round((float) $character->money + ($salary * $bonus / 30) * $elapsedMin, 2);
|
||||||
}
|
}
|
||||||
$character->energy = max(0, (int) $character->energy - (int) round(1.2 * $elapsedMin));
|
$character->energy = max(0, (int) $character->energy - (int) round(1.2 * $elapsedMin));
|
||||||
break;
|
break;
|
||||||
@@ -132,6 +141,14 @@ class CityTick extends Command
|
|||||||
case Character::STATE_STUDYING:
|
case Character::STATE_STUDYING:
|
||||||
$character->education = min(100, (int) $character->education + (int) round(0.5 * $elapsedMin));
|
$character->education = min(100, (int) $character->education + (int) round(0.5 * $elapsedMin));
|
||||||
$character->energy = max(0, (int) $character->energy - (int) round(0.8 * $elapsedMin));
|
$character->energy = max(0, (int) $character->energy - (int) round(0.8 * $elapsedMin));
|
||||||
|
MissionTracker::sync($character, 'education');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Character::STATE_EXERCISING:
|
||||||
|
$character->energy = max(0, (int) $character->energy - (int) round(1.0 * $elapsedMin));
|
||||||
|
if ((int) $character->energy <= 5) {
|
||||||
|
$character->state = Character::STATE_AWAKE;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -162,6 +179,13 @@ class CityTick extends Command
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Offline exercise sessions end after the next tick so NPCs recover.
|
||||||
|
if ($character->state === Character::STATE_EXERCISING) {
|
||||||
|
$character->state = Character::STATE_AWAKE;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ($character->energy < 20 && $character->house_id !== null) {
|
if ($character->energy < 20 && $character->house_id !== null) {
|
||||||
$character->state = Character::STATE_SLEEPING;
|
$character->state = Character::STATE_SLEEPING;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Building;
|
||||||
|
use App\Models\Character;
|
||||||
|
use App\Models\CharacterMessage;
|
||||||
|
use App\Models\City;
|
||||||
|
use App\Models\Job;
|
||||||
|
use App\Models\MarketListing;
|
||||||
|
use App\Models\Mission;
|
||||||
|
use App\Models\Store;
|
||||||
|
use App\Models\Transaction;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserConsent;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Administrator world dashboard and metrics.
|
||||||
|
*
|
||||||
|
* Aggregates live city health: citizens, economy, constructions, missions
|
||||||
|
* and marketplace activity so operators can steer the persistent world.
|
||||||
|
*/
|
||||||
|
class AdminWorldController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* HTML dashboard for the admin area.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
abort_unless($request->user()?->isAdmin(), 403);
|
||||||
|
|
||||||
|
return view('admin.world.index', [
|
||||||
|
'metrics' => $this->metrics(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON metrics (also used by the report command).
|
||||||
|
*/
|
||||||
|
public function metrics(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'users' => [
|
||||||
|
'total' => User::query()->count(),
|
||||||
|
'pending' => User::query()->where('status', User::STATUS_PENDING)->count(),
|
||||||
|
'active' => User::query()->where('status', User::STATUS_ACTIVE)->count(),
|
||||||
|
'banned' => User::query()->where('status', User::STATUS_BANNED)->count(),
|
||||||
|
'consents' => UserConsent::query()->count(),
|
||||||
|
],
|
||||||
|
'cities' => City::query()->count(),
|
||||||
|
'characters' => [
|
||||||
|
'total' => Character::query()->count(),
|
||||||
|
'online' => Character::query()->where('is_online', true)->count(),
|
||||||
|
'hospitalized' => Character::query()->where('state', Character::STATE_HOSPITAL)->count(),
|
||||||
|
'avg_health' => (int) round(Character::query()->avg('health') ?? 0),
|
||||||
|
'avg_education' => (int) round(Character::query()->avg('education') ?? 0),
|
||||||
|
'total_money' => round((float) Character::query()->sum('money'), 2),
|
||||||
|
],
|
||||||
|
'world' => [
|
||||||
|
'stores' => Store::query()->count(),
|
||||||
|
'buildings' => Building::query()->count(),
|
||||||
|
'houses_complete' => Building::query()->where('type', 'house')->where('is_complete', true)->count(),
|
||||||
|
'jobs' => Job::query()->count(),
|
||||||
|
'messages' => CharacterMessage::query()->count(),
|
||||||
|
'transactions' => Transaction::query()->count(),
|
||||||
|
'transaction_volume' => round((float) Transaction::query()->sum('amount'), 2),
|
||||||
|
],
|
||||||
|
'market' => [
|
||||||
|
'listings' => MarketListing::query()->where('is_active', true)->count(),
|
||||||
|
],
|
||||||
|
'missions' => [
|
||||||
|
'active' => Mission::query()->where('is_active', true)->count(),
|
||||||
|
'completed' => DB::table('character_missions')->where('is_completed', true)->count(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Character;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Character appearance customization.
|
||||||
|
*/
|
||||||
|
class CharacterCustomizeController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Show current appearance slots.
|
||||||
|
*/
|
||||||
|
public function show(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$me = Character::ensureForUser($request->user());
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'name' => $me->name,
|
||||||
|
'skin' => (int) $me->skin,
|
||||||
|
'hair' => (int) $me->hair,
|
||||||
|
'shirt' => (int) $me->shirt,
|
||||||
|
'face' => (int) $me->face,
|
||||||
|
'beard' => (int) $me->beard,
|
||||||
|
'accessory' => (int) $me->accessory,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist appearance choices (1-based slots within safe ranges).
|
||||||
|
*/
|
||||||
|
public function update(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$me = Character::ensureForUser($request->user());
|
||||||
|
$data = $request->validate([
|
||||||
|
'skin' => ['required', 'integer', 'min:1', 'max:4'],
|
||||||
|
'hair' => ['required', 'integer', 'min:1', 'max:4'],
|
||||||
|
'shirt' => ['required', 'integer', 'min:1', 'max:5'],
|
||||||
|
'face' => ['required', 'integer', 'min:1', 'max:3'],
|
||||||
|
'beard' => ['required', 'integer', 'min:0', 'max:2'],
|
||||||
|
'accessory' => ['required', 'integer', 'min:0', 'max:2'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$me->forceFill($data)->save();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => __('city.customize_saved'),
|
||||||
|
'appearance' => $data,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ use App\Models\City;
|
|||||||
use App\Models\InventoryItem;
|
use App\Models\InventoryItem;
|
||||||
use App\Models\Job;
|
use App\Models\Job;
|
||||||
use App\Models\Transaction;
|
use App\Models\Transaction;
|
||||||
|
use App\Support\CityPlazas;
|
||||||
|
use App\Support\MissionTracker;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -97,14 +99,152 @@ class CityActionController extends Controller
|
|||||||
'sell' => $this->sell($request, $character),
|
'sell' => $this->sell($request, $character),
|
||||||
'build' => $this->build($request, $character),
|
'build' => $this->build($request, $character),
|
||||||
'apply-job' => $this->applyJob($request, $character),
|
'apply-job' => $this->applyJob($request, $character),
|
||||||
|
'exercise' => $this->exercise($request, $character),
|
||||||
|
'stop-exercise' => $this->stopExercise($character),
|
||||||
|
'ride' => $this->ride($request, $character),
|
||||||
default => response()->json(['message' => __('city.action_unknown')], 422),
|
default => response()->json(['message' => __('city.action_unknown')], 422),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Board a nearby bus stop or train for a short ride.
|
||||||
|
*/
|
||||||
|
private function ride(Request $request, Character $character): JsonResponse
|
||||||
|
{
|
||||||
|
if ($character->isHospitalized()) {
|
||||||
|
return response()->json(['message' => __('city.hospital_blocked')], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'kind' => ['required', 'in:bus,train'],
|
||||||
|
'pos_x' => ['nullable', 'numeric'],
|
||||||
|
'pos_z' => ['nullable', 'numeric'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$x = array_key_exists('pos_x', $data) ? (float) $data['pos_x'] : (float) $character->pos_x;
|
||||||
|
$z = array_key_exists('pos_z', $data) ? (float) $data['pos_z'] : (float) $character->pos_z;
|
||||||
|
|
||||||
|
// Must be near a road (bus) or the train corridor (train).
|
||||||
|
$city = $character->city;
|
||||||
|
if ($data['kind'] === 'bus') {
|
||||||
|
$nearRoad = false;
|
||||||
|
for ($vx = ceil(((float) $city->bounds_min_x) / 40) * 40; $vx <= $city->bounds_max_x; $vx += 40) {
|
||||||
|
if (abs($x - $vx) < 8) {
|
||||||
|
$nearRoad = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (! $nearRoad) {
|
||||||
|
for ($vz = ceil(((float) $city->bounds_min_z) / 40) * 40; $vz <= $city->bounds_max_z; $vz += 40) {
|
||||||
|
if (abs($z - $vz) < 8) {
|
||||||
|
$nearRoad = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (! $nearRoad) {
|
||||||
|
return response()->json(['message' => __('city.ride_need_stop')], 422);
|
||||||
|
}
|
||||||
|
} elseif (abs($z - ((float) $city->bounds_max_z + 14)) > 20) {
|
||||||
|
return response()->json(['message' => __('city.ride_need_stop')], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$character->forceFill([
|
||||||
|
'ride_kind' => $data['kind'],
|
||||||
|
'ride_until' => now()->addSeconds(45),
|
||||||
|
'state' => Character::STATE_AWAKE,
|
||||||
|
'pos_x' => $x,
|
||||||
|
'pos_z' => $z,
|
||||||
|
'is_online' => true,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => $data['kind'] === 'train' ? __('city.ride_train_ok') : __('city.ride_bus_ok'),
|
||||||
|
'character' => $this->characterData($character),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One exercise rep on nearby plaza gym equipment: +health, −energy.
|
||||||
|
*
|
||||||
|
* Optional pos_x/pos_z let the client report its live position so the
|
||||||
|
* proximity check does not wait for the throttled position sync.
|
||||||
|
*/
|
||||||
|
private function exercise(Request $request, Character $character): JsonResponse
|
||||||
|
{
|
||||||
|
if ($character->state === Character::STATE_SLEEPING) {
|
||||||
|
return response()->json(['message' => __('city.exercise_need_awake')], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'pos_x' => ['nullable', 'numeric'],
|
||||||
|
'pos_z' => ['nullable', 'numeric'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$x = array_key_exists('pos_x', $data) ? (float) $data['pos_x'] : (float) $character->pos_x;
|
||||||
|
$z = array_key_exists('pos_z', $data) ? (float) $data['pos_z'] : (float) $character->pos_z;
|
||||||
|
|
||||||
|
$nearby = CityPlazas::nearestEquipment(
|
||||||
|
$character->city,
|
||||||
|
$x,
|
||||||
|
$z,
|
||||||
|
4.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($nearby === null) {
|
||||||
|
return response()->json(['message' => __('city.exercise_need_equipment')], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $character->energy < 5) {
|
||||||
|
return response()->json(['message' => __('city.exercise_tired')], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $character->health >= 100) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => __('city.exercise_max_health'),
|
||||||
|
'character' => $this->characterData($character),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$character->forceFill([
|
||||||
|
'state' => Character::STATE_EXERCISING,
|
||||||
|
'health' => min(100, (int) $character->health + 2),
|
||||||
|
'energy' => max(0, (int) $character->energy - 2),
|
||||||
|
'exercise_count' => (int) $character->exercise_count + 1,
|
||||||
|
'pos_x' => $x,
|
||||||
|
'pos_z' => $z,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$missionDones = MissionTracker::sync($character, 'exercise_count');
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => __('city.exercise_ok'),
|
||||||
|
'equipment' => $nearby,
|
||||||
|
'missions_completed' => $missionDones,
|
||||||
|
'character' => $this->characterData($character),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leave the exercising state (back to awake).
|
||||||
|
*/
|
||||||
|
private function stopExercise(Character $character): JsonResponse
|
||||||
|
{
|
||||||
|
if ($character->state === Character::STATE_EXERCISING) {
|
||||||
|
$character->state = Character::STATE_AWAKE;
|
||||||
|
$character->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => __('city.action_ok'),
|
||||||
|
'character' => $this->characterData($character),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admit the character to the hospital after a vehicle collision.
|
* Admit the character to the hospital after a vehicle collision.
|
||||||
*
|
*
|
||||||
* Stays two game days (Character::HOSPITAL_STAY_SECONDS).
|
* Stays one game hour (Character::HOSPITAL_STAY_SECONDS).
|
||||||
*/
|
*/
|
||||||
private function accident(Character $character): JsonResponse
|
private function accident(Character $character): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -399,6 +539,8 @@ class CityActionController extends Controller
|
|||||||
'build_progress' => 0,
|
'build_progress' => 0,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
MissionTracker::sync($character->fresh(), 'buildings');
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => __('city.build_started'),
|
'message' => __('city.build_started'),
|
||||||
'building' => [
|
'building' => [
|
||||||
@@ -483,13 +625,23 @@ class CityActionController extends Controller
|
|||||||
'energy' => (int) $character->energy,
|
'energy' => (int) $character->energy,
|
||||||
'hunger' => (int) $character->hunger,
|
'hunger' => (int) $character->hunger,
|
||||||
'thirst' => (int) $character->thirst,
|
'thirst' => (int) $character->thirst,
|
||||||
|
'health' => (int) $character->health,
|
||||||
'education' => (int) $character->education,
|
'education' => (int) $character->education,
|
||||||
|
'exercise_count' => (int) $character->exercise_count,
|
||||||
'job_id' => $character->job_id,
|
'job_id' => $character->job_id,
|
||||||
'job_title' => $character->job?->title,
|
'job_title' => $character->job?->title,
|
||||||
'house_id' => $character->house_id,
|
'house_id' => $character->house_id,
|
||||||
'pos_x' => (float) $character->pos_x,
|
'pos_x' => (float) $character->pos_x,
|
||||||
'pos_z' => (float) $character->pos_z,
|
'pos_z' => (float) $character->pos_z,
|
||||||
'hospitalized_until' => $character->hospitalized_until?->toIso8601String(),
|
'hospitalized_until' => $character->hospitalized_until?->toIso8601String(),
|
||||||
|
'face' => (int) $character->face,
|
||||||
|
'beard' => (int) $character->beard,
|
||||||
|
'accessory' => (int) $character->accessory,
|
||||||
|
'skin' => (int) $character->skin,
|
||||||
|
'hair' => (int) $character->hair,
|
||||||
|
'shirt' => (int) $character->shirt,
|
||||||
|
'ride_kind' => $character->ride_kind,
|
||||||
|
'ride_until' => $character->ride_until?->toIso8601String(),
|
||||||
'inventory' => $character->inventory->mapWithKeys(fn ($i) => [$i->item => (int) $i->qty])->all(),
|
'inventory' => $character->inventory->mapWithKeys(fn ($i) => [$i->item => (int) $i->qty])->all(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Character;
|
||||||
|
use App\Models\InventoryItem;
|
||||||
|
use App\Models\MarketListing;
|
||||||
|
use App\Models\Mission;
|
||||||
|
use App\Models\Transaction;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marketplace between citizens and mission journal endpoints.
|
||||||
|
*/
|
||||||
|
class CityMarketController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Active listings in the caller's city plus own listings.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$me = Character::ensureForUser($request->user());
|
||||||
|
|
||||||
|
$listings = MarketListing::query()
|
||||||
|
->where('city_id', $me->city_id)
|
||||||
|
->where('is_active', true)
|
||||||
|
->with('seller:id,name')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->limit(50)
|
||||||
|
->get()
|
||||||
|
->map(fn (MarketListing $l): array => [
|
||||||
|
'id' => $l->id,
|
||||||
|
'item' => $l->item,
|
||||||
|
'qty' => (int) $l->qty,
|
||||||
|
'unit_price' => (float) $l->unit_price,
|
||||||
|
'seller_id' => $l->seller_character_id,
|
||||||
|
'seller_name' => $l->seller?->name,
|
||||||
|
'is_mine' => (int) $l->seller_character_id === (int) $me->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$missions = Mission::query()
|
||||||
|
->where('city_id', $me->city_id)
|
||||||
|
->where('is_active', true)
|
||||||
|
->get()
|
||||||
|
->map(function (Mission $m) use ($me) {
|
||||||
|
$pivot = DB::table('character_missions')
|
||||||
|
->where('character_id', $me->id)
|
||||||
|
->where('mission_id', $m->id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $m->id,
|
||||||
|
'code' => $m->code,
|
||||||
|
'title' => $m->title,
|
||||||
|
'description' => $m->description,
|
||||||
|
'target' => (int) $m->target,
|
||||||
|
'reward' => (float) $m->reward,
|
||||||
|
'progress' => (int) ($pivot->progress ?? 0),
|
||||||
|
'is_completed' => (bool) ($pivot->is_completed ?? false),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'listings' => $listings,
|
||||||
|
'missions' => $missions,
|
||||||
|
'money' => (float) $me->money,
|
||||||
|
'inventory' => $me->inventory()->get()->mapWithKeys(fn ($i) => [$i->item => (int) $i->qty])->all(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish inventory for sale.
|
||||||
|
*/
|
||||||
|
public function sell(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$me = Character::ensureForUser($request->user());
|
||||||
|
$data = $request->validate([
|
||||||
|
'item' => ['required', 'in:food,water,materials,product'],
|
||||||
|
'qty' => ['required', 'integer', 'min:1', 'max:100'],
|
||||||
|
'unit_price' => ['required', 'numeric', 'min:0.5', 'max:1000'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$row = InventoryItem::query()
|
||||||
|
->where('character_id', $me->id)
|
||||||
|
->where('item', $data['item'])
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($row === null || (int) $row->qty < (int) $data['qty']) {
|
||||||
|
return response()->json(['message' => __('city.no_inventory')], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($me, $data, $row): void {
|
||||||
|
$row->qty = (int) $row->qty - (int) $data['qty'];
|
||||||
|
$row->save();
|
||||||
|
MarketListing::create([
|
||||||
|
'seller_character_id' => $me->id,
|
||||||
|
'city_id' => $me->city_id,
|
||||||
|
'item' => $data['item'],
|
||||||
|
'qty' => (int) $data['qty'],
|
||||||
|
'unit_price' => (float) $data['unit_price'],
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json(['message' => __('city.market_listed')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Purchase a listing from another citizen.
|
||||||
|
*/
|
||||||
|
public function buy(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$me = Character::ensureForUser($request->user());
|
||||||
|
$data = $request->validate([
|
||||||
|
'listing_id' => ['required', 'integer', 'exists:market_listings,id'],
|
||||||
|
'qty' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$listing = MarketListing::query()->findOrFail($data['listing_id']);
|
||||||
|
|
||||||
|
if (! $listing->is_active || (int) $listing->seller_character_id === (int) $me->id) {
|
||||||
|
return response()->json(['message' => __('city.market_unavailable')], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$qty = min((int) ($data['qty'] ?? $listing->qty), (int) $listing->qty);
|
||||||
|
$total = round($listing->unit_price * $qty, 2);
|
||||||
|
|
||||||
|
if ((float) $me->money < $total) {
|
||||||
|
return response()->json(['message' => __('city.no_money')], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($me, $listing, $qty, $total): void {
|
||||||
|
$seller = Character::query()->lockForUpdate()->findOrFail($listing->seller_character_id);
|
||||||
|
$me->money = round((float) $me->money - $total, 2);
|
||||||
|
$me->save();
|
||||||
|
$seller->money = round((float) $seller->money + $total, 2);
|
||||||
|
$seller->save();
|
||||||
|
|
||||||
|
$row = InventoryItem::query()
|
||||||
|
->where('character_id', $me->id)
|
||||||
|
->where('item', $listing->item)
|
||||||
|
->first();
|
||||||
|
if ($row) {
|
||||||
|
$row->qty = (int) $row->qty + $qty;
|
||||||
|
$row->save();
|
||||||
|
} else {
|
||||||
|
InventoryItem::create([
|
||||||
|
'character_id' => $me->id,
|
||||||
|
'item' => $listing->item,
|
||||||
|
'qty' => $qty,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$listing->qty = (int) $listing->qty - $qty;
|
||||||
|
if ($listing->qty <= 0) {
|
||||||
|
$listing->is_active = false;
|
||||||
|
}
|
||||||
|
$listing->save();
|
||||||
|
|
||||||
|
Transaction::create([
|
||||||
|
'from_character_id' => $me->id,
|
||||||
|
'to_character_id' => $seller->id,
|
||||||
|
'item' => $listing->item,
|
||||||
|
'qty' => $qty,
|
||||||
|
'amount' => $total,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
$me->refresh();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => __('city.market_bought'),
|
||||||
|
'money' => (float) $me->money,
|
||||||
|
'inventory' => $me->inventory()->get()->mapWithKeys(fn ($i) => [$i->item => (int) $i->qty])->all(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Character;
|
||||||
|
use App\Models\City;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight multiplayer presence feed.
|
||||||
|
*
|
||||||
|
* Returns the positions and states of characters active in a city so the
|
||||||
|
* viewer can render other citizens in near-real-time without WebSockets.
|
||||||
|
*/
|
||||||
|
class CityPresenceController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Snapshot of citizens in the requested (or default) city.
|
||||||
|
*/
|
||||||
|
public function __invoke(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
abort_unless($user !== null, 401);
|
||||||
|
|
||||||
|
$me = Character::ensureForUser($user);
|
||||||
|
$slug = $request->query('city');
|
||||||
|
$city = $me->city;
|
||||||
|
if (is_string($slug) && $slug !== '') {
|
||||||
|
$city = City::query()
|
||||||
|
->where('slug', $slug)
|
||||||
|
->orWhere('id', ctype_digit($slug) ? (int) $slug : 0)
|
||||||
|
->first() ?? $city;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cacheKey = 'presence.'.$city->id.'.'.now()->timestamp;
|
||||||
|
$citizens = Cache::remember($cacheKey, 1, function () use ($city, $me) {
|
||||||
|
return Character::query()
|
||||||
|
->where('city_id', $city->id)
|
||||||
|
->where(function ($q): void {
|
||||||
|
$q->where('is_online', true)
|
||||||
|
->orWhere('updated_at', '>=', now()->subMinutes(10));
|
||||||
|
})
|
||||||
|
->with('user:id,name')
|
||||||
|
->get()
|
||||||
|
->map(fn (Character $c): array => [
|
||||||
|
'id' => $c->id,
|
||||||
|
'name' => $c->name,
|
||||||
|
'type' => $c->user_id === null ? 'child' : 'citizen',
|
||||||
|
'status' => $c->state,
|
||||||
|
'position' => [(float) $c->pos_x, (float) $c->pos_y, (float) $c->pos_z],
|
||||||
|
'is_online' => (bool) $c->is_online,
|
||||||
|
'is_me' => (int) $c->id === (int) $me->id,
|
||||||
|
'owner_name' => $c->user?->name,
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'city_id' => $city->id,
|
||||||
|
'server_time' => now()->toIso8601String(),
|
||||||
|
'citizens' => $citizens,
|
||||||
|
])->header('Cache-Control', 'no-store');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ use App\Models\Character;
|
|||||||
use App\Models\City;
|
use App\Models\City;
|
||||||
use App\Models\Job;
|
use App\Models\Job;
|
||||||
use App\Models\Store;
|
use App\Models\Store;
|
||||||
|
use App\Support\CityPlazas;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@@ -128,7 +129,9 @@ class CityViewerController extends Controller
|
|||||||
'energy' => (int) $me->energy,
|
'energy' => (int) $me->energy,
|
||||||
'hunger' => (int) $me->hunger,
|
'hunger' => (int) $me->hunger,
|
||||||
'thirst' => (int) $me->thirst,
|
'thirst' => (int) $me->thirst,
|
||||||
|
'health' => (int) $me->health,
|
||||||
'education' => (int) $me->education,
|
'education' => (int) $me->education,
|
||||||
|
'exercise_count' => (int) $me->exercise_count,
|
||||||
'job_id' => $me->job_id,
|
'job_id' => $me->job_id,
|
||||||
'job_title' => $me->job?->title,
|
'job_title' => $me->job?->title,
|
||||||
'house_id' => $me->house_id,
|
'house_id' => $me->house_id,
|
||||||
@@ -137,6 +140,7 @@ class CityViewerController extends Controller
|
|||||||
'inventory' => $me->inventory()->get()->mapWithKeys(fn ($i) => [$i->item => (int) $i->qty])->all(),
|
'inventory' => $me->inventory()->get()->mapWithKeys(fn ($i) => [$i->item => (int) $i->qty])->all(),
|
||||||
] : null,
|
] : null,
|
||||||
'layout' => $this->cityLayout($city, $stores->all()),
|
'layout' => $this->cityLayout($city, $stores->all()),
|
||||||
|
'plazas' => CityPlazas::forCity($city),
|
||||||
'agents' => AiAgent::orderBy('id')->get()->map(fn ($a): array => [
|
'agents' => AiAgent::orderBy('id')->get()->map(fn ($a): array => [
|
||||||
'id' => $a->id,
|
'id' => $a->id,
|
||||||
'name' => $a->name,
|
'name' => $a->name,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,9 +18,9 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
*/
|
*/
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'user_id', 'city_id', 'name', 'skin', 'hair', 'shirt',
|
'user_id', 'city_id', 'name', 'skin', 'hair', 'shirt',
|
||||||
'pos_x', 'pos_y', 'pos_z', 'money', 'energy', 'hunger', 'thirst',
|
'pos_x', 'pos_y', 'pos_z', 'money', 'energy', 'hunger', 'thirst', 'health',
|
||||||
'state', 'job_id', 'house_id', 'education', 'is_online', 'last_tick_at',
|
'state', 'job_id', 'house_id', 'education', 'exercise_count', 'is_online', 'last_tick_at',
|
||||||
'hospitalized_until',
|
'hospitalized_until', 'face', 'beard', 'accessory', 'ride_kind', 'ride_until',
|
||||||
])]
|
])]
|
||||||
class Character extends Model
|
class Character extends Model
|
||||||
{
|
{
|
||||||
@@ -34,6 +35,13 @@ class Character extends Model
|
|||||||
|
|
||||||
public const STATE_STUDYING = 'studying';
|
public const STATE_STUDYING = 'studying';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Character is exercising on public plaza gym equipment.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
public const STATE_EXERCISING = 'exercising';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Character admitted to the hospital after a traffic accident.
|
* Character admitted to the hospital after a traffic accident.
|
||||||
*
|
*
|
||||||
@@ -42,11 +50,14 @@ class Character extends Model
|
|||||||
public const STATE_HOSPITAL = 'hospital';
|
public const STATE_HOSPITAL = 'hospital';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hospital stay length in seconds (two game days of 240s each).
|
* Hospital stay length in seconds (one game hour).
|
||||||
|
*
|
||||||
|
* The day/night cycle is 240 real seconds per 24 game hours, so one
|
||||||
|
* game hour equals 10 real seconds.
|
||||||
*
|
*
|
||||||
* @var int
|
* @var int
|
||||||
*/
|
*/
|
||||||
public const HOSPITAL_STAY_SECONDS = 480;
|
public const HOSPITAL_STAY_SECONDS = 10;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default hospital bed coordinates used when no hospital building exists.
|
* Default hospital bed coordinates used when no hospital building exists.
|
||||||
@@ -106,6 +117,28 @@ class Character extends Model
|
|||||||
return $this->hasMany(Building::class, 'owner_character_id');
|
return $this->hasMany(Building::class, 'owner_character_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marketplace listings published by this character.
|
||||||
|
*
|
||||||
|
* @return HasMany<MarketListing, $this>
|
||||||
|
*/
|
||||||
|
public function listings(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(MarketListing::class, 'seller_character_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Missions this character is tracking.
|
||||||
|
*
|
||||||
|
* @return BelongsToMany<Mission, $this>
|
||||||
|
*/
|
||||||
|
public function missions(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Mission::class, 'character_missions')
|
||||||
|
->withPivot(['progress', 'is_completed', 'completed_at'])
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inventory stacks owned by this character.
|
* Inventory stacks owned by this character.
|
||||||
*
|
*
|
||||||
@@ -153,9 +186,14 @@ class Character extends Model
|
|||||||
'energy' => 100,
|
'energy' => 100,
|
||||||
'hunger' => 100,
|
'hunger' => 100,
|
||||||
'thirst' => 100,
|
'thirst' => 100,
|
||||||
|
'health' => 100,
|
||||||
'state' => self::STATE_AWAKE,
|
'state' => self::STATE_AWAKE,
|
||||||
'education' => 0,
|
'education' => 0,
|
||||||
|
'exercise_count' => 0,
|
||||||
'is_online' => false,
|
'is_online' => false,
|
||||||
|
'face' => random_int(1, 3),
|
||||||
|
'beard' => random_int(0, 2),
|
||||||
|
'accessory' => random_int(0, 2),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Starter kit so new citizens can eat, drink and build immediately.
|
// Starter kit so new citizens can eat, drink and build immediately.
|
||||||
@@ -189,10 +227,16 @@ class Character extends Model
|
|||||||
'energy' => 'integer',
|
'energy' => 'integer',
|
||||||
'hunger' => 'integer',
|
'hunger' => 'integer',
|
||||||
'thirst' => 'integer',
|
'thirst' => 'integer',
|
||||||
|
'health' => 'integer',
|
||||||
'education' => 'integer',
|
'education' => 'integer',
|
||||||
|
'exercise_count' => 'integer',
|
||||||
'is_online' => 'boolean',
|
'is_online' => 'boolean',
|
||||||
'last_tick_at' => 'datetime',
|
'last_tick_at' => 'datetime',
|
||||||
'hospitalized_until' => 'datetime',
|
'hospitalized_until' => 'datetime',
|
||||||
|
'face' => 'integer',
|
||||||
|
'beard' => 'integer',
|
||||||
|
'accessory' => 'integer',
|
||||||
|
'ride_until' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +251,7 @@ class Character extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admit the character to the hospital for two game days after an accident.
|
* Admit the character to the hospital for one game hour after an accident.
|
||||||
*
|
*
|
||||||
* Teleports them to the city hospital bed and returns the bed position.
|
* Teleports them to the city hospital bed and returns the bed position.
|
||||||
*
|
*
|
||||||
@@ -236,7 +280,7 @@ class Character extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Release the character from the hospital when the stay has elapsed.
|
* Release the character from the hospital when the one-hour stay has elapsed.
|
||||||
*/
|
*/
|
||||||
public function releaseIfHospitalStayOver(): bool
|
public function releaseIfHospitalStayOver(): bool
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Player-to-player marketplace listing.
|
||||||
|
*/
|
||||||
|
#[Fillable(['seller_character_id', 'city_id', 'item', 'qty', 'unit_price', 'is_active'])]
|
||||||
|
class MarketListing extends Model
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Listing seller.
|
||||||
|
*
|
||||||
|
* @return BelongsTo<Character, $this>
|
||||||
|
*/
|
||||||
|
public function seller(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Character::class, 'seller_character_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* City where the listing is visible.
|
||||||
|
*
|
||||||
|
* @return BelongsTo<City, $this>
|
||||||
|
*/
|
||||||
|
public function city(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(City::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the attributes that should be cast.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'unit_price' => 'float',
|
||||||
|
'qty' => 'integer',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catalog mission that citizens can complete for a reward.
|
||||||
|
*/
|
||||||
|
#[Fillable(['city_id', 'code', 'title', 'description', 'metric', 'target', 'reward', 'is_active'])]
|
||||||
|
class Mission extends Model
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Characters tracking this mission.
|
||||||
|
*
|
||||||
|
* @return BelongsToMany<Character, $this>
|
||||||
|
*/
|
||||||
|
public function characters(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Character::class, 'character_missions')
|
||||||
|
->withPivot(['progress', 'is_completed', 'completed_at'])
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the attributes that should be cast.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'reward' => 'float',
|
||||||
|
'target' => 'integer',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Models\Building;
|
||||||
|
use App\Models\City;
|
||||||
|
use App\Models\Store;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic public plazas with outdoor gym equipment.
|
||||||
|
*
|
||||||
|
* Plazas replace free city blocks (not near stores, houses or the hospital).
|
||||||
|
* Equipment world positions must match the frontend generator so the server
|
||||||
|
* can validate exercise proximity.
|
||||||
|
*/
|
||||||
|
class CityPlazas
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Equipment kinds rendered on each plaza.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
public const EQUIPMENT_KINDS = ['bench', 'bars', 'bike', 'treadmill'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build plaza + equipment layout for a city.
|
||||||
|
*
|
||||||
|
* @return list<array{id: string, position: array{0: float, 1: float}, size: array{0: float, 1: float}, equipment: list<array{id: string, kind: string, position: array{0: float, 1: float}}>}>
|
||||||
|
*/
|
||||||
|
public static function forCity(City $city): array
|
||||||
|
{
|
||||||
|
$minX = (int) floor(((float) $city->bounds_min_x) / 40) * 40 - 40;
|
||||||
|
$maxX = (int) ceil(((float) $city->bounds_max_x) / 40) * 40 + 40;
|
||||||
|
$minZ = (int) floor(((float) $city->bounds_min_z) / 40) * 40 - 40;
|
||||||
|
$maxZ = (int) ceil(((float) $city->bounds_max_z) / 40) * 40 + 40;
|
||||||
|
|
||||||
|
$occupied = Store::query()
|
||||||
|
->where(function ($q): void {
|
||||||
|
// Stores are global for now; keep city filter ready for multi-city.
|
||||||
|
$q->whereNotNull('id');
|
||||||
|
})
|
||||||
|
->get(['id', 'pos_x', 'pos_z'])
|
||||||
|
->map(fn ($s): array => ['x' => (float) $s->pos_x, 'z' => (float) $s->pos_z])
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$occupied = array_merge($occupied, Building::query()
|
||||||
|
->where('city_id', $city->id)
|
||||||
|
->get(['id', 'pos_x', 'pos_z', 'type'])
|
||||||
|
->map(fn ($b): array => ['x' => (float) $b->pos_x, 'z' => (float) $b->pos_z])
|
||||||
|
->all());
|
||||||
|
|
||||||
|
$plazas = [];
|
||||||
|
$index = 0;
|
||||||
|
for ($x = $minX; $x < $maxX; $x += 40) {
|
||||||
|
for ($z = $minZ; $z < $maxZ; $z += 40) {
|
||||||
|
$cx = $x + 20.0;
|
||||||
|
$cz = $z + 20.0;
|
||||||
|
|
||||||
|
$nearOccupied = array_reduce($occupied, function (bool $carry, array $o) use ($cx, $cz): bool {
|
||||||
|
return $carry || (abs($o['x'] - $cx) < 25 && abs($o['z'] - $cz) < 25);
|
||||||
|
}, false);
|
||||||
|
|
||||||
|
// Keep a sparse, deterministic plaza pattern (~1 free block in 4).
|
||||||
|
if ($nearOccupied || ($index % 4) !== 1) {
|
||||||
|
$index++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$index++;
|
||||||
|
|
||||||
|
$equipment = [];
|
||||||
|
foreach (self::EQUIPMENT_KINDS as $k => $kind) {
|
||||||
|
$angle = ($k / count(self::EQUIPMENT_KINDS)) * M_PI * 2;
|
||||||
|
$ex = round($cx + cos($angle) * 8, 2);
|
||||||
|
$ez = round($cz + sin($angle) * 8, 2);
|
||||||
|
$equipment[] = [
|
||||||
|
'id' => sprintf('plaza-%d-%s', count($plazas), $kind),
|
||||||
|
'kind' => $kind,
|
||||||
|
'position' => [$ex, $ez],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$plazas[] = [
|
||||||
|
'id' => 'plaza-'.count($plazas),
|
||||||
|
'position' => [$cx, $cz],
|
||||||
|
'size' => [22.0, 22.0],
|
||||||
|
'equipment' => $equipment,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guarantee at least one plaza near the city center for newcomers.
|
||||||
|
if ($plazas === []) {
|
||||||
|
$cx = 20.0;
|
||||||
|
$cz = -20.0;
|
||||||
|
$equipment = [];
|
||||||
|
foreach (self::EQUIPMENT_KINDS as $k => $kind) {
|
||||||
|
$angle = ($k / count(self::EQUIPMENT_KINDS)) * M_PI * 2;
|
||||||
|
$equipment[] = [
|
||||||
|
'id' => 'plaza-0-'.$kind,
|
||||||
|
'kind' => $kind,
|
||||||
|
'position' => [round($cx + cos($angle) * 8, 2), round($cz + sin($angle) * 8, 2)],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$plazas[] = [
|
||||||
|
'id' => 'plaza-0',
|
||||||
|
'position' => [$cx, $cz],
|
||||||
|
'size' => [22.0, 22.0],
|
||||||
|
'equipment' => $equipment,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $plazas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the nearest gym equipment within reach of a world position.
|
||||||
|
*
|
||||||
|
* @return array{plaza_id: string, kind: string, position: array{0: float, 1: float}, distance: float}|null
|
||||||
|
*/
|
||||||
|
public static function nearestEquipment(City $city, float $x, float $z, float $maxDistance = 3.0): ?array
|
||||||
|
{
|
||||||
|
$best = null;
|
||||||
|
foreach (self::forCity($city) as $plaza) {
|
||||||
|
foreach ($plaza['equipment'] as $eq) {
|
||||||
|
$dx = $eq['position'][0] - $x;
|
||||||
|
$dz = $eq['position'][1] - $z;
|
||||||
|
$dist = hypot($dx, $dz);
|
||||||
|
if ($dist <= $maxDistance && ($best === null || $dist < $best['distance'])) {
|
||||||
|
$best = [
|
||||||
|
'plaza_id' => $plaza['id'],
|
||||||
|
'kind' => $eq['kind'],
|
||||||
|
'position' => $eq['position'],
|
||||||
|
'distance' => $dist,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $best;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Models\Character;
|
||||||
|
use App\Models\Mission;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks citizen mission progress and pays rewards on completion.
|
||||||
|
*/
|
||||||
|
class MissionTracker
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Metric keys readable from a character snapshot.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
public const METRICS = ['exercise_count', 'buildings', 'transactions', 'education'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the character has an active row for every city mission.
|
||||||
|
*/
|
||||||
|
public static function ensureTracking(Character $character): void
|
||||||
|
{
|
||||||
|
$missions = Mission::query()
|
||||||
|
->where('city_id', $character->city_id)
|
||||||
|
->where('is_active', true)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($missions as $mission) {
|
||||||
|
$exists = DB::table('character_missions')
|
||||||
|
->where('character_id', $character->id)
|
||||||
|
->where('mission_id', $mission->id)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if (! $exists) {
|
||||||
|
DB::table('character_missions')->insert([
|
||||||
|
'character_id' => $character->id,
|
||||||
|
'mission_id' => $mission->id,
|
||||||
|
'progress' => 0,
|
||||||
|
'is_completed' => false,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recompute progress for a metric and complete missions that hit target.
|
||||||
|
*
|
||||||
|
* @return list<array{code: string, title: string, reward: float}>
|
||||||
|
*/
|
||||||
|
public static function sync(Character $character, string $metric): array
|
||||||
|
{
|
||||||
|
if (! in_array($metric, self::METRICS, true)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
self::ensureTracking($character);
|
||||||
|
$character->refresh();
|
||||||
|
$value = (int) match ($metric) {
|
||||||
|
'exercise_count' => $character->exercise_count,
|
||||||
|
'education' => $character->education,
|
||||||
|
'buildings' => $character->buildings()->where('type', 'house')->count(),
|
||||||
|
'transactions' => DB::table('transactions')
|
||||||
|
->where('from_character_id', $character->id)
|
||||||
|
->orWhere('to_character_id', $character->id)
|
||||||
|
->count(),
|
||||||
|
default => 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
$completed = [];
|
||||||
|
$rows = DB::table('character_missions')
|
||||||
|
->where('character_id', $character->id)
|
||||||
|
->where('is_completed', false)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$mission = Mission::query()->find($row->mission_id);
|
||||||
|
if ($mission === null || $mission->metric !== $metric) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$progress = min($value, (int) $mission->target);
|
||||||
|
DB::table('character_missions')->where('id', $row->id)->update([
|
||||||
|
'progress' => $progress,
|
||||||
|
'is_completed' => $progress >= (int) $mission->target,
|
||||||
|
'completed_at' => $progress >= (int) $mission->target ? now() : null,
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($progress >= (int) $mission->target) {
|
||||||
|
$character->money = round((float) $character->money + (float) $mission->reward, 2);
|
||||||
|
$completed[] = [
|
||||||
|
'code' => $mission->code,
|
||||||
|
'title' => $mission->title,
|
||||||
|
'reward' => (float) $mission->reward,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($completed !== []) {
|
||||||
|
$character->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $completed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ use Illuminate\Support\Facades\Schema;
|
|||||||
/**
|
/**
|
||||||
* Adds hospital admission tracking to characters.
|
* Adds hospital admission tracking to characters.
|
||||||
*
|
*
|
||||||
* When a character is hit by a vehicle they are admitted for two game days
|
* When a character is hit by a vehicle they are admitted for one game hour
|
||||||
* (hospitalized_until). The world ticker releases them automatically.
|
* (hospitalized_until). The world ticker releases them automatically.
|
||||||
*/
|
*/
|
||||||
return new class extends Migration
|
return new class extends Migration
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds fitness tracking to characters.
|
||||||
|
*
|
||||||
|
* health is 0-100 and rises while the character exercises on public plaza
|
||||||
|
* equipment; exercise_count is a lifetime counter for progress display.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('characters', function (Blueprint $table): void {
|
||||||
|
$table->unsignedTinyInteger('health')->default(100)->after('thirst');
|
||||||
|
$table->unsignedInteger('exercise_count')->default(0)->after('education');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('characters', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn(['health', 'exercise_count']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||