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:
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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