Files
City10/app/Http/Controllers/Web/CityViewerController.php
T
marcocosta10000 580c830ffb feat(port): add sea port with quay, terminal, berths, and AI port master
Adds a new port district on the north-east coast with walkable quay, container yard, terminal, gantry cranes, and berths. Includes AI-driven port master (Capitão do Porto) and dock workers for ship dispatch, plus full frontend rendering of ships, boats, and trucks.
2026-10-31 23:33:06 -03:00

677 lines
25 KiB
PHP
Executable File

<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\AiAgent;
use App\Models\Building;
use App\Models\Character;
use App\Models\City;
use App\Models\Job;
use App\Models\Store;
use App\Support\Airports;
use App\Support\Beach;
use App\Support\CityPlazas;
use App\Support\Port;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
/**
* City viewer entry point and JSON payload.
*
* Renders the 3D scene for a chosen city and exposes characters, buildings
* and jobs so the frontend can simulate a living persistent world.
*/
class CityViewerController extends Controller
{
/**
* Render the 3D city viewer page.
*/
public function __invoke(Request $request): View
{
$city = $this->resolveCity($request);
return view('city.viewer', [
'city' => $city,
]);
}
/**
* JSON payload driving the 3D scene for the selected city.
*/
public function data(Request $request): JsonResponse
{
$city = $this->resolveCity($request);
$user = Auth::user();
$me = $user ? Character::ensureForUser($user, $city) : null;
if ($me && (int) $me->city_id !== (int) $city->id) {
// Keep the player's home city authoritative when no explicit ?city= given.
if (! $request->filled('city')) {
$city = $me->city;
}
}
$stores = Store::query()
->orderBy('id')
->get(['id', 'name', 'type', 'pos_x', 'pos_y', 'pos_z', 'width', 'height', 'depth', 'is_open', 'owner_id'])
->map(fn (Store $s): array => [
'id' => $s->id,
'name' => $s->name,
'type' => $s->type,
'position' => [(float) $s->pos_x, (float) $s->pos_y, (float) $s->pos_z],
'size' => [(float) $s->width, (float) $s->height, (float) $s->depth],
'is_open' => (bool) $s->is_open,
'owner_id' => $s->owner_id,
]);
$characters = Character::query()
->where('city_id', $city->id)
->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' => $me !== null && (int) $me->id === (int) $c->id,
'owner_name' => $c->user?->name,
]);
$buildings = Building::query()
->where('city_id', $city->id)
->get()
->map(fn (Building $b): array => [
'id' => $b->id,
'type' => $b->type,
'name' => $b->name,
'position' => [(float) $b->pos_x, 0, (float) $b->pos_z],
'size' => [(float) $b->width, (float) $b->height, (float) $b->depth],
'is_complete' => (bool) $b->is_complete,
'build_progress' => (int) $b->build_progress,
'owner_character_id' => $b->owner_character_id,
]);
$jobs = Job::query()
->where('city_id', $city->id)
->get()
->map(fn (Job $j): array => [
'id' => $j->id,
'title' => $j->title,
'salary' => (float) $j->salary,
'requires_education' => (int) $j->requires_education,
'store_id' => $j->store_id,
]);
$payload = [
'generated_at' => now()->toIso8601String(),
'city' => [
'id' => $city->id,
'name' => $city->name,
'slug' => $city->slug,
'bounds' => [
'min_x' => (float) $city->bounds_min_x,
'max_x' => (float) $city->bounds_max_x,
'min_z' => (float) $city->bounds_min_z,
'max_z' => (float) $city->bounds_max_z,
],
],
'stores' => $stores,
'characters' => $characters,
'buildings' => $buildings,
'jobs' => $jobs,
'me' => $me ? [
'id' => $me->id,
'name' => $me->name,
'state' => $me->state,
'money' => (float) $me->money,
'energy' => (int) $me->energy,
'hunger' => (int) $me->hunger,
'thirst' => (int) $me->thirst,
'health' => (int) $me->health,
'education' => (int) $me->education,
'exercise_count' => (int) $me->exercise_count,
'job_id' => $me->job_id,
'job_title' => $me->job?->title,
'house_id' => $me->house_id,
'position' => [(float) $me->pos_x, (float) $me->pos_y, (float) $me->pos_z],
'hospitalized_until' => $me->hospitalized_until?->toIso8601String(),
'inventory' => $me->inventory()->get()->mapWithKeys(fn ($i) => [$i->item => (int) $i->qty])->all(),
] : null,
'layout' => $this->cityLayout($city, $stores->all()),
'plazas' => CityPlazas::forCity($city),
'beach' => Beach::geometry($city),
'airports' => Airports::geometry($city),
'port' => Port::geometry($city),
'agents' => AiAgent::orderBy('id')->get()->map(fn ($a): array => [
'id' => $a->id,
'name' => $a->name,
'type' => $a->type,
'status' => $a->status,
'position' => [(float) $a->pos_x, (float) $a->pos_y, (float) $a->pos_z],
'rotation_y' => (float) $a->rotation_y,
'current_goal' => $a->current_goal,
'last_decision' => $a->last_decision,
]),
];
return response()->json($payload)->header('Cache-Control', 'no-store');
}
/**
* Airport summary: sites, control-tower agent and latest clearance.
*
* The client polls this while aircraft operate so AI-generated tower
* clearances (AI_ATC_MODEL) show up as they are produced.
*/
public function airport(Request $request): JsonResponse
{
$city = $this->resolveCity($request);
$atc = AiAgent::query()->where('type', 'atc')->first();
return response()->json([
'sites' => array_map(
static fn (array $site): array => ['id' => $site['id'], 'name' => $site['name'], 'code' => $site['code']],
Airports::geometry($city)['sites']
),
'atc' => $atc !== null ? [
'id' => (int) $atc->id,
'name' => $atc->name,
'model' => $atc->model,
'status' => $atc->status,
'error_count' => (int) $atc->error_count,
] : null,
'clearance' => cache()->get('airport.clearance'),
])->header('Cache-Control', 'no-store');
}
/**
* Ask the control tower for the next takeoff clearance (fire and
* forget). Internally throttled: one generation per 30 seconds.
*/
public function dispatch(): JsonResponse
{
if (! cache()->add('airport.dispatch.lock', 1, 30)) {
return response()->json(['accepted' => false, 'reason' => 'cooldown'], 202);
}
$atc = AiAgent::query()->where('type', 'atc')->first();
if ($atc === null) {
return response()->json(['message' => __('city.atc_missing')], 422);
}
dispatch(function (): void {
self::requestClearance();
})->afterResponse();
return response()->json(['accepted' => true], 202);
}
/**
* Generate one ATC clearance with the configured tower model.
*
* Runs after the response (no queue worker needed). The payload only
* carries simulated flight data — no user PII (RULES.md 2.2). On any
* failure a deterministic clearance is stored so operations continue
* and the failure is counted for backoff (RULES.md 2.4).
*/
public static function requestClearance(): void
{
try {
$atc = AiAgent::query()->where('type', 'atc')->first();
if ($atc === null) {
return;
}
$flight = self::clearanceFlight();
// Backoff (RULES.md 2.4): after 5 consecutive failures only
// retry every 5 minutes; never call without an API key.
$mayTry = false;
if (filled(config('ai.openrouter.api_key'))) {
$lastTry = (int) cache()->get('airport.atc.last_try', 0);
$mayTry = (int) $atc->error_count < 5 || $lastTry < time() - 300;
if ($mayTry) {
cache()->put('airport.atc.last_try', time(), now()->addHour());
}
}
$text = $mayTry ? self::askTower($atc, $flight) : null;
if ($text === null) {
// Count real attempts only (backoff-blocked skips and
// missing-key short-circuits keep their own semantics).
$attempted = $mayTry || ! filled(config('ai.openrouter.api_key'));
$errors = (int) $atc->error_count + ($attempted ? 1 : 0);
$atc->update([
'status' => $errors >= 5 ? 'error' : 'active',
'error_count' => $errors,
]);
$text = sprintf(
'Torre: %s (%s) autorizado para decolagem na pista %s.',
$flight['number'],
$flight['airline'],
$flight['runway']
);
} else {
$atc->update([
'status' => 'active',
'error_count' => 0,
'last_think_at' => now(),
]);
}
cache()->put('airport.clearance', [
'text' => $text,
'flight' => $flight['number'],
'airline' => $flight['airline'],
'runway' => $flight['runway'],
'source' => $atc->error_count > 0 ? 'fallback' : 'model',
'at' => now()->toIso8601String(),
], now()->addHours(6));
$log = $atc->last_decision ?? [];
$log[] = ['role' => 'atc', 'content' => $text, 'at' => now()->toIso8601String()];
$atc->update(['last_decision' => array_slice($log, -20)]);
} catch (\Throwable $e) {
Log::warning('ATC clearance generation failed', ['error' => $e->getMessage()]);
}
}
/**
* Random simulated flight for the next clearance (no real bookings).
*
* @return array{number: string, airline: string, runway: string}
*/
private static function clearanceFlight(): array
{
$airlines = ['City10 Air', 'AzulCéu', 'Bandeirante', 'Sol Nascente', 'Pampa Air', 'NorteLine'];
$runways = ['09', '27', '18', '36'];
return [
'number' => 'C10-'.random_int(101, 399),
'airline' => $airlines[array_rand($airlines)],
'runway' => $runways[array_rand($runways)],
];
}
/**
* Prompt-echo markers shared by every clearance guard: replies that
* restate the instruction instead of speaking are rejected.
*
* @var list<string>
*/
private const PROMPT_META = [
'the user wants',
'respond only',
'no quotes',
'atc style',
'short sentence',
'for example',
'instructions',
];
/**
* Run a clearance prompt across a model chain (extra models first —
* e.g. AI_ATC_MODEL — then the agent model, the platform default and
* AI_FALLBACK_MODEL), returning the first reply accepted by $accept.
*
* Without an API key nothing is attempted (no try consumed).
*
* @param callable(string): bool|null $accept per-model shape guard
* @param list<string|null>|null $extraModels models tried first
*/
private static function askWithChain(AiAgent $agent, string $prompt, ?callable $accept = null, ?array $extraModels = null): ?string
{
if (! filled(config('ai.openrouter.api_key'))) {
return null;
}
$allowed = config('ai.allowed_models', []);
$candidates = [];
$chain = array_merge($extraModels ?? [], [
$agent->model,
config('ai.default_model'),
config('ai.fallback_model'),
]);
foreach ($chain as $model) {
if (is_string($model) && $model !== '' && in_array($model, $allowed, true)) {
$candidates[$model] = $model;
}
}
$timeout = min((int) config('ai.openrouter.timeout', 30), 15);
foreach ($candidates as $model) {
try {
$response = Http::withHeaders([
'Authorization' => 'Bearer '.config('ai.openrouter.api_key'),
'HTTP-Referer' => config('ai.openrouter.app_url'),
'X-Title' => config('ai.openrouter.app_name'),
])->timeout($timeout)
->post(config('ai.openrouter.base_url').'/chat/completions', [
'model' => $model,
'messages' => [['role' => 'user', 'content' => $prompt]],
'max_tokens' => 120,
]);
} catch (\Throwable $e) {
Log::warning('Clearance model exception', ['model' => $model, 'error' => $e->getMessage()]);
continue;
}
if ($response->failed()) {
Log::warning('Clearance model failed', ['model' => $model, 'status' => $response->status()]);
continue;
}
$text = $response->json('choices.0.message.content');
if (is_array($text)) {
$text = implode('', array_map(
static fn ($part): string => is_array($part) ? (string) ($part['text'] ?? '') : (string) $part,
$text
));
}
if (is_string($text) && trim($text) !== '') {
$text = mb_substr(trim($text), 0, 240);
if ($accept === null || $accept($text)) {
return $text;
}
// Echoed the instruction instead of the phrase: reject and
// continue down the chain (next model, then the caller's
// deterministic fallback).
Log::warning('Clearance rejected (not a clearance)', ['model' => $model]);
}
}
return null;
}
/**
* One ATC takeoff clearance (chain tries AI_ATC_MODEL first).
*
* @param array{number: string, airline: string, runway: string} $flight
*/
private static function askTower(AiAgent $atc, array $flight): ?string
{
$prompt = 'Você é a torre de controle aéreo do Aeroporto City10. Autorize em UMA frase '
.'curta, em português, estilo controle de tráfego aéreo, a decolagem do voo '
.$flight['number'].' ('.$flight['airline'].') na pista '.$flight['runway']
.'. Responda apenas com a frase de autorização, sem aspas nem explicações.';
return self::askWithChain(
$atc,
$prompt,
static fn (string $text): bool => self::looksLikeClearance($text, $flight),
[config('ai.atc_model')]
);
}
/**
* One port-movement clearance from the Capitania (maritime phrasing).
*
* @param array{ship: string, company: string, action: string, action_text: string, berth: int} $maneuver
*/
private static function askPort(AiAgent $captain, array $maneuver): ?string
{
$prompt = 'Você é a Capitania do Porto City10. Autorize em UMA frase curta, em português, '
.'estilo marítimo de controle portuário, a '.$maneuver['action_text'].' do navio '
.$maneuver['ship'].' ('.$maneuver['company'].') no berço '.$maneuver['berth']
.'. Responda apenas com a frase de autorização, sem aspas nem explicações.';
return self::askWithChain($captain, $prompt, static function (string $text) use ($maneuver): bool {
$lower = mb_strtolower($text);
foreach (self::PROMPT_META as $needle) {
if (str_contains($lower, $needle)) {
return false;
}
}
return str_contains($text, $maneuver['ship'])
|| preg_match('/autoriz|atrac|cais|berço|berco|manobr|liberad|deixar|partida/iu', $text) === 1;
});
}
/**
* Heuristic guard: a clearance is short, speech-like and about the
* requested flight — never a restatement of the prompt itself.
*
* @param array{number: string, airline: string, runway: string} $flight
*/
private static function looksLikeClearance(string $text, array $flight): bool
{
$lower = mb_strtolower($text);
foreach (self::PROMPT_META as $needle) {
if (str_contains($lower, $needle)) {
return false;
}
}
return str_contains($text, $flight['number'])
|| preg_match('/autoriz|pista|runway|cleared|descolagem|decolag/iu', $text) === 1;
}
/**
* Port summary: geometry, Capitania agent and latest maneuver
* clearance (generated by the port AI agent on dispatch).
*/
public function port(Request $request): JsonResponse
{
$city = $this->resolveCity($request);
$captain = AiAgent::query()->where('type', 'port_master')->first();
return response()->json([
'geometry' => Port::geometry($city),
'captain' => $captain !== null ? [
'id' => (int) $captain->id,
'name' => $captain->name,
'model' => $captain->model,
'status' => $captain->status,
'error_count' => (int) $captain->error_count,
] : null,
'clearance' => cache()->get('port.clearance'),
])->header('Cache-Control', 'no-store');
}
/**
* Ask the Capitania for the next port-movement clearance (fire and
* forget; one generation per 30 seconds internally).
*/
public function dispatchPort(): JsonResponse
{
if (! cache()->add('port.dispatch.lock', 1, 30)) {
return response()->json(['accepted' => false, 'reason' => 'cooldown'], 202);
}
$captain = AiAgent::query()->where('type', 'port_master')->first();
if ($captain === null) {
return response()->json(['message' => __('city.port_missing')], 422);
}
dispatch(function (): void {
self::requestPortClearance();
})->afterResponse();
return response()->json(['accepted' => true], 202);
}
/**
* Generate one port-movement clearance with the Capitania model.
*
* Runs after the response (no queue worker). The structured fields
* (action/ship/berth) are stored with or without AI, so the client
* can couple real ship maneuvers to the authorisation; text uses
* simulated vessel data only — no user PII (RULES.md 2.2). Failures
* are counted for backoff (RULES.md 2.4) and logged to the agent's
* decision history (RULES.md 2.5).
*/
public static function requestPortClearance(): void
{
try {
$captain = AiAgent::query()->where('type', 'port_master')->first();
if ($captain === null) {
return;
}
$maneuver = self::portManeuver();
$mayTry = false;
if (filled(config('ai.openrouter.api_key'))) {
$lastTry = (int) cache()->get('port.last_try', 0);
$mayTry = (int) $captain->error_count < 5 || $lastTry < time() - 300;
if ($mayTry) {
cache()->put('port.last_try', time(), now()->addHour());
}
}
$text = $mayTry ? self::askPort($captain, $maneuver) : null;
if ($text === null) {
$attempted = $mayTry || ! filled(config('ai.openrouter.api_key'));
$errors = (int) $captain->error_count + ($attempted ? 1 : 0);
$captain->update([
'status' => $errors >= 5 ? 'error' : 'active',
'error_count' => $errors,
]);
$text = sprintf(
'Capitania: %s (%s), %s no berço %s, autorizado.',
$maneuver['ship'],
$maneuver['company'],
$maneuver['action_text'],
$maneuver['berth']
);
} else {
$captain->update([
'status' => 'active',
'error_count' => 0,
'last_think_at' => now(),
]);
}
cache()->put('port.clearance', [
'text' => $text,
'action' => $maneuver['action'],
'action_text' => $maneuver['action_text'],
'ship' => $maneuver['ship'],
'company' => $maneuver['company'],
'berth' => $maneuver['berth'],
'source' => $captain->error_count > 0 ? 'fallback' : 'model',
'at' => now()->toIso8601String(),
], now()->addHours(6));
$log = $captain->last_decision ?? [];
$log[] = ['role' => 'port', 'content' => $text, 'at' => now()->toIso8601String()];
$captain->update(['last_decision' => array_slice($log, -20)]);
} catch (\Throwable $e) {
Log::warning('Port clearance generation failed', ['error' => $e->getMessage()]);
}
}
/**
* Random simulated maneuver for the next clearance (no real traffic).
*
* @return array{ship: string, company: string, action: string, action_text: string, berth: int}
*/
private static function portManeuver(): array
{
$ships = [
['CITY VOYAGER', 'City10 Cargo'],
['AZUL MAR', 'AzulCéu Cargo'],
['ATLÂNTICO CELESTE', 'NorteLine'],
];
[$ship, $company] = $ships[array_rand($ships)];
$actions = [
['berth', 'atracação'],
['depart', 'partida'],
];
[$action, $actionText] = $actions[array_rand($actions)];
return [
'ship' => $ship,
'company' => $company,
'action' => $action,
'action_text' => $actionText,
'berth' => random_int(1, 2),
];
}
/**
* Resolve the city from the query string or the active default.
*/
private function resolveCity(Request $request): City
{
$slug = $request->query('city');
if (is_string($slug) && $slug !== '') {
$city = City::query()
->where('slug', $slug)
->orWhere('id', ctype_digit($slug) ? (int) $slug : 0)
->first();
if ($city) {
return $city;
}
}
$user = Auth::user();
if ($user) {
$character = Character::query()->where('user_id', $user->id)->first();
if ($character?->city) {
return $character->city;
}
}
return City::active() ?? City::query()->firstOrFail();
}
/**
* Build the street grid layout from city bounds and store anchors.
*
* @param array<int, array<string, mixed>> $storeRows
* @return array<string, mixed>
*/
private function cityLayout(City $city, array $storeRows): 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;
$roads = [];
for ($x = $minX; $x <= $maxX; $x += 40) {
$roads[] = ['from' => [$x, 0, $minZ], 'to' => [$x, 0, $maxZ], 'width' => 8, 'axis' => 'z'];
}
for ($z = $minZ; $z <= $maxZ; $z += 40) {
$roads[] = ['from' => [$minX, 0, $z], 'to' => [$maxX, 0, $z], 'width' => 8, 'axis' => 'x'];
}
$blocks = [];
for ($x = $minX; $x < $maxX; $x += 40) {
for ($z = $minZ; $z < $maxZ; $z += 40) {
$blocks[] = ['min' => [$x + 5, 0, $z + 5], 'max' => [$x + 35, 0, $z + 35]];
}
}
return [
'roads' => $roads,
'blocks' => $blocks,
'origin' => [0, 0, 0],
'bounds' => [
'min_x' => $minX,
'max_x' => $maxX,
'min_z' => $minZ,
'max_z' => $maxZ,
],
];
}
}