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.
677 lines
25 KiB
PHP
Executable File
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,
|
|
],
|
|
];
|
|
}
|
|
}
|