diff --git a/.playwright-mcp/porto-cais-navios.png b/.playwright-mcp/porto-cais-navios.png new file mode 100644 index 0000000..80905ae Binary files /dev/null and b/.playwright-mcp/porto-cais-navios.png differ diff --git a/.playwright-mcp/porto-patio-caminhoes.png b/.playwright-mcp/porto-patio-caminhoes.png new file mode 100644 index 0000000..4e94b45 Binary files /dev/null and b/.playwright-mcp/porto-patio-caminhoes.png differ diff --git a/app/Http/Controllers/Web/CityActionController.php b/app/Http/Controllers/Web/CityActionController.php index 04c7ba1..4ef91f9 100755 --- a/app/Http/Controllers/Web/CityActionController.php +++ b/app/Http/Controllers/Web/CityActionController.php @@ -13,6 +13,7 @@ use App\Support\Airports; use App\Support\Beach; use App\Support\CityPlazas; use App\Support\MissionTracker; +use App\Support\Port; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -62,6 +63,11 @@ class CityActionController extends Controller $minZ = Airports::VISIT_MIN_Z; $maxZ = Airports::VISIT_MAX_Z; } + // Port strip (north-east coast): walking east of the bounds opens + // the Z range down to the port grounds/quay. + if ($x >= Port::VISIT_MIN_X) { + $minZ = Port::VISIT_MIN_Z; + } $z = max($minZ, min($maxZ, (float) $data['pos_z'])); $character->forceFill([ diff --git a/app/Http/Controllers/Web/CityViewerController.php b/app/Http/Controllers/Web/CityViewerController.php index af40a5f..04ee0a4 100755 --- a/app/Http/Controllers/Web/CityViewerController.php +++ b/app/Http/Controllers/Web/CityViewerController.php @@ -12,6 +12,7 @@ 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; @@ -147,6 +148,7 @@ class CityViewerController extends Controller '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, @@ -300,36 +302,50 @@ class CityViewerController extends Controller } /** - * Call the tower for one short clearance phrase; null on failure. + * Prompt-echo markers shared by every clearance guard: replies that + * restate the instruction instead of speaking are rejected. * - * 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 + * @var list */ - private static function askTower(AiAgent $atc, array $flight): ?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|null $extraModels models tried first + */ + private static function askWithChain(AiAgent $agent, string $prompt, ?callable $accept = null, ?array $extraModels = null): ?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) { + $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; } } - $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) { @@ -345,13 +361,13 @@ class CityViewerController extends Controller 'max_tokens' => 120, ]); } catch (\Throwable $e) { - Log::warning('ATC tower model exception', ['model' => $model, 'error' => $e->getMessage()]); + Log::warning('Clearance model exception', ['model' => $model, 'error' => $e->getMessage()]); continue; } if ($response->failed()) { - Log::warning('ATC tower model failed', ['model' => $model, 'status' => $response->status()]); + Log::warning('Clearance model failed', ['model' => $model, 'status' => $response->status()]); continue; } @@ -366,19 +382,64 @@ class CityViewerController extends Controller if (is_string($text) && trim($text) !== '') { $text = mb_substr(trim($text), 0, 240); - if (self::looksLikeClearance($text, $flight)) { + if ($accept === null || $accept($text)) { 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]); + // 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. @@ -389,16 +450,7 @@ class CityViewerController extends Controller { $lower = mb_strtolower($text); - $promptMeta = [ - 'the user wants', - 'respond only', - 'no quotes', - 'atc style', - 'short sentence', - 'for example', - 'instructions', - ]; - foreach ($promptMeta as $needle) { + foreach (self::PROMPT_META as $needle) { if (str_contains($lower, $needle)) { return false; } @@ -408,6 +460,150 @@ class CityViewerController extends Controller || 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. */ diff --git a/app/Support/Port.php b/app/Support/Port.php new file mode 100644 index 0000000..391243b --- /dev/null +++ b/app/Support/Port.php @@ -0,0 +1,98 @@ + + */ + public static function geometry(City $city): array + { + $waterline = Beach::sandTo($city); // 264 for the shipped bounds + $gridEast = (float) (ceil(((float) $city->bounds_max_x) / 40) * 40 + 40); // 200 + + $quayX = $waterline + 2; // quay straddles the shore + $yardWest = $gridEast + 4; + $yardEast = $waterline - 6; + $yardCenterX = ($yardWest + $yardEast) / 2; + + return [ + 'visit' => [ + 'min_x' => self::VISIT_MIN_X, + 'max_x' => $quayX + 2, // walk to the quay edge (266) + 'min_z' => self::VISIT_MIN_Z, + 'max_z' => self::VISIT_MAX_Z, + ], + 'water' => [ + // The east sea extends north to cover the port basin + // (initBeach widens its plane down to this Z). + 'z_min' => self::VISIT_MIN_Z - 10, + ], + // Far-half layout (zone z −430..−290): terminal at the city + // (south) gate, yard behind it, quay along the shore. + 'quay' => ['center' => [$quayX, -360.0], 'size' => [8.0, 130.0]], + 'yard' => ['center' => [$yardCenterX, -362.0], 'size' => [$yardEast - $yardWest, 116.0]], + 'terminal' => ['center' => [$yardCenterX, -297.0], 'size' => [46.0, 14.0]], + 'cranes' => [ + ['center' => [$quayX, -330.0]], + ['center' => [$quayX, -395.0]], + ], + // Berth centres spaced so full hulls never touch: 60-unit + // container ship + 88-unit liner need >=82 between centres. + 'berths' => [ + ['id' => 1, 'center' => [$quayX + 16, -310.0]], + ['id' => 2, 'center' => [$quayX + 20, -393.0]], + ], + 'ships' => [ + ['id' => 'c1', 'name' => 'CITY VOYAGER', 'company' => 'City10 Cargo', 'kind' => 'container', 'berth' => 1], + ['id' => 'c2', 'name' => 'AZUL MAR', 'company' => 'AzulCéu Cargo', 'kind' => 'container', 'berth' => null], + ['id' => 'l1', 'name' => 'ATLÂNTICO CELESTE', 'company' => 'NorteLine', 'kind' => 'liner', 'berth' => 2], + ], + 'boats' => [ + ['name' => 'Lancha V1'], + ['name' => 'Lancha V2'], + ['name' => 'Lancha V3'], + ], + ]; + } + + /** + * Whether a point is on the walkable port grounds/quay. + */ + public static function isOnPort(City $city, float $x, float $z): bool + { + $visit = self::geometry($city)['visit']; + + return $x >= $visit['min_x'] + && $x <= $visit['max_x'] + && $z >= $visit['min_z'] + && $z <= $visit['max_z']; + } +} diff --git a/database/seeders/CityWorldSeeder.php b/database/seeders/CityWorldSeeder.php index caa07a0..07cb215 100755 --- a/database/seeders/CityWorldSeeder.php +++ b/database/seeders/CityWorldSeeder.php @@ -98,6 +98,45 @@ class CityWorldSeeder extends Seeder ); } + // --- Port staff: the Capitania (AI clearances) + dock workers ---- + $portModel = (string) config('ai.default_model'); + if (! in_array($portModel, config('ai.allowed_models', []), true)) { + $portModel = 'deepseek/deepseek-v4-flash-0731'; + } + AiAgent::updateOrCreate( + ['name' => 'Capitão do Porto'], + [ + 'type' => 'port_master', + 'model' => $portModel, + 'system_prompt' => 'Você é o Capitão do Porto da cidade virtual City10. Autorize ' + .'atracações, partidas e manobras de navios com linguagem marítima formal e curta, ' + .'sempre em português, citando o navio, a companhia e o berço. Nunca forneça ' + .'e-mail, senha ou dados de conta de ninguém.', + 'current_goal' => 'Coordenar as movimentações dos navios nos berços com segurança', + 'status' => 'active', + 'pos_x' => 256, + 'pos_y' => 0, + 'pos_z' => -298, + ], + ); + foreach ([['Estivador 01', 264, -340], ['Estivador 02', 240, -360]] as [$name, $px, $pz]) { + AiAgent::updateOrCreate( + ['name' => $name], + [ + 'type' => 'dock_worker', + 'model' => $portModel, + 'system_prompt' => 'Você é '.$name.', estivador do Porto City10 que trabalha no cais ' + .'movimentando containers. Fale de forma direta e trabalhadora, em português, ' + .'sem inventar dados pessoais de usuários.', + 'current_goal' => 'Apoiar a movimentação de containers no pátio e no cais', + 'status' => 'active', + 'pos_x' => $px, + 'pos_y' => 0, + 'pos_z' => $pz, + ], + ); + } + $bakery = Store::where('type', 'bakery')->orderBy('id')->first(); $market = Store::where('type', 'market')->orderBy('id')->first(); diff --git a/lang/en.json b/lang/en.json index 3994ce8..ac040e1 100755 --- a/lang/en.json +++ b/lang/en.json @@ -220,6 +220,7 @@ "city.beach_prompt": "Relax at the beach", "city.beach_active": "Relaxing at the beach", "city.atc_missing": "No control tower configured.", + "city.port_missing": "No port authority configured.", "city.sign_plaza": "Plaza", "city.sign_beach": "Beach", "city.sign_food": "Food", diff --git a/lang/es.json b/lang/es.json index f8bc983..5b5e3bc 100755 --- a/lang/es.json +++ b/lang/es.json @@ -220,6 +220,7 @@ "city.beach_prompt": "Relajar en la playa", "city.beach_active": "Relajándote en la playa", "city.atc_missing": "Ninguna torre de control configurada.", + "city.port_missing": "Ninguna capitanía del puerto configurada.", "city.sign_plaza": "Plaza", "city.sign_beach": "Playa", "city.sign_food": "Comida", diff --git a/lang/pt_BR.json b/lang/pt_BR.json index 53dd34e..cb05f33 100755 --- a/lang/pt_BR.json +++ b/lang/pt_BR.json @@ -220,6 +220,7 @@ "city.beach_prompt": "Relaxar na praia", "city.beach_active": "Relaxando na praia", "city.atc_missing": "Nenhuma torre de controle configurada.", + "city.port_missing": "Nenhuma capitania do porto configurada.", "city.sign_plaza": "Praça", "city.sign_beach": "Praia", "city.sign_food": "Comidas", diff --git a/public/js/city/app.js b/public/js/city/app.js index f72aabd..3e71648 100755 --- a/public/js/city/app.js +++ b/public/js/city/app.js @@ -247,6 +247,10 @@ let beach = null; let beachSystem = null; // Airports payload {visit, sites} — geometry, walk zone and flight paths. let airportsCfg = null; +// Port payload {visit, water, quay, yard, terminal, cranes, berths, ships…}. +let portCfg = null; +// Port ops runtime (ships, boats, trucks, crane, AI clearances). +let portSystem = null; let remoteCitizens = new Map(); // characterId -> mesh let presenceTimer = null; let vehicleHeadlights = []; @@ -3017,6 +3021,11 @@ function lerp3(a, b, t) { return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; } +/** 2D waypoint interpolation for ground/water paths [x, z]. */ +function lerp2(a, b, t) { + return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; +} + /** Distance between two 3D points. */ function dist3(a, b) { return Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]); @@ -3468,6 +3477,662 @@ function initAirplanes(cfg) { }; } +// ------------------------------------------------------------------ +// Port — far half of the north-east coast (z −430..−290; the near half +// stays free for city expansion): quay, yard, terminal, gantry cranes, +// container ships, liner, speedboats, trucks — with ship maneuvers +// authorised by the Capitania AI agent (/city/port/dispatch). +// ------------------------------------------------------------------ + +/** Ship heading: hull nose points local −Z. */ +function shipYaw(dx, dz) { + return Math.atan2(-dx, -dz); +} + +/** Clamped keyframe interpolation: kf(t, [[time, value], …]). */ +function kf(t, frames) { + if (t <= frames[0][0]) return frames[0][1]; + for (let i = 1; i < frames.length; i++) { + if (t <= frames[i][0]) { + const [t0, v0] = frames[i - 1]; + const [t1, v1] = frames[i]; + const k = (t - t0) / Math.max(1e-6, t1 - t0); + return v0 + (v1 - v0) * k; + } + } + return frames[frames.length - 1][1]; +} + +/** + * Big vessels: hull with stepped bow, decks, bridge/funnel and (for + * container ships) a stacked container block on deck. Nose points −Z. + * + * @param {'container'|'liner'} kind + * @param {{name: string, company: string, accent?: number}} spec + */ +function buildShip(kind, spec) { + const g = new THREE.Group(); + const white = new THREE.MeshStandardMaterial({ color: 0xe8ecf1, roughness: 0.55, metalness: 0.2 }); + const dark = new THREE.MeshStandardMaterial({ color: 0x14202c, roughness: 0.4, metalness: 0.5 }); + const accent = new THREE.MeshStandardMaterial({ color: spec.accent ?? 0x1f6fb2, roughness: 0.45, metalness: 0.3 }); + const box = (w, h, d, mat, x, y, z) => { + const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), mat); + m.position.set(x, y, z); + g.add(m); + return m; + }; + + if (kind === 'liner') { + const L = 88, W = 20; + box(W, 6.5, L, white, 0, 1.2, 0); // hull + box(W + 0.4, 1.3, L + 0.4, accent, 0, 2.4, 0); // company band + box(14, 5, 8, white, 0, 1.8, -L / 2 - 2); // stepped bow + box(17, 4, 60, white, 0, 6.2, 4); // deck tier 1 + box(17.3, 1, 60.3, dark, 0, 7.0, 4); // window band + box(14, 3.4, 44, white, 0, 10.0, 6); // tier 2 + box(14.3, 1, 44.3, dark, 0, 10.6, 6); // windows + box(11, 2.6, 14, white, 0, 13.4, -14); // bridge + box(11.3, 1, 14.3, dark, 0, 13.8, -14); // bridge glass + for (const fz of [14, 22]) { + const funnel = new THREE.Mesh(new THREE.CylinderGeometry(1.9, 2.3, 6, 12), accent); + funnel.position.set(0, 14.0, fz); + g.add(funnel); + } + } else { + const L = 60, W = 16; + box(W, 5.5, L - 2, dark, 0, 1.0, 0); // hull + box(W + 0.3, 1.1, L - 1.6, accent, 0, 3.2, 0); // livery band + box(W - 0.6, 1.2, L - 3, white, 0, 4.0, 0); // deck rim + box(11, 4.5, 6, dark, 0, 1.6, -L / 2 - 1); // stepped bow + box(11, 3.6, 9, white, 0, 5.9, 21); // deckhouse t1 + box(9, 2.4, 8, white, 0, 8.6, 21.5); // deckhouse t2 + box(9.2, 0.9, 8.2, dark, 0, 8.9, 21.5); // bridge glass + const funnel = new THREE.Mesh(new THREE.CylinderGeometry(1.4, 1.8, 4.4, 10), accent); + funnel.position.set(0, 11.4, 17); + g.add(funnel); + // Container stacks on deck (3 lanes × 6 rows, some double-stacked). + const containerColors = [0xc0392b, 0x2980b9, 0x27ae60, 0xd68910]; + let ci = 0; + for (const lx of [-5, 0, 5]) { + for (let r = 0; r < 6; r++) { + const cz = -22 + r * 6.4; + const color = containerColors[ci++ % containerColors.length]; + box(4.6, 2.6, 3, new THREE.MeshStandardMaterial({ color, roughness: 0.7 }), lx, 5.9, cz); + if ((ci + r) % 3 === 0) { + box(4.6, 2.6, 3, new THREE.MeshStandardMaterial({ color: containerColors[ci % containerColors.length], roughness: 0.7 }), lx, 8.5, cz); + } + } + } + } + + g.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } }); + g.userData.shipName = spec.name; + return g; +} + +/** Small fast boat (speedboat) hull + cabin. */ +function buildPortBoat(accentColor) { + const g = new THREE.Group(); + const white = new THREE.MeshStandardMaterial({ color: 0xf2f4f7, roughness: 0.5 }); + const accent = new THREE.MeshStandardMaterial({ color: accentColor, roughness: 0.45 }); + const dark = new THREE.MeshStandardMaterial({ color: 0x14202c, roughness: 0.6 }); + const hull = new THREE.Mesh(new THREE.BoxGeometry(3.2, 1.4, 9), white); + hull.position.y = 0.55; + g.add(hull); + const stripe = new THREE.Mesh(new THREE.BoxGeometry(3.3, 0.4, 8.6), accent); + stripe.position.y = 0.95; + g.add(stripe); + const nose = new THREE.Mesh(new THREE.BoxGeometry(2.4, 1.0, 2.4), white); + nose.position.set(0, 0.6, -5.2); + g.add(nose); + const cabin = new THREE.Mesh(new THREE.BoxGeometry(2.2, 1.4, 3), white); + cabin.position.set(0, 1.9, 0.6); + g.add(cabin); + const screen = new THREE.Mesh(new THREE.BoxGeometry(2.1, 0.6, 0.5), dark); + screen.position.set(0, 2.2, -0.9); + g.add(screen); + const motor = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.9, 0.8), dark); + motor.position.set(0, 0.9, 4.8); + g.add(motor); + g.traverse(o => { if (o.isMesh) o.castShadow = true; }); + return g; +} + +/** Yard truck: cab + chassis, optional container on the back (nose +X). */ +function buildPortTruck(accentColor, loaded) { + const g = new THREE.Group(); + const steel = new THREE.MeshStandardMaterial({ color: 0x3a4048, roughness: 0.6, metalness: 0.4 }); + const cabMat = new THREE.MeshStandardMaterial({ color: accentColor, roughness: 0.5, metalness: 0.2 }); + const chassis = new THREE.Mesh(new THREE.BoxGeometry(7, 0.5, 2.6), steel); + chassis.position.y = 1.15; + g.add(chassis); + const cab = new THREE.Mesh(new THREE.BoxGeometry(2.4, 2.3, 2.5), cabMat); + cab.position.set(2.4, 2.3, 0); + g.add(cab); + if (loaded) { + const container = new THREE.Mesh( + new THREE.BoxGeometry(4.2, 2.3, 2.4), + new THREE.MeshStandardMaterial({ color: 0x2980b9, roughness: 0.7 }) + ); + container.position.set(-1.2, 2.6, 0); + g.add(container); + } + for (const [wx, wz] of [[2.4, 1.35], [2.4, -1.35], [-2.4, 1.35], [-2.4, -1.35]]) { + const wheel = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 0.5, 0.4, 10), steel); + wheel.rotation.x = Math.PI / 2; + wheel.position.set(wx, 0.5, wz); + g.add(wheel); + } + g.traverse(o => { if (o.isMesh) o.castShadow = true; }); + return g; +} + +/** + * Build the port and its moving fleet from the payload geometry. + * + * Fixed structures (quay, yard, terminal, bollards, container stacks, + * crane frames) merge through a static batcher; ships, boats, trucks + * and crane trolleys stay individual and animated. Container-ship + * maneuvers wait for a Capitania clearance (AI text or deterministic + * fallback) before departing or berthing. + * + * @param {object} cfg port payload {visit, water, quay, yard, terminal, cranes, berths, ships, boats} + */ +function initPort(cfg) { + const quay = cfg.quay; + const yard = cfg.yard; + const terminal = cfg.terminal; + const berths = cfg.berths; + const cranes = cfg.cranes; + const ships = cfg.ships; + const batch = makeStaticBatcher(); + + const concreteMat = new THREE.MeshStandardMaterial({ color: 0x6a7078, roughness: 0.95 }); + const asphaltMat = new THREE.MeshStandardMaterial({ color: 0x4a4f56, roughness: 0.95 }); + const steelMat = new THREE.MeshStandardMaterial({ color: 0x8f969e, roughness: 0.5, metalness: 0.6 }); + const whiteLineMat = new THREE.MeshStandardMaterial({ color: 0xe8e8e8, roughness: 0.85 }); + const yellowLineMat = new THREE.MeshStandardMaterial({ color: 0xd8b12a, roughness: 0.8 }); + + const addFlat = (cx, cz, w, d, mat, y) => { + const m = new THREE.Mesh(new THREE.PlaneGeometry(w, d), mat); + m.rotation.x = -Math.PI / 2; + m.position.set(cx, y, cz); + m.receiveShadow = true; + worldGroup.add(m); + }; + + // ---- Quay + yard pavement ---- + addFlat(quay.center[0], quay.center[1], quay.size[0], quay.size[1], concreteMat, 0.06); + const edgeLine = new THREE.Mesh( + new THREE.BoxGeometry(0.3, 0.02, quay.size[1] - 4), yellowLineMat + ); + edgeLine.position.set(quay.center[0] + quay.size[0] / 2 - 0.6, 0.08, quay.center[1]); + worldGroup.add(edgeLine); + addFlat(yard.center[0], yard.center[1], yard.size[0], yard.size[1], asphaltMat, 0.05); + + // Bollards along the quay edge (merged). + const bollardGeo = new THREE.CylinderGeometry(0.17, 0.2, 0.7, 8); + for (let z = quay.center[1] - quay.size[1] / 2 + 8; z <= quay.center[1] + quay.size[1] / 2 - 8; z += 13) { + const b = new THREE.Mesh(bollardGeo, steelMat); + b.position.set(quay.center[0] + quay.size[0] / 2 - 1.6, 0.4, z); + b.updateMatrix(); + batch.add(bollardGeo, steelMat, b.matrix, true); + } + + // ---- Terminal (multi-window facade) ---- + const termPair = facadePair('#6d7f93', 3, Math.max(4, Math.round(terminal.size[0] / 7))); + const termMat = new THREE.MeshStandardMaterial({ + map: termPair.map, emissiveMap: termPair.emissiveMap, + emissive: 0xffd9a0, emissiveIntensity: 0, roughness: 0.85, + }); + facadeMaterials.push(termMat); + const termH = 9; + const termMesh = new THREE.Mesh(new THREE.BoxGeometry(terminal.size[0], termH, terminal.size[1]), termMat); + termMesh.position.set(terminal.center[0], termH / 2, terminal.center[1]); + termMesh.castShadow = true; + termMesh.receiveShadow = true; + worldGroup.add(termMesh); + addCollider(terminal.center[0], 0, terminal.center[1], terminal.size[0], termH, terminal.size[1]); + const termRoof = new THREE.Mesh( + new THREE.BoxGeometry(terminal.size[0] + 0.6, 0.5, terminal.size[1] + 0.6), + new THREE.MeshStandardMaterial({ color: 0x33373d, roughness: 0.9 }) + ); + termRoof.position.set(terminal.center[0], termH + 0.15, terminal.center[1]); + worldGroup.add(termRoof); + + // ---- Container stacks in the yard (merged per colour) ---- + const stackColors = [0xc0392b, 0x2980b9, 0x27ae60, 0xd68910]; + const stackMats = stackColors.map( + color => new THREE.MeshStandardMaterial({ color, roughness: 0.7 }) + ); + let stackIdx = 0; + for (const ox of [-16, -4, 8]) { + for (let z = yard.center[1] - yard.size[1] / 2 + 8; + z <= yard.center[1] + yard.size[1] / 2 - 8; + z += 16) { + const mat = stackMats[stackIdx % stackMats.length]; + const sx = yard.center[0] + ox; + const baseGeo = new THREE.BoxGeometry(5, 2.6, 2.8); + const base = new THREE.Mesh(baseGeo, mat); + base.position.set(sx, 1.35, z); + base.updateMatrix(); + batch.add(baseGeo, mat, base.matrix, true); + if (stackIdx % 3 === 0) { + const topGeo = new THREE.BoxGeometry(5, 2.6, 2.8); + const top = new THREE.Mesh(topGeo, mat); + top.position.set(sx, 3.95, z); + top.updateMatrix(); + batch.add(topGeo, mat, top.matrix, true); + } + stackIdx++; + } + } + + // ---- Gantry crane frames (merged) + animated trolleys ---- + const craneRuntime = []; + const deckYs = berths.map((b) => { + const ship = ships.find(s => s.berth === b.id); + return ship && ship.kind === 'liner' ? 6.2 : 4.6; + }); + cranes.forEach((crane, i) => { + const [cx, cz] = crane.center; + for (const lx of [cx - 2.5, cx + 2.5]) { + for (const lz of [cz - 6.5, cz + 6.5]) { + const legGeo = new THREE.BoxGeometry(1.1, 15, 1.1); + const leg = new THREE.Mesh(legGeo, steelMat); + leg.position.set(lx, 7.5, lz); + leg.updateMatrix(); + batch.add(legGeo, steelMat, leg.matrix, true); + } + const tieGeo = new THREE.BoxGeometry(1, 1, 14); + const tie = new THREE.Mesh(tieGeo, steelMat); + tie.position.set(lx, 15.5, cz); + tie.updateMatrix(); + batch.add(tieGeo, steelMat, tie.matrix); + } + const beamGeo = new THREE.BoxGeometry(6, 1, 1.2); + for (const bz of [cz - 6.5, cz + 6.5]) { + const beam = new THREE.Mesh(beamGeo, steelMat); + beam.position.set(cx, 15.5, bz); + beam.updateMatrix(); + batch.add(beamGeo, steelMat, beam.matrix); + } + // Boom rails reaching out over the water. + for (const rz of [cz - 0.9, cz + 0.9]) { + const railGeo = new THREE.BoxGeometry(36, 0.6, 0.45); + const rail = new THREE.Mesh(railGeo, steelMat); + rail.position.set(cx + 18, 15.6, rz); + rail.updateMatrix(); + batch.add(railGeo, steelMat, rail.matrix); + } + const deckGeo = new THREE.BoxGeometry(36, 0.25, 2.6); + const deck = new THREE.Mesh(deckGeo, steelMat); + deck.position.set(cx + 18, 16.1, cz); + deck.updateMatrix(); + batch.add(deckGeo, steelMat, deck.matrix); + + // Animated trolley + cable + spreader + carried container. + const trolley = new THREE.Group(); + const carriage = new THREE.Mesh(new THREE.BoxGeometry(3, 1.5, 3.1), steelMat); + carriage.position.y = 14.75; + trolley.add(carriage); + const cable = new THREE.Mesh( + new THREE.BoxGeometry(0.14, 1, 0.14), + new THREE.MeshStandardMaterial({ color: 0x2a2e36, roughness: 0.5 }) + ); + trolley.add(cable); + const spreader = new THREE.Mesh(new THREE.BoxGeometry(3.1, 0.4, 2.5), yellowLineMat); + trolley.add(spreader); + const load = new THREE.Mesh( + new THREE.BoxGeometry(4.6, 2.6, 2.9), + new THREE.MeshStandardMaterial({ color: stackColors[i % stackColors.length], roughness: 0.7 }) + ); + trolley.add(load); + worldGroup.add(trolley); + craneRuntime.push({ + cx, cz, trolley, cable, spreader, load, + deckY: deckYs[i], t: i * 0.45, + berthId: berths[i] ? berths[i].id : null, + }); + }); + + batch.flush(worldGroup); + + // ---- Fleet: container ships, liner, speedboats, trucks ---- + const laneX = quay.center[0] + 52; // clear of the berthed hulls (W/2 sums ~18) + const zoneStart = berths[0].center[1] + 20; + const zoneEnd = berths[1].center[1] - 20; + const oval = [ + [laneX, zoneStart], + [laneX + 8, zoneStart - 25], + [laneX + 8, zoneEnd + 25], + [laneX, zoneEnd], + [laneX - 8, zoneEnd + 25], + [laneX - 8, zoneStart - 25], + ]; + const departLegsFor = berthCenter => [ + [berthCenter[0] + 16, berthCenter[1]], + [laneX, zoneStart], + ]; + const returnLegsFor = berthCenter => [ + [berthCenter[0] + 16, berthCenter[1]], + [berthCenter[0], berthCenter[1]], + ]; + + const fleet = []; + ships.forEach((spec) => { + const mesh = buildShip(spec.kind, spec); + worldGroup.add(mesh); + const berth = spec.berth ? berths.find(b => b.id === spec.berth) : null; + const entity = { + spec, + mesh, + berth: berth ? [...berth.center] : null, + cycle: spec.kind === 'container', + state: berth ? 'berthed' : 'sailing', + legs: null, + leg: 0, + t: 0, + p: [0, 0, 0], + yaw: 0, + timer: berth ? 11 + Math.random() * 6 : 22 + Math.random() * 14, + purpose: berth ? 'depart' : 'berth', + awaitedAt: 0, + retry: 0, + phase: Math.random() * Math.PI * 2, + }; + if (berth) { + entity.p = [berth.center[0], 0, berth.center[1]]; + } else { + entity.legs = oval; + entity.leg = 1; + entity.t = 0.4; + const [sx, sz] = lerp2(oval[0], oval[1], 0.4); + entity.p = [sx, 0, sz]; + entity.yaw = shipYaw(oval[1][0] - oval[0][0], oval[1][1] - oval[0][1]); + entity.purpose = 'berth'; + } + fleet.push(entity); + }); + + const boats = []; + const boatOval = [ + [laneX - 18, zoneStart], + [laneX - 10, zoneEnd], + [laneX + 18, zoneEnd], + [laneX + 10, zoneStart], + ]; + [0x2fb6c9, 0xe08a1e, 0xd92b2b].forEach((color, i) => { + const mesh = buildPortBoat(color); + worldGroup.add(mesh); + const start = i % boatOval.length; + const t0 = (i * 0.37) % 1; + boats.push({ + mesh, + legs: boatOval, + leg: start, + t: t0, + p: (() => { + const [bx, bz] = lerp2(boatOval[start], boatOval[(start + 1) % boatOval.length], t0); + return [bx, 0, bz]; + })(), + yaw: 0, + speed: 8 + i, + phase: i * 2.1, + }); + }); + + const trucks = []; + const yardLoop = [ + [yard.center[0] - 16, yard.center[1] - 50], + [yard.center[0] + 16, yard.center[1] - 50], + [yard.center[0] + 16, yard.center[1] + 50], + [yard.center[0] - 16, yard.center[1] + 50], + ]; + [true, false].forEach((loaded, i) => { + const mesh = buildPortTruck(i === 0 ? 0xd97706 : 0x3f9d4f, loaded); + worldGroup.add(mesh); + const leg = (i * 2) % yardLoop.length; + const t0 = i * 0.5; + trucks.push({ + mesh, + legs: yardLoop, + leg, + t: t0, + p: (() => { + const [tx, tz] = lerp2(yardLoop[leg], yardLoop[(leg + 1) % yardLoop.length], t0); + return [tx, 0, tz]; + })(), + yaw: 0, + speed: loaded ? 4 : 4.6, + truck: true, + }); + }); + + /** Advance one entity along its waypoint legs; true when finished. */ + const stepAlong = (e, speed, dt) => { + if (!e.legs || !e.legs.length) return true; + let guard = 0; + while (guard++ < 4) { + // Perpetual loops wrap; one-shot paths reset via their caller. + if (e.leg >= e.legs.length) e.leg = 0; + const target = e.legs[e.leg]; // [x, z] waypoint pair + const dx = target[0] - e.p[0]; + const dz = target[1] - e.p[2]; + const len = Math.hypot(dx, dz); + if (len < 1e-3) { + e.leg++; + if (e.leg >= e.legs.length) return true; + continue; + } + const step = speed * dt; + if (step >= len) { + e.p = [target[0], 0, target[1]]; + e.leg++; + if (e.leg >= e.legs.length) return true; + continue; + } + e.p = [e.p[0] + (dx / len) * step, 0, e.p[2] + (dz / len) * step]; + e.yaw = e.truck ? Math.atan2(-dz, dx) : shipYaw(dx, dz); + return false; + } + return true; + }; + + const postDispatch = () => { + postJson('/city/port/dispatch', {}).catch(() => { /* throttled server-side */ }); + }; + + const system = { + clearance: null, + shownAt: null, + poll: 4, + + counts() { + return { + ships: fleet.length, + boats: boats.length, + trucks: trucks.length, + cranes: craneRuntime.length, + berthed: fleet.filter(f => f.state === 'berthed').length, + underway: fleet.filter(f => ['departing', 'sailing', 'returning', 'turning'].includes(f.state)).length, + states: fleet.map(f => `${f.spec.id}:${f.state}`), + shipPositions: fleet.map(f => ({ + id: f.spec.id, + x: +f.p[0].toFixed(1), + z: +f.p[2].toFixed(1), + })), + // Motion sample for tests: positions that must change + // between two readings. + movingSample: { + boat: boats[0] ? [+boats[0].p[0].toFixed(1), +boats[0].p[2].toFixed(1)] : null, + truck: trucks[0] ? [+trucks[0].p[0].toFixed(1), +trucks[0].p[2].toFixed(1)] : null, + }, + }; + }, + + update(dt, time) { + // ---- Capitania clearance poll (flash + maneuver gating) ---- + this.poll -= dt; + if (this.poll <= 0) { + this.poll = 8; + fetch('/city/port', { + headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, + cache: 'no-store', + }) + .then(r => (r.ok ? r.json() : null)) + .then((data) => { + const c = data && data.clearance; + if (c && c.at && c.at !== this.shownAt) { + this.shownAt = c.at; + this.clearance = c; + flashCharMsg('💬 '.concat(c.text)); + } + }) + .catch(() => { /* offline-safe */ }); + } + + // ---- Container ships: AI-gated maneuver cycle ---- + for (const f of fleet) { + if (!f.cycle) continue; + if (f.state === 'berthed') { + f.timer -= dt; + if (f.timer <= 0) { + f.state = 'awaiting'; + f.purpose = 'depart'; + f.awaitedAt = Date.now(); + f.retry = 1; + postDispatch(); + } + } else if (f.state === 'awaiting') { + f.retry -= dt; + if (f.retry <= 0) { + f.retry = 7; + postDispatch(); + } + const c = this.clearance; + if (c && Date.parse(c.at) >= f.awaitedAt - 1500 + && c.action === f.purpose) { + if (f.purpose === 'depart') { + if (!f.berth) continue; // nothing to leave + f.legs = departLegsFor(f.berth); + } else { + // Claim the first FREE berth: occupied means + // berthed, or still moored waiting to depart + // (c1 sits on its berth until cleared to leave). + const free = berths.find(b => !fleet.some(o => { + if (o === f || !o.berth) return false; + const sameSlot = o.berth[0] === b.center[0] + && o.berth[1] === b.center[1]; + const occupying = o.state === 'berthed' + || o.state === 'returning' + || o.state === 'turning' + || (o.state === 'awaiting' && o.purpose === 'depart'); + return sameSlot && occupying; + })); + if (!free) { + f.retry = 6; // all busy: ask again shortly + continue; + } + f.berth = [...free.center]; + f.legs = returnLegsFor(f.berth); + } + f.leg = 0; + f.state = f.purpose === 'depart' ? 'departing' : 'returning'; + } + } else if (f.state === 'departing') { + if (stepAlong(f, 4.5, dt)) { + f.legs = oval; + f.leg = 0; + f.timer = 20 + Math.random() * 14; + f.state = 'sailing'; + } + } else if (f.state === 'sailing') { + f.timer -= dt; + stepAlong(f, 5.5, dt); + if (f.timer <= 0) { + f.state = 'awaiting'; + f.purpose = 'berth'; + f.awaitedAt = Date.now(); + f.retry = 1; + postDispatch(); + } + } else if (f.state === 'returning') { + if (stepAlong(f, 4.5, dt)) { + f.state = 'turning'; + f.turnT = 0; + f.fromYaw = f.yaw; + } + } else if (f.state === 'turning') { + f.turnT += dt / 2.2; + const k = Math.min(1, f.turnT); + f.yaw = f.fromYaw + (0 - f.fromYaw) * k; + if (k >= 1) { + f.state = 'berthed'; + f.timer = 10 + Math.random() * 6; + f.purpose = 'depart'; + } + } + + // Pose: position + swell bob + gentle roll. + f.mesh.position.set( + f.p[0], + 0.1 + beachWaveY(f.p[0], f.p[2], time) * 0.5, + f.p[2] + ); + f.mesh.rotation.y = f.yaw; + f.mesh.rotation.z = Math.sin(time * 0.7 + f.phase) * 0.03; + } + + // ---- Speedboats: perpetual loops ---- + for (const b of boats) { + if (stepAlong(b, b.speed, dt)) b.leg = 0; + b.mesh.position.set( + b.p[0], + 0.15 + beachWaveY(b.p[0], b.p[2], time) * 0.7, + b.p[2] + ); + b.mesh.rotation.y = b.yaw; + b.mesh.rotation.z = Math.sin(time * 1.1 + b.phase) * 0.07; + } + + // ---- Yard trucks on their loop ---- + for (const tr of trucks) { + if (stepAlong(tr, tr.speed, dt)) tr.leg = 0; + tr.mesh.position.set(tr.p[0], 0, tr.p[2]); + tr.mesh.rotation.y = tr.yaw; + } + + // ---- Gantry cranes: load cycle while their berth is occupied ---- + for (const cr of craneRuntime) { + const busy = fleet.some( + f => f.spec.berth === cr.berthId && f.state === 'berthed' + ); + cr.busy = busy; + if (busy) cr.t = (cr.t + dt / 13) % 1; + const t = busy ? cr.t : 0; + cr.trolley.position.x = kf(t, [ + [0, cr.cx], [0.18, cr.cx + 32], + [0.60, cr.cx + 32], [0.72, cr.cx], [1, cr.cx], + ]); + const hookY = kf(t, [ + [0, 13], [0.18, 13], [0.30, cr.deckY], + [0.40, 12.5], [0.60, 12.5], + [0.72, 3.2], [0.82, 13], [1, 13], + ]); + cr.cable.scale.y = Math.max(0.1, 14.0 - hookY); + cr.cable.position.y = (14.0 + hookY) / 2; + cr.spreader.position.y = hookY; + const carrying = busy && t >= 0.28 && t < 0.70; + cr.load.visible = carrying; + cr.load.position.y = hookY - 1.6; + } + }, + }; + + return system; +} + // ------------------------------------------------------------------ // Train system + level crossing // ------------------------------------------------------------------ @@ -3900,7 +4565,7 @@ function beachWaveY(x, z, t) { * @param {object} cfg beach geometry (sand_from/sand_to/sea_to/z_min/z_max) * @returns {{update: Function, counts: Function}} */ -function initBeach(cfg) { +function initBeach(cfg, portCfg = null) { const sandFrom = cfg.sand_from; const sandTo = cfg.sand_to; const seaTo = cfg.sea_to; @@ -3908,6 +4573,11 @@ function initBeach(cfg) { const zMax = cfg.z_max; const midZ = (zMin + zMax) / 2; const zDepth = zMax - zMin; + // The sea extends north over the port basin (and the free half of + // that coast, kept open for future city expansion). + const seaZMin = Math.min(zMin, portCfg?.water?.z_min ?? zMin); + const seaSpan = zMax - seaZMin; + const seaCenterZ = (zMax + seaZMin) / 2; const group = new THREE.Group(); const counts = { @@ -3931,14 +4601,17 @@ function initBeach(cfg) { // ---- Sea with animated waves (one mesh, vertex displacement) ---- const seaW = seaTo - sandTo; - const seaGeo = new THREE.PlaneGeometry(seaW, zDepth, 40, 56); + const seaGeo = new THREE.PlaneGeometry( + seaW, seaSpan, 40, + Math.max(16, Math.round((56 * seaSpan) / zDepth)) + ); const seaMat = new THREE.MeshStandardMaterial({ color: 0x2f7fb5, roughness: 0.35, metalness: 0.15, transparent: true, opacity: 0.94, }); const seaMesh = new THREE.Mesh(seaGeo, seaMat); seaMesh.rotation.x = -Math.PI / 2; - seaMesh.position.set(sandTo + seaW / 2, 0.09, midZ); + seaMesh.position.set(sandTo + seaW / 2, 0.09, seaCenterZ); seaMesh.frustumCulled = false; group.add(seaMesh); const seaBase = Float32Array.from(seaGeo.attributes.position.array); @@ -4242,7 +4915,7 @@ function initBeach(cfg) { for (let i = 0; i < pos.count; i++) { const lx = seaBase[i * 3]; const ly = seaBase[i * 3 + 1]; - arr[i * 3 + 2] = beachWaveY(seaWorldX + lx, midZ - ly, time); + arr[i * 3 + 2] = beachWaveY(seaWorldX + lx, seaCenterZ - ly, time); } pos.needsUpdate = true; foamMat.opacity = 0.55 + Math.sin(time * 1.7) * 0.2; @@ -4594,6 +5267,8 @@ function buildCity(data) { beach = null; beachSystem = null; airportsCfg = null; + portCfg = null; + portSystem = null; const layout = data.layout || { roads: [], blocks: [] }; const stores = data.stores || []; @@ -4936,14 +5611,18 @@ function buildCity(data) { worldGroup.add(ring); }); - // ---- Beach (east shore) ---- + // ---- Beach (east shore, sea also covers the port basin) ---- beach = data.beach || null; - beachSystem = beach ? initBeach(beach) : null; + portCfg = data.port || null; + beachSystem = beach ? initBeach(beach, portCfg) : null; // ---- Airports (north/south) + flight operations ---- airportsCfg = data.airports || null; buildAirports(airportsCfg); + // ---- Port (far half of the north-east coast) ---- + portSystem = portCfg ? initPort(portCfg) : null; + // ---- Filler buildings ---- // Fillers only exist on empty ground: skip cells near stores AND near // any placed building (hospital, school, city hall, player houses) so @@ -5534,6 +6213,9 @@ function updatePlayer(dt) { if (beach && player.pos.z >= beach.z_min && player.pos.z <= beach.z_max) { maxX = Math.max(maxX, beach.sand_to + 1); } + if (portCfg?.visit && player.pos.z <= portCfg.visit.max_z && player.pos.z >= portCfg.visit.min_z) { + maxX = Math.max(maxX, portCfg.visit.max_x); + } player.pos.x = cityBounds ? Math.max(cityBounds.min_x, Math.min(maxX, player.pos.x)) : Math.max(-400, Math.min(maxX, player.pos.x)); @@ -5545,6 +6227,9 @@ function updatePlayer(dt) { minZ = airportsCfg.visit.min_z; maxZ = airportsCfg.visit.max_z; } + if (portCfg?.visit && player.pos.x >= portCfg.visit.min_x) { + minZ = Math.min(minZ, portCfg.visit.min_z); + } player.pos.z = Math.max(minZ, Math.min(maxZ, player.pos.z)); } @@ -6180,7 +6865,13 @@ function animate() { if (airplaneSystem) airplaneSystem.update(dt); if (trainSystem) trainSystem.update(dt); if (riverSystem) riverSystem.update(dt, simTimeAcc); - if (beachSystem) beachSystem.update(dt, simTimeAcc); + if (beachSystem) { + try { beachSystem.update(dt, simTimeAcc); } catch (err) { console.error('beach update', err); } + } + if (portSystem) { + // A port glitch must never abort the frame (renderer.render below). + try { portSystem.update(dt, simTimeAcc); } catch (err) { console.error('port update', err); } + } updatePlayer(dt); checkVehicleHitOnPlayer(); @@ -6748,6 +7439,7 @@ window.__city10 = { sidewalkSegments: sidewalkSegmentCount, beach: beachSystem ? beachSystem.counts() : null, flights: airplaneSystem ? airplaneSystem.flights() : null, + port: portSystem ? portSystem.counts() : null, robots: pedestrianSystem ? pedestrianSystem.walkers.filter(w => w.robot).length : 0, diff --git a/routes/web.php b/routes/web.php index d4dfab7..b6679dd 100755 --- a/routes/web.php +++ b/routes/web.php @@ -137,6 +137,14 @@ Route::middleware('auth')->group(function (): void { Route::post('/city/airport/dispatch', [CityViewerController::class, 'dispatch']) ->middleware('throttle:60,1') ->name('city.airport.dispatch'); + + // Port ops: geometry, Capitania clearances (AI-driven maneuvers). + Route::get('/city/port', [CityViewerController::class, 'port']) + ->middleware('throttle:60,1') + ->name('city.port'); + Route::post('/city/port/dispatch', [CityViewerController::class, 'dispatchPort']) + ->middleware('throttle:60,1') + ->name('city.port.dispatch'); }); }); diff --git a/tests/Feature/CityActionsTest.php b/tests/Feature/CityActionsTest.php index 3ec0776..1935be0 100755 --- a/tests/Feature/CityActionsTest.php +++ b/tests/Feature/CityActionsTest.php @@ -9,6 +9,7 @@ use App\Models\Job; use App\Models\Transaction; use App\Models\User; use App\Support\Beach; +use App\Support\Port; use Database\Seeders\CityWorldSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -58,7 +59,8 @@ class CityActionsTest extends TestCase /** * Position updates clamp to the beach shoreline (past the road grid) - * and persist; other axes clamp to the city bounds. + * and persist; Z clamps to the port strip (opened for x >= 150, the + * north-east port/coast) or to the city bounds otherwise. */ public function test_position_persists_and_clamps(): void { @@ -67,8 +69,10 @@ class CityActionsTest extends TestCase $this->character->refresh(); $city = $this->character->city; + // Clamped X lands on the shoreline/quay band (>= VISIT_MIN_X), so + // the deep north coast (port strip) is reachable. $this->assertSame(Beach::sandTo($city) + 2, $this->character->pos_x); - $this->assertSame((float) $city->bounds_min_z, $this->character->pos_z); + $this->assertSame(Port::VISIT_MIN_Z, $this->character->pos_z); } /** diff --git a/tests/Feature/PortTest.php b/tests/Feature/PortTest.php new file mode 100644 index 0000000..faee1b6 --- /dev/null +++ b/tests/Feature/PortTest.php @@ -0,0 +1,250 @@ +seed(CityWorldSeeder::class); + $this->user = User::factory()->create(['status' => User::STATUS_ACTIVE, 'is_active' => true]); + $this->character = Character::ensureForUser($this->user); + $this->actingAs($this->user); + } + + /** + * The payload carries the full port descriptor. + */ + public function test_payload_contains_port_descriptor(): void + { + $port = $this->getJson('/city/data')->assertOk()->json('port'); + + $this->assertEquals(-430.0, $port['visit']['min_z']); + $this->assertEquals(-290.0, $port['visit']['max_z']); + $this->assertNotEmpty($port['quay']); + $this->assertNotEmpty($port['yard']); + $this->assertNotEmpty($port['terminal']); + $this->assertCount(2, $port['cranes']); + $this->assertCount(2, $port['berths']); + $this->assertCount(3, $port['ships']); + $this->assertCount(3, $port['boats']); + $this->assertSame( + ['container', 'container', 'liner'], + array_column($port['ships'], 'kind') + ); + foreach ($port['ships'] as $ship) { + $this->assertNotEmpty($ship['company']); + } + } + + /** + * ONLY the far half of the side hosts port structures: everything + * sits at z ≤ −290, leaving the near half (−290..−150) free for + * future city expansion. + */ + public function test_port_occupies_only_the_far_half_of_the_side(): void + { + $port = $this->getJson('/city/data')->assertOk()->json('port'); + + $centers = [ + 'quay' => $port['quay']['center'][1], + 'yard' => $port['yard']['center'][1], + 'terminal' => $port['terminal']['center'][1], + ]; + foreach ($port['cranes'] as $i => $crane) { + $centers["crane{$i}"] = $crane['center'][1]; + } + foreach ($port['berths'] as $berth) { + $centers['berth'.$berth['id']] = $berth['center'][1]; + } + + foreach ($centers as $label => $z) { + $this->assertLessThanOrEqual(-290.0, (float) $z, "{$label} must stay in the far half"); + } + + // Visit zone ends at the buffer line as well. + $this->assertEquals(-290.0, $port['visit']['max_z']); + $this->assertGreaterThanOrEqual( + Port::VISIT_MIN_Z, + Port::VISIT_MAX_Z + ); + } + + /** + * Capitania + dock workers exist with allow-listed models, maritime + * personas and staff positions inside the port zone. + */ + public function test_port_staff_seeded(): void + { + $captain = AiAgent::query()->where('type', 'port_master')->firstOrFail(); + $this->assertSame('Capitão do Porto', $captain->name); + $this->assertContains($captain->model, config('ai.allowed_models')); + $this->assertStringContainsString('Capitão do Porto', $captain->system_prompt); + $this->assertLessThanOrEqual(-290, (float) $captain->pos_z); + + $dockers = AiAgent::query()->where('type', 'dock_worker')->get(); + $this->assertCount(2, $dockers); + foreach ($dockers as $docker) { + $this->assertContains($docker->model, config('ai.allowed_models')); + $this->assertLessThanOrEqual(-290, (float) $docker->pos_z); + } + } + + /** + * The port endpoint exposes geometry, captain and clearance. + */ + public function test_port_endpoint_exposes_captain(): void + { + $this->getJson('/city/port') + ->assertOk() + ->assertJsonPath('captain.name', 'Capitão do Porto') + ->assertJsonPath('clearance', null); + + $payload = $this->getJson('/city/port')->json(); + $this->assertNotEmpty($payload['geometry']['quay']); + } + + /** + * Dispatch generates an AI clearance with structured maneuver fields. + */ + public function test_dispatch_generates_model_clearance(): void + { + config(['ai.openrouter.api_key' => 'test-key']); + Http::fake([ + 'openrouter.ai/*' => Http::response([ + 'choices' => [['message' => ['content' => 'Navio CITY VOYAGER, autorizado a atracar no berço 1.']]], + ]), + ]); + + $this->postJson('/city/port/dispatch') + ->assertStatus(202) + ->assertJsonPath('accepted', true); + + $clearance = cache()->get('port.clearance'); + $this->assertNotNull($clearance); + $this->assertSame('model', $clearance['source']); + $this->assertContains($clearance['action'], ['berth', 'depart']); + $this->assertContains($clearance['ship'], ['CITY VOYAGER', 'AZUL MAR', 'ATLÂNTICO CELESTE']); + $this->assertNotSame('', $clearance['text']); + + Http::assertSent(function ($request): bool { + $content = (string) $request['messages'][0]['content']; + + return $request['model'] === config('ai.default_model') + && str_contains($content, 'Capitania') + && str_contains($content, 'navio'); + }); + + $captain = AiAgent::query()->where('type', 'port_master')->firstOrFail(); + $this->assertSame(0, (int) $captain->error_count); + $this->assertSame('active', $captain->status); + $this->assertSame('port', collect($captain->last_decision)->last()['role'] ?? null); + } + + /** + * Meta prompt-echoes are rejected and the chain falls through. + */ + public function test_dispatch_rejects_prompt_echoes(): void + { + config(['ai.openrouter.api_key' => 'test-key']); + Http::fake([ + 'openrouter.ai/*' => Http::sequence() + ->push(['choices' => [['message' => ['content' => 'The user wants a single short sentence in Portuguese, ATC style, no quotes.']]]], 200) + ->push(['choices' => [['message' => ['content' => 'AZUL MAR, autorizado a deixar o cais, partida liberada.']]]], 200), + ]); + + $this->postJson('/city/port/dispatch')->assertStatus(202); + + $clearance = cache()->get('port.clearance'); + $this->assertNotNull($clearance); + $this->assertSame('model', $clearance['source']); + $this->assertStringContainsString('autorizado', $clearance['text']); + Http::assertSentCount(2); + } + + /** + * Without an API key the deterministic fallback (with structure) is + * stored and the failure counts toward backoff. + */ + public function test_dispatch_without_key_stores_fallback(): void + { + config(['ai.openrouter.api_key' => null]); + Http::preventStrayRequests(); + + $this->postJson('/city/port/dispatch') + ->assertStatus(202) + ->assertJsonPath('accepted', true); + + $clearance = cache()->get('port.clearance'); + $this->assertNotNull($clearance); + $this->assertSame('fallback', $clearance['source']); + $this->assertStringContainsString('autorizado', $clearance['text']); + $this->assertContains($clearance['action'], ['berth', 'depart']); + + $captain = AiAgent::query()->where('type', 'port_master')->firstOrFail(); + $this->assertSame(1, (int) $captain->error_count); + } + + /** + * Internal cooldown: one clearance per 30 seconds. + */ + public function test_dispatch_has_internal_cooldown(): void + { + config(['ai.openrouter.api_key' => null]); + Http::preventStrayRequests(); + + $this->postJson('/city/port/dispatch')->assertJsonPath('accepted', true); + $this->postJson('/city/port/dispatch') + ->assertStatus(202) + ->assertJsonPath('accepted', false) + ->assertJsonPath('reason', 'cooldown'); + } + + /** + * Position sync reaches the quay inside the visit zone and stops at + * its edge; outside the port X-band the city bounds still apply. + */ + public function test_position_reaches_the_quay(): void + { + $this->postJson('/city/actions/position', ['pos_x' => 250, 'pos_z' => -350]) + ->assertOk(); + $this->character->refresh(); + $this->assertEquals(250.0, (float) $this->character->pos_x); + $this->assertEquals(-350.0, (float) $this->character->pos_z); + + // Past the quay edge → clamped to the visit max. + $this->postJson('/city/actions/position', ['pos_x' => 400, 'pos_z' => -350]) + ->assertOk(); + $this->character->refresh(); + $this->assertEquals(266.0, (float) $this->character->pos_x); + + // Outside the port X-band the deep Z stays blocked. + $this->postJson('/city/actions/position', ['pos_x' => 130, 'pos_z' => -350]) + ->assertOk(); + $this->character->refresh(); + $this->assertEquals((float) $this->character->city->bounds_min_z, (float) $this->character->pos_z); + } +}