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:
2026-10-31 23:33:06 -03:00
parent e230c391ad
commit 580c830ffb
13 changed files with 1338 additions and 42 deletions
+6 -2
View File
@@ -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);
}
/**
+250
View File
@@ -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);
}
}