Add university and fire station civic buildings plus 7 new store types, each with facade and luminous blade signs. Extend landmark material colors and sign accents. Fix ATC dispatch to reject prompt-echoing AI responses via heuristic guard. Add CityPlacesTest for collision and roadway avoidance, and extend AirportTest and SignLabelsTest.
481 lines
18 KiB
PHP
Executable File
481 lines
18 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 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),
|
|
'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)],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Call the tower for one short clearance phrase; null on failure.
|
|
*
|
|
* Tries the configured ATC model first (AI_ATC_MODEL), then the
|
|
* agent's own model and finally the platform default — all
|
|
* allow-listed — before the caller stores the deterministic
|
|
* fallback. Clearances stay AI-generated even while a preferred id
|
|
* is missing from the live OpenRouter catalogue.
|
|
*
|
|
* @param array{number: string, airline: string, runway: string} $flight
|
|
*/
|
|
private static function askTower(AiAgent $atc, array $flight): ?string
|
|
{
|
|
// Without an API key there is nothing to ask (no try consumed).
|
|
if (! filled(config('ai.openrouter.api_key'))) {
|
|
return null;
|
|
}
|
|
|
|
$allowed = config('ai.allowed_models', []);
|
|
$candidates = [];
|
|
foreach ([config('ai.atc_model'), $atc->model, config('ai.default_model'), config('ai.fallback_model')] as $model) {
|
|
if (is_string($model) && $model !== '' && in_array($model, $allowed, true)) {
|
|
$candidates[$model] = $model;
|
|
}
|
|
}
|
|
|
|
$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.';
|
|
|
|
$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('ATC tower model exception', ['model' => $model, 'error' => $e->getMessage()]);
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($response->failed()) {
|
|
Log::warning('ATC tower 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 (self::looksLikeClearance($text, $flight)) {
|
|
return $text;
|
|
}
|
|
// Model echoed the instruction instead of the clearance:
|
|
// reject and continue down the chain (next model or the
|
|
// deterministic fallback in the caller).
|
|
Log::warning('ATC clearance rejected (not a clearance)', ['model' => $model]);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
|
|
$promptMeta = [
|
|
'the user wants',
|
|
'respond only',
|
|
'no quotes',
|
|
'atc style',
|
|
'short sentence',
|
|
'for example',
|
|
'instructions',
|
|
];
|
|
foreach ($promptMeta 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;
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
],
|
|
];
|
|
}
|
|
}
|