Compare commits

..
5 Commits
Author SHA1 Message Date
marcocosta10000 c299a4ae21 feat(city): implement marketplace, missions, customization and admin dashboard
- Add database migrations for market_listings, missions and character cosmetics
- Create controllers for marketplace, missions, presence and character customization
- Implement admin world dashboard with live platform metrics
- Add feature tests for world functionality
- Include phase 2 design documents and gap analysis
2026-10-17 23:21:23 -03:00
marcocosta10000 a620f83dd8 feat(city): add player marketplace, mission journal, character customization and presence
- Player-to-player marketplace with listings, buying and selling
- Mission system with progress tracking and money rewards
- Character appearance customization (skin, hair, face, beard, accessory)
- Multiplayer presence feed showing nearby citizens in near-real-time
- Admin world dashboard with live platform metrics
- Bus and train transportation with temporary ride state
- Mobile joystick controls and minimap HUD
- Education bonus applied to work salary
2026-10-17 22:51:20 -03:00
marcocosta10000 18778b3d26 fix(city): correct vehicle yaw calculation and expose orientation API
- Extracts yawFromDelta helper to standardize rotation math
- Removes incorrect PI/2 offset causing reverse-facing vehicles
- Accounts for direction sign in shuttle vehicle rotation
- Adds vehicleOrientations() for test validation
2026-10-17 22:25:53 -03:00
marcocosta10000 fa25dd7307 feat(city): add pedestrian traffic signals with walk/wait indicators
- Adds 3D pedestrian signal heads at each intersection corner
- Implements pedestrian signal state logic tied to vehicle traffic phases
- Displays localized wait banner when pedestrian signal is red
- Includes test asset for pedestrian signal visualization
2026-10-17 22:20:34 -03:00
marcocosta10000 090b530481 feat(city): add plaza exercise system with health tracking
- Adds CityPlazas support class for deterministic public plaza generation with outdoor gym equipment
- Implements exercise and stop-exercise actions with proximity validation to equipment
- Introduces health (0-100) and exercise_count attributes to Character model
- Reduces hospital stay duration from two game days to one game hour
- Adds 3D gym equipment rendering (bench, bars, bike, treadmill) in city viewer
- Includes exercise state machine handling in world ticker with energy drain
- Adds comprehensive PlazaExerciseTest and updates HospitalAdmissionTest
- Provides localized UI strings for health HUD and exercise prompts
2026-10-17 22:10:50 -03:00
120 changed files with 3120 additions and 35 deletions
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
Regular → Executable
View File
View File

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

View File

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 110 KiB

View File

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 110 KiB

View File

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 110 KiB

View File

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 102 KiB

View File

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 109 KiB

View File

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 118 KiB

View File

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 109 KiB

View File

Before

Width:  |  Height:  |  Size: 218 KiB

After

Width:  |  Height:  |  Size: 218 KiB

View File

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 170 KiB

View File

Before

Width:  |  Height:  |  Size: 282 KiB

After

Width:  |  Height:  |  Size: 282 KiB

View File

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

View File

Before

Width:  |  Height:  |  Size: 223 KiB

After

Width:  |  Height:  |  Size: 223 KiB

View File

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

View File

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

View File

Before

Width:  |  Height:  |  Size: 203 KiB

After

Width:  |  Height:  |  Size: 203 KiB

View File

Before

Width:  |  Height:  |  Size: 222 KiB

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

View File

Before

Width:  |  Height:  |  Size: 235 KiB

After

Width:  |  Height:  |  Size: 235 KiB

View File

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 114 KiB

View File

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 131 KiB

View File

Before

Width:  |  Height:  |  Size: 261 KiB

After

Width:  |  Height:  |  Size: 261 KiB

View File

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 132 KiB

View File

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 129 KiB

View File

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

View File

Before

Width:  |  Height:  |  Size: 182 KiB

After

Width:  |  Height:  |  Size: 182 KiB

View File

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 129 KiB

View File

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 110 KiB

View File

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

View File

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

+1
View File
@@ -0,0 +1 @@
56096
+1
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

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}
Regular → Executable
View File
Regular → Executable
View File
BIN
View File
Binary file not shown.
+56
View File
@@ -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;
}
}
+25 -1
View File
@@ -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;
+81
View File
@@ -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(),
],
];
}
}
+55
View File
@@ -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(),
]; ];
} }
+180
View File
@@ -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(),
]);
}
}
+68
View File
@@ -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,
+51 -7
View File
@@ -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
{ {
+48
View File
@@ -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',
];
}
}
+40
View File
@@ -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',
];
}
}
+142
View File
@@ -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;
}
}
+110
View File
@@ -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;
}
}
Regular → Executable
View File
@@ -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']);
});
}
};

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