feat(port): add sea port with quay, terminal, berths, and AI port master
Adds a new port district on the north-east coast with walkable quay, container yard, terminal, gantry cranes, and berths. Includes AI-driven port master (Capitão do Porto) and dock workers for ship dispatch, plus full frontend rendering of ships, boats, and trucks.
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 197 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 202 KiB |
@@ -13,6 +13,7 @@ use App\Support\Airports;
|
|||||||
use App\Support\Beach;
|
use App\Support\Beach;
|
||||||
use App\Support\CityPlazas;
|
use App\Support\CityPlazas;
|
||||||
use App\Support\MissionTracker;
|
use App\Support\MissionTracker;
|
||||||
|
use App\Support\Port;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -62,6 +63,11 @@ class CityActionController extends Controller
|
|||||||
$minZ = Airports::VISIT_MIN_Z;
|
$minZ = Airports::VISIT_MIN_Z;
|
||||||
$maxZ = Airports::VISIT_MAX_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']));
|
$z = max($minZ, min($maxZ, (float) $data['pos_z']));
|
||||||
|
|
||||||
$character->forceFill([
|
$character->forceFill([
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use App\Models\Store;
|
|||||||
use App\Support\Airports;
|
use App\Support\Airports;
|
||||||
use App\Support\Beach;
|
use App\Support\Beach;
|
||||||
use App\Support\CityPlazas;
|
use App\Support\CityPlazas;
|
||||||
|
use App\Support\Port;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@@ -147,6 +148,7 @@ class CityViewerController extends Controller
|
|||||||
'plazas' => CityPlazas::forCity($city),
|
'plazas' => CityPlazas::forCity($city),
|
||||||
'beach' => Beach::geometry($city),
|
'beach' => Beach::geometry($city),
|
||||||
'airports' => Airports::geometry($city),
|
'airports' => Airports::geometry($city),
|
||||||
|
'port' => Port::geometry($city),
|
||||||
'agents' => AiAgent::orderBy('id')->get()->map(fn ($a): array => [
|
'agents' => AiAgent::orderBy('id')->get()->map(fn ($a): array => [
|
||||||
'id' => $a->id,
|
'id' => $a->id,
|
||||||
'name' => $a->name,
|
'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
|
* @var list<string>
|
||||||
* 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
|
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
|
||||||
{
|
{
|
||||||
// Without an API key there is nothing to ask (no try consumed).
|
|
||||||
if (! filled(config('ai.openrouter.api_key'))) {
|
if (! filled(config('ai.openrouter.api_key'))) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$allowed = config('ai.allowed_models', []);
|
$allowed = config('ai.allowed_models', []);
|
||||||
$candidates = [];
|
$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)) {
|
if (is_string($model) && $model !== '' && in_array($model, $allowed, true)) {
|
||||||
$candidates[$model] = $model;
|
$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);
|
$timeout = min((int) config('ai.openrouter.timeout', 30), 15);
|
||||||
|
|
||||||
foreach ($candidates as $model) {
|
foreach ($candidates as $model) {
|
||||||
@@ -345,13 +361,13 @@ class CityViewerController extends Controller
|
|||||||
'max_tokens' => 120,
|
'max_tokens' => 120,
|
||||||
]);
|
]);
|
||||||
} catch (\Throwable $e) {
|
} 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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($response->failed()) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -366,19 +382,64 @@ class CityViewerController extends Controller
|
|||||||
|
|
||||||
if (is_string($text) && trim($text) !== '') {
|
if (is_string($text) && trim($text) !== '') {
|
||||||
$text = mb_substr(trim($text), 0, 240);
|
$text = mb_substr(trim($text), 0, 240);
|
||||||
if (self::looksLikeClearance($text, $flight)) {
|
if ($accept === null || $accept($text)) {
|
||||||
return $text;
|
return $text;
|
||||||
}
|
}
|
||||||
// Model echoed the instruction instead of the clearance:
|
// Echoed the instruction instead of the phrase: reject and
|
||||||
// reject and continue down the chain (next model or the
|
// continue down the chain (next model, then the caller's
|
||||||
// deterministic fallback in the caller).
|
// deterministic fallback).
|
||||||
Log::warning('ATC clearance rejected (not a clearance)', ['model' => $model]);
|
Log::warning('Clearance rejected (not a clearance)', ['model' => $model]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
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
|
* Heuristic guard: a clearance is short, speech-like and about the
|
||||||
* requested flight — never a restatement of the prompt itself.
|
* requested flight — never a restatement of the prompt itself.
|
||||||
@@ -389,16 +450,7 @@ class CityViewerController extends Controller
|
|||||||
{
|
{
|
||||||
$lower = mb_strtolower($text);
|
$lower = mb_strtolower($text);
|
||||||
|
|
||||||
$promptMeta = [
|
foreach (self::PROMPT_META as $needle) {
|
||||||
'the user wants',
|
|
||||||
'respond only',
|
|
||||||
'no quotes',
|
|
||||||
'atc style',
|
|
||||||
'short sentence',
|
|
||||||
'for example',
|
|
||||||
'instructions',
|
|
||||||
];
|
|
||||||
foreach ($promptMeta as $needle) {
|
|
||||||
if (str_contains($lower, $needle)) {
|
if (str_contains($lower, $needle)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -408,6 +460,150 @@ class CityViewerController extends Controller
|
|||||||
|| preg_match('/autoriz|pista|runway|cleared|descolagem|decolag/iu', $text) === 1;
|
|| 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.
|
* Resolve the city from the query string or the active default.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Models\City;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sea port on the north-east coast (past the beach, on the same east
|
||||||
|
* shoreline).
|
||||||
|
*
|
||||||
|
* The port occupies only the FAR HALF of that side (z −430..−290): the
|
||||||
|
* near half (z −290..−150) is intentionally left empty so the city can
|
||||||
|
* keep growing north/east without ever colliding with the port lands.
|
||||||
|
*
|
||||||
|
* Geometry derives from the Beach waterline so the quay always meets
|
||||||
|
* the sea, and feeds the world payload: quay, container yard, terminal,
|
||||||
|
* gantry cranes, berths and the fleet descriptions. Ships' movements are
|
||||||
|
* authorised by the port AI agent (Capitão do Porto) through
|
||||||
|
* /city/port/dispatch — same pattern as the airports.
|
||||||
|
*/
|
||||||
|
class Port
|
||||||
|
{
|
||||||
|
/** West edge of the walkable port strip (east of the city bounds). */
|
||||||
|
public const VISIT_MIN_X = 150.0;
|
||||||
|
|
||||||
|
/** Visit Z range: from the port up to the start of the beach sand. */
|
||||||
|
public const VISIT_MIN_Z = -430.0;
|
||||||
|
|
||||||
|
public const VISIT_MAX_Z = -290.0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full port descriptor for the world payload.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
$bakery = Store::where('type', 'bakery')->orderBy('id')->first();
|
||||||
$market = Store::where('type', 'market')->orderBy('id')->first();
|
$market = Store::where('type', 'market')->orderBy('id')->first();
|
||||||
|
|
||||||
|
|||||||
@@ -220,6 +220,7 @@
|
|||||||
"city.beach_prompt": "Relax at the beach",
|
"city.beach_prompt": "Relax at the beach",
|
||||||
"city.beach_active": "Relaxing at the beach",
|
"city.beach_active": "Relaxing at the beach",
|
||||||
"city.atc_missing": "No control tower configured.",
|
"city.atc_missing": "No control tower configured.",
|
||||||
|
"city.port_missing": "No port authority configured.",
|
||||||
"city.sign_plaza": "Plaza",
|
"city.sign_plaza": "Plaza",
|
||||||
"city.sign_beach": "Beach",
|
"city.sign_beach": "Beach",
|
||||||
"city.sign_food": "Food",
|
"city.sign_food": "Food",
|
||||||
|
|||||||
@@ -220,6 +220,7 @@
|
|||||||
"city.beach_prompt": "Relajar en la playa",
|
"city.beach_prompt": "Relajar en la playa",
|
||||||
"city.beach_active": "Relajándote en la playa",
|
"city.beach_active": "Relajándote en la playa",
|
||||||
"city.atc_missing": "Ninguna torre de control configurada.",
|
"city.atc_missing": "Ninguna torre de control configurada.",
|
||||||
|
"city.port_missing": "Ninguna capitanía del puerto configurada.",
|
||||||
"city.sign_plaza": "Plaza",
|
"city.sign_plaza": "Plaza",
|
||||||
"city.sign_beach": "Playa",
|
"city.sign_beach": "Playa",
|
||||||
"city.sign_food": "Comida",
|
"city.sign_food": "Comida",
|
||||||
|
|||||||
@@ -220,6 +220,7 @@
|
|||||||
"city.beach_prompt": "Relaxar na praia",
|
"city.beach_prompt": "Relaxar na praia",
|
||||||
"city.beach_active": "Relaxando na praia",
|
"city.beach_active": "Relaxando na praia",
|
||||||
"city.atc_missing": "Nenhuma torre de controle configurada.",
|
"city.atc_missing": "Nenhuma torre de controle configurada.",
|
||||||
|
"city.port_missing": "Nenhuma capitania do porto configurada.",
|
||||||
"city.sign_plaza": "Praça",
|
"city.sign_plaza": "Praça",
|
||||||
"city.sign_beach": "Praia",
|
"city.sign_beach": "Praia",
|
||||||
"city.sign_food": "Comidas",
|
"city.sign_food": "Comidas",
|
||||||
|
|||||||
+699
-7
@@ -247,6 +247,10 @@ let beach = null;
|
|||||||
let beachSystem = null;
|
let beachSystem = null;
|
||||||
// Airports payload {visit, sites} — geometry, walk zone and flight paths.
|
// Airports payload {visit, sites} — geometry, walk zone and flight paths.
|
||||||
let airportsCfg = null;
|
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 remoteCitizens = new Map(); // characterId -> mesh
|
||||||
let presenceTimer = null;
|
let presenceTimer = null;
|
||||||
let vehicleHeadlights = [];
|
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];
|
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. */
|
/** Distance between two 3D points. */
|
||||||
function dist3(a, b) {
|
function dist3(a, b) {
|
||||||
return Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]);
|
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
|
// 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)
|
* @param {object} cfg beach geometry (sand_from/sand_to/sea_to/z_min/z_max)
|
||||||
* @returns {{update: Function, counts: Function}}
|
* @returns {{update: Function, counts: Function}}
|
||||||
*/
|
*/
|
||||||
function initBeach(cfg) {
|
function initBeach(cfg, portCfg = null) {
|
||||||
const sandFrom = cfg.sand_from;
|
const sandFrom = cfg.sand_from;
|
||||||
const sandTo = cfg.sand_to;
|
const sandTo = cfg.sand_to;
|
||||||
const seaTo = cfg.sea_to;
|
const seaTo = cfg.sea_to;
|
||||||
@@ -3908,6 +4573,11 @@ function initBeach(cfg) {
|
|||||||
const zMax = cfg.z_max;
|
const zMax = cfg.z_max;
|
||||||
const midZ = (zMin + zMax) / 2;
|
const midZ = (zMin + zMax) / 2;
|
||||||
const zDepth = zMax - zMin;
|
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 group = new THREE.Group();
|
||||||
const counts = {
|
const counts = {
|
||||||
@@ -3931,14 +4601,17 @@ function initBeach(cfg) {
|
|||||||
|
|
||||||
// ---- Sea with animated waves (one mesh, vertex displacement) ----
|
// ---- Sea with animated waves (one mesh, vertex displacement) ----
|
||||||
const seaW = seaTo - sandTo;
|
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({
|
const seaMat = new THREE.MeshStandardMaterial({
|
||||||
color: 0x2f7fb5, roughness: 0.35, metalness: 0.15,
|
color: 0x2f7fb5, roughness: 0.35, metalness: 0.15,
|
||||||
transparent: true, opacity: 0.94,
|
transparent: true, opacity: 0.94,
|
||||||
});
|
});
|
||||||
const seaMesh = new THREE.Mesh(seaGeo, seaMat);
|
const seaMesh = new THREE.Mesh(seaGeo, seaMat);
|
||||||
seaMesh.rotation.x = -Math.PI / 2;
|
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;
|
seaMesh.frustumCulled = false;
|
||||||
group.add(seaMesh);
|
group.add(seaMesh);
|
||||||
const seaBase = Float32Array.from(seaGeo.attributes.position.array);
|
const seaBase = Float32Array.from(seaGeo.attributes.position.array);
|
||||||
@@ -4242,7 +4915,7 @@ function initBeach(cfg) {
|
|||||||
for (let i = 0; i < pos.count; i++) {
|
for (let i = 0; i < pos.count; i++) {
|
||||||
const lx = seaBase[i * 3];
|
const lx = seaBase[i * 3];
|
||||||
const ly = seaBase[i * 3 + 1];
|
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;
|
pos.needsUpdate = true;
|
||||||
foamMat.opacity = 0.55 + Math.sin(time * 1.7) * 0.2;
|
foamMat.opacity = 0.55 + Math.sin(time * 1.7) * 0.2;
|
||||||
@@ -4594,6 +5267,8 @@ function buildCity(data) {
|
|||||||
beach = null;
|
beach = null;
|
||||||
beachSystem = null;
|
beachSystem = null;
|
||||||
airportsCfg = null;
|
airportsCfg = null;
|
||||||
|
portCfg = null;
|
||||||
|
portSystem = null;
|
||||||
|
|
||||||
const layout = data.layout || { roads: [], blocks: [] };
|
const layout = data.layout || { roads: [], blocks: [] };
|
||||||
const stores = data.stores || [];
|
const stores = data.stores || [];
|
||||||
@@ -4936,14 +5611,18 @@ function buildCity(data) {
|
|||||||
worldGroup.add(ring);
|
worldGroup.add(ring);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Beach (east shore) ----
|
// ---- Beach (east shore, sea also covers the port basin) ----
|
||||||
beach = data.beach || null;
|
beach = data.beach || null;
|
||||||
beachSystem = beach ? initBeach(beach) : null;
|
portCfg = data.port || null;
|
||||||
|
beachSystem = beach ? initBeach(beach, portCfg) : null;
|
||||||
|
|
||||||
// ---- Airports (north/south) + flight operations ----
|
// ---- Airports (north/south) + flight operations ----
|
||||||
airportsCfg = data.airports || null;
|
airportsCfg = data.airports || null;
|
||||||
buildAirports(airportsCfg);
|
buildAirports(airportsCfg);
|
||||||
|
|
||||||
|
// ---- Port (far half of the north-east coast) ----
|
||||||
|
portSystem = portCfg ? initPort(portCfg) : null;
|
||||||
|
|
||||||
// ---- Filler buildings ----
|
// ---- Filler buildings ----
|
||||||
// Fillers only exist on empty ground: skip cells near stores AND near
|
// Fillers only exist on empty ground: skip cells near stores AND near
|
||||||
// any placed building (hospital, school, city hall, player houses) so
|
// 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) {
|
if (beach && player.pos.z >= beach.z_min && player.pos.z <= beach.z_max) {
|
||||||
maxX = Math.max(maxX, beach.sand_to + 1);
|
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
|
player.pos.x = cityBounds
|
||||||
? Math.max(cityBounds.min_x, Math.min(maxX, player.pos.x))
|
? Math.max(cityBounds.min_x, Math.min(maxX, player.pos.x))
|
||||||
: Math.max(-400, 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;
|
minZ = airportsCfg.visit.min_z;
|
||||||
maxZ = airportsCfg.visit.max_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));
|
player.pos.z = Math.max(minZ, Math.min(maxZ, player.pos.z));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6180,7 +6865,13 @@ function animate() {
|
|||||||
if (airplaneSystem) airplaneSystem.update(dt);
|
if (airplaneSystem) airplaneSystem.update(dt);
|
||||||
if (trainSystem) trainSystem.update(dt);
|
if (trainSystem) trainSystem.update(dt);
|
||||||
if (riverSystem) riverSystem.update(dt, simTimeAcc);
|
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);
|
updatePlayer(dt);
|
||||||
checkVehicleHitOnPlayer();
|
checkVehicleHitOnPlayer();
|
||||||
@@ -6748,6 +7439,7 @@ window.__city10 = {
|
|||||||
sidewalkSegments: sidewalkSegmentCount,
|
sidewalkSegments: sidewalkSegmentCount,
|
||||||
beach: beachSystem ? beachSystem.counts() : null,
|
beach: beachSystem ? beachSystem.counts() : null,
|
||||||
flights: airplaneSystem ? airplaneSystem.flights() : null,
|
flights: airplaneSystem ? airplaneSystem.flights() : null,
|
||||||
|
port: portSystem ? portSystem.counts() : null,
|
||||||
robots: pedestrianSystem
|
robots: pedestrianSystem
|
||||||
? pedestrianSystem.walkers.filter(w => w.robot).length
|
? pedestrianSystem.walkers.filter(w => w.robot).length
|
||||||
: 0,
|
: 0,
|
||||||
|
|||||||
@@ -137,6 +137,14 @@ Route::middleware('auth')->group(function (): void {
|
|||||||
Route::post('/city/airport/dispatch', [CityViewerController::class, 'dispatch'])
|
Route::post('/city/airport/dispatch', [CityViewerController::class, 'dispatch'])
|
||||||
->middleware('throttle:60,1')
|
->middleware('throttle:60,1')
|
||||||
->name('city.airport.dispatch');
|
->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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Models\Job;
|
|||||||
use App\Models\Transaction;
|
use App\Models\Transaction;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Support\Beach;
|
use App\Support\Beach;
|
||||||
|
use App\Support\Port;
|
||||||
use Database\Seeders\CityWorldSeeder;
|
use Database\Seeders\CityWorldSeeder;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
@@ -58,7 +59,8 @@ class CityActionsTest extends TestCase
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Position updates clamp to the beach shoreline (past the road grid)
|
* 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
|
public function test_position_persists_and_clamps(): void
|
||||||
{
|
{
|
||||||
@@ -67,8 +69,10 @@ class CityActionsTest extends TestCase
|
|||||||
|
|
||||||
$this->character->refresh();
|
$this->character->refresh();
|
||||||
$city = $this->character->city;
|
$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(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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\AiAgent;
|
||||||
|
use App\Models\Character;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\Port;
|
||||||
|
use Database\Seeders\CityWorldSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port payload, half-side placement (expansion buffer), Capitania staff
|
||||||
|
* and the AI-driven maneuver clearances.
|
||||||
|
*/
|
||||||
|
class PortTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected User $user;
|
||||||
|
|
||||||
|
protected Character $character;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boot world fixtures.
|
||||||
|
*/
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user