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

Initial commit of the complete City10 virtual city simulation platform.

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

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

Infrastructure:
- MariaDB/MySQL database with comprehensive migrations
- Database seeders for demo content and test accounts
- PHPUnit test suite and Laravel Pint code styling
- Vite build configuration with hot reload
This commit is contained in:
2026-10-17 20:46:15 -03:00
commit 79d12fa2f4
128 changed files with 17053 additions and 0 deletions
@@ -0,0 +1,109 @@
<?php
namespace App\Http\Controllers\Web\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreRequest;
use App\Models\AiAgent;
use App\Models\Store;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* Administrative store management (admin only).
*
* Provides the server-rendered CRUD screens where administrators create
* and edit stores, defining their exact 3D placement in the virtual city
* through a Three.js position picker.
*/
class StoreAdminController extends Controller
{
/**
* Paginated listing of every store with search and type filtering.
*/
public function index(Request $request): View
{
$query = Store::query()->with(['owner:id,name', 'agent:id,name'])->orderBy('name');
if ($search = $request->query('search')) {
$query->where('name', 'like', "%{$search}%");
}
if ($type = $request->query('type')) {
$query->where('type', $type);
}
$types = Store::query()->select('type')->distinct()->orderBy('type')->pluck('type');
return view('admin.stores.index', [
'stores' => $query->paginate(12)->withQueryString(),
'types' => $types,
'currentSearch' => $search,
'currentType' => $type,
]);
}
/**
* Show the store creation form with the 3D position picker.
*/
public function create(): View
{
return view('admin.stores.form', [
'store' => new Store(['is_open' => true, 'pos_x' => 0, 'pos_y' => 0, 'pos_z' => 0, 'width' => 8, 'height' => 6, 'depth' => 8]),
'owners' => User::query()->orderBy('name')->get(['id', 'name', 'email']),
'agents' => AiAgent::query()->orderBy('name')->get(['id', 'name', 'type']),
'isEdit' => false,
]);
}
/**
* Persist a newly created store.
*/
public function store(StoreRequest $request): RedirectResponse
{
$store = Store::create($request->validated());
return redirect()
->route('admin.stores.edit', $store)
->with('status', __('admin.stores.created'));
}
/**
* Show the store edit form pre-filled with the current 3D placement.
*/
public function edit(Store $store): View
{
return view('admin.stores.form', [
'store' => $store,
'owners' => User::query()->orderBy('name')->get(['id', 'name', 'email']),
'agents' => AiAgent::query()->orderBy('name')->get(['id', 'name', 'type']),
'isEdit' => true,
]);
}
/**
* Update an existing store.
*/
public function update(StoreRequest $request, Store $store): RedirectResponse
{
$store->update($request->validated());
return redirect()
->route('admin.stores.index')
->with('status', __('admin.stores.updated'));
}
/**
* Delete a store from the city registry.
*/
public function destroy(Store $store): RedirectResponse
{
$store->delete();
return redirect()
->route('admin.stores.index')
->with('status', __('stores.deleted'));
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Http\Requests\LoginRequest;
use App\Http\Requests\RegisterRequest;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
/**
* Web authentication controllers (session based).
*
* Complements the token-based API with a traditional server-rendered
* login and registration flow including a math CAPTCHA.
*/
class AuthController extends Controller
{
/**
* Show the login form.
*/
public function showLogin(): View
{
return view('auth.login', [
'captcha' => self::makeCaptcha(),
]);
}
/**
* Handle the login form submission.
*/
public function login(LoginRequest $request): RedirectResponse
{
$credentials = $request->only('email', 'password');
$remember = $request->boolean('remember');
if (! Auth::attempt($credentials, $remember)) {
return back()->withErrors([
'email' => __('auth.failed'),
])->onlyInput('email');
}
$user = Auth::user();
if (! $user->is_active) {
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login')->withErrors([
'email' => __('auth.disabled'),
]);
}
$user->forceFill(['last_login_at' => now()])->save();
$request->session()->regenerate();
// Seed the session locale from the user's stored preference so the
// SetLocale middleware renders the UI in their chosen language.
if (in_array($user->locale, ['pt_BR', 'en', 'es'], true)) {
$request->session()->put('locale', $user->locale);
}
return redirect()->intended(route('dashboard'));
}
/**
* Show the registration form.
*/
public function showRegister(): View
{
return view('auth.register', [
'captcha' => self::makeCaptcha(),
]);
}
/**
* Handle the registration form submission.
*/
public function register(RegisterRequest $request): RedirectResponse
{
$data = $request->validated();
$user = User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => $data['password'],
'role' => User::ROLE_USER,
'locale' => $data['locale'] ?? 'pt_BR',
'is_active' => true,
]);
Auth::login($user);
$request->session()->regenerate();
return redirect()->route('terms.accept');
}
/**
* Log the user out of the web session.
*/
public function logout(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login');
}
/**
* Build a fresh math CAPTCHA challenge.
*
* @return array{a: int, b: int, question: string}
*/
private static function makeCaptcha(): array
{
$a = random_int(1, 9);
$b = random_int(1, 9);
session()->put('captcha_result', $a + $b);
session()->put('captcha_expires_at', now()->addMinutes(10)->getTimestamp());
return [
'a' => $a,
'b' => $b,
'question' => sprintf('%d + %d = ?', $a, $b),
];
}
}
+204
View File
@@ -0,0 +1,204 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\AiAgent;
use App\Models\Store;
use Illuminate\Http\Request;
use Illuminate\Http\StreamedResponse;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
/**
* NPC chat endpoints consumed by the 3D city viewer.
*
* The POST endpoint accepts a user message, enqueues the OpenRouter call
* asynchronously and immediately returns 202 so the UI can show a
* "thinking" state. The GET endpoint streams agent replies as SSE events.
* Only simulated city context (never personal data) is sent upstream,
* as required by RULES.md.
*/
class ChatController extends Controller
{
/**
* Send a message to an agent and trigger an asynchronous reply.
*/
public function send(Request $request, AiAgent $agent): Response
{
$data = $request->validate([
'message' => ['required', 'string', 'max:2000'],
]);
// Persist the conversation in the agent's last_decision for audit
// and stream the pending state to any connected SSE listener.
$history = $agent->last_decision ?? [];
$history[] = ['role' => 'user', 'content' => $data['message'], 'at' => now()->toIso8601String()];
$agent->update([
'last_decision' => array_slice($history, -20),
'status' => 'thinking',
]);
self::publish($agent, ['type' => 'thinking', 'agent_id' => $agent->id, 'status' => 'thinking']);
// Fire the OpenRouter call without blocking the HTTP response.
dispatch(function () use ($agent, $data): void {
self::think($agent, $data['message']);
});
return response()->json([
'status' => 'accepted',
'agent_id' => $agent->id,
'message' => __('city.chat_accepted'),
], 202);
}
/**
* Stream agent status and replies as Server-Sent Events.
*/
public function stream(Request $request, AiAgent $agent): StreamedResponse
{
$request->session()->save();
return response()->stream(function () use ($agent): void {
echo "retry: 3000\n\n";
flush();
$last = null;
while (true) {
if (connection_aborted()) {
break;
}
// Poll the cache for a message published by think().
$payload = cache()->pull("chat.{$agent->id}");
if ($payload !== null) {
$json = json_encode($payload, JSON_UNESCAPED_UNICODE);
echo "event: message\n";
echo "data: {$json}\n\n";
flush();
$last = $payload;
}
// Heartbeat keeps proxies from closing the stream.
echo ": ping\n\n";
flush();
sleep(2);
}
}, 200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
'X-Accel-Buffering' => 'no',
]);
}
/**
* Call OpenRouter with the agent persona and simulated city context.
*
* Never includes personal data — only city state (RULES.md 2.2).
*/
private static function think(AiAgent $agent, string $userMessage): void
{
try {
$apiKey = config('ai.openrouter.api_key');
if (! filled($apiKey)) {
self::fail($agent, 'OPENROUTER_API_KEY not configured');
return;
}
// Simulated city context only — no PII (RULES.md 2.2).
$cityContext = [
'stores' => Store::query()
->get(['name', 'type', 'is_open'])
->map(fn ($s) => ['name' => $s->name, 'type' => $s->type, 'open' => $s->is_open])
->all(),
'agent_name' => $agent->name,
'agent_type' => $agent->type,
'current_goal' => $agent->current_goal,
];
$system = ($agent->system_prompt ?: "Você é {$agent->name}, um habitante da cidade virtual City10.")
."\n\nContexto simulado da cidade: ".json_encode($cityContext, JSON_UNESCAPED_UNICODE)
."\nResponda de forma curta e natural, em português. Não invente dados pessoais de usuários.";
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$apiKey,
'HTTP-Referer' => config('ai.openrouter.app_url'),
'X-Title' => config('ai.openrouter.app_name'),
])->timeout((int) config('ai.openrouter.timeout', 30))
->post(config('ai.openrouter.base_url').'/chat/completions', [
'model' => $agent->model,
'messages' => [
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $userMessage],
],
'max_tokens' => 300,
]);
if ($response->failed()) {
self::fail($agent, 'HTTP '.$response->status());
return;
}
$reply = $response->json('choices.0.message.content') ?? __('city.chat_error');
$history = $agent->last_decision ?? [];
$history[] = ['role' => 'assistant', 'content' => $reply, 'at' => now()->toIso8601String()];
$agent->update([
'last_decision' => array_slice($history, -20),
'status' => 'active',
'error_count' => 0,
'last_think_at' => now(),
]);
self::publish($agent, [
'type' => 'reply',
'agent_id' => $agent->id,
'name' => $agent->name,
'reply' => $reply,
'status' => 'active',
]);
} catch (\Throwable $e) {
Log::error('NPC think failed', ['agent' => $agent->id, 'error' => $e->getMessage()]);
self::fail($agent, $e->getMessage());
}
}
/**
* Record a failure with exponential backoff (RULES.md 2.4).
*/
private static function fail(AiAgent $agent, string $reason): void
{
$errors = (int) $agent->error_count + 1;
$backoff = min(300, 2 ** min($errors, 8));
$agent->update([
'status' => $errors >= 5 ? 'error' : 'active',
'error_count' => $errors,
]);
self::publish($agent, [
'type' => 'error',
'agent_id' => $agent->id,
'reply' => __('city.chat_error'),
'status' => $agent->status,
'retry_after' => $backoff,
]);
}
/**
* Publish a message for the SSE stream to pick up.
*/
private static function publish(AiAgent $agent, array $payload): void
{
cache()->put("chat.{$agent->id}", $payload, now()->addMinutes(5));
}
}
@@ -0,0 +1,123 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\AiAgent;
use App\Models\Store;
use Illuminate\Http\JsonResponse;
use Illuminate\View\View;
/**
* 3D city viewer.
*
* Serves the Three.js scene page plus a lightweight JSON payload of stores
* and agents. The JSON endpoint reuses the web session so the browser can
* fetch world data without a separate API token.
*/
class CityViewerController extends Controller
{
/**
* Render the city viewer page.
*/
public function __invoke(): View
{
return view('city.viewer');
}
/**
* Return the world snapshot consumed by the viewer scene.
*/
public function data(): JsonResponse
{
$stores = Store::query()
->get(['id', 'name', 'type', 'pos_x', 'pos_y', 'pos_z', 'width', 'height', 'depth', 'is_open'])
->map(fn (Store $store): array => [
'id' => $store->id,
'name' => $store->name,
'type' => $store->type,
'position' => [(float) $store->pos_x, (float) $store->pos_y, (float) $store->pos_z],
'size' => [(float) $store->width, (float) $store->height, (float) $store->depth],
'is_open' => $store->is_open,
]);
$agents = AiAgent::query()
->where('status', '!=', 'paused')
->get(['id', 'name', 'type', 'status', 'pos_x', 'pos_y', 'pos_z', 'rotation_y', 'last_decision'])
->map(fn (AiAgent $agent): array => [
'id' => $agent->id,
'name' => $agent->name,
'type' => $agent->type,
'status' => $agent->status,
'position' => [(float) $agent->pos_x, (float) $agent->pos_y, (float) $agent->pos_z],
'rotation_y' => (float) $agent->rotation_y,
'last_decision' => $agent->last_decision,
]);
return response()->json([
'generated_at' => now()->toIso8601String(),
'stores' => $stores,
'agents' => $agents,
'layout' => self::cityLayout($stores->all()),
])->header('Cache-Control', 'no-store');
}
/**
* Derive a deterministic street layout from store positions.
*
* Returns road segments and block rectangles so the frontend can build
* a consistent grid-based city without additional database tables.
*
* @param array<int, array<string, mixed>> $stores
* @return array<string, mixed>
*/
private static function cityLayout(array $stores): array
{
if ($stores === []) {
return ['roads' => [], 'blocks' => []];
}
$xs = array_map(fn ($s) => $s['position'][0], $stores);
$zs = array_map(fn ($s) => $s['position'][2], $stores);
$minX = floor(min($xs) / 40) * 40 - 40;
$maxX = ceil(max($xs) / 40) * 40 + 40;
$minZ = floor(min($zs) / 40) * 40 - 40;
$maxZ = ceil(max($zs) / 40) * 40 + 40;
$roads = [];
// Vertical roads every 40 units.
for ($x = $minX; $x <= $maxX; $x += 40) {
$roads[] = [
'from' => [(float) $x, 0.0, (float) $minZ],
'to' => [(float) $x, 0.0, (float) $maxZ],
'width' => 8.0,
'axis' => 'z',
];
}
// Horizontal roads every 40 units.
for ($z = $minZ; $z <= $maxZ; $z += 40) {
$roads[] = [
'from' => [(float) $minX, 0.0, (float) $z],
'to' => [(float) $maxX, 0.0, (float) $z],
'width' => 8.0,
'axis' => 'x',
];
}
// Blocks = cells between roads.
$blocks = [];
for ($x = $minX; $x < $maxX; $x += 40) {
for ($z = $minZ; $z < $maxZ; $z += 40) {
$blocks[] = [
'min' => [(float) $x + 5.0, 0.0, (float) $z + 5.0],
'max' => [(float) $x + 35.0, 0.0, (float) $z + 35.0],
];
}
}
return ['roads' => $roads, 'blocks' => $blocks];
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* Dashboard controller for the authenticated web area.
*
* Phase 1 renders an overview panel; the full admin console and 3D city
* viewer are delivered in subsequent phases.
*/
class DashboardController extends Controller
{
/**
* Show the user dashboard overview.
*/
public function __invoke(Request $request): View
{
$user = $request->user();
return view('dashboard.index', [
'user' => $user,
'isAdmin' => $user->isAdmin(),
'storeCount' => $user->stores()->count(),
]);
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
/**
* Language switcher controller.
*
* Persists the selected locale (pt_BR, en, es) in the session and in the
* user's profile when authenticated, so the whole UI is rendered in the
* language chosen from the dashboard or the public pages.
*/
class LanguageController extends Controller
{
/**
* Switch the application locale for the current visitor.
*/
public function switch(Request $request): RedirectResponse
{
$locale = $request->input('locale');
if (! in_array($locale, ['pt_BR', 'en', 'es'], true)) {
return back();
}
App::setLocale($locale);
$request->session()->put('locale', $locale);
if ($user = $request->user()) {
$user->update(['locale' => $locale]);
}
// Persist the cookie preference (1 year) used on subsequent visits.
$response = back();
$response->withCookie(cookie('city10-locale', $locale, 60 * 24 * 365, '/', null, false, true));
return $response;
}
}
@@ -0,0 +1,120 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Http\Requests\ConsentRequest;
use App\Models\LegalDocument;
use App\Models\UserConsent;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
/**
* Web controllers for legal documents and consent management.
*
* Renders the Terms of Use, Privacy Policy and Cookie Policy and records
* the auditable acceptance required by the Brazilian LGPD before the
* user can access the main platform features.
*/
class LegalController extends Controller
{
/**
* Show the current version of a legal document.
*/
public function show(string $type): View
{
$document = LegalDocument::query()
->where('type', $type)
->where('is_current', true)
->firstOrFail();
$locale = app()->getLocale();
return view('legal.show', [
'document' => $document,
'title' => $document->title[$locale] ?? ($document->title['pt_BR'] ?? $document->type),
'body' => $document->body[$locale] ?? ($document->body['pt_BR'] ?? ''),
]);
}
/**
* Show the first-access terms acceptance screen.
*/
public function acceptForm(Request $request): View|RedirectResponse
{
$user = $request->user();
$current = LegalDocument::query()->where('is_current', true)->orderBy('type')->get();
if ($current->isEmpty()) {
return redirect()->route('dashboard');
}
$acceptedIds = $user->consents()->pluck('legal_document_id');
$pending = $current->reject(fn (LegalDocument $doc) => $acceptedIds->contains($doc->id));
if ($pending->isEmpty()) {
return redirect()->route('dashboard');
}
$locale = app()->getLocale();
$documents = $pending->map(fn (LegalDocument $doc): array => [
'type' => $doc->type,
'version' => $doc->version,
'title' => $doc->title[$locale] ?? ($doc->title['pt_BR'] ?? $doc->type),
'body' => $doc->body[$locale] ?? ($doc->body['pt_BR'] ?? ''),
])->values();
return view('legal.accept', [
'documents' => $documents,
]);
}
/**
* Record the acceptance of all current legal documents.
*/
public function accept(ConsentRequest $request): RedirectResponse
{
$user = $request->user();
$documents = LegalDocument::query()->where('is_current', true)->get();
DB::transaction(function () use ($user, $documents, $request): void {
foreach ($documents as $document) {
UserConsent::updateOrCreate(
[
'user_id' => $user->id,
'legal_document_id' => $document->id,
],
[
'accepted_at' => now(),
'ip_address' => $request->ip(),
'user_agent' => (string) $request->userAgent(),
'source' => 'web_form',
],
);
}
});
return redirect()->route('dashboard')->with('status', __('legal.accepted'));
}
/**
* Display the consent audit history of the authenticated user.
*/
public function history(Request $request): View
{
$history = $request->user()
->consents()
->with('document:id,type,version')
->orderByDesc('accepted_at')
->get();
return view('legal.history', [
'history' => $history,
]);
}
}