Files
City10/tests/Feature/PortTest.php
T
marcocosta10000 580c830ffb 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.
2026-10-31 23:33:06 -03:00

251 lines
9.1 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
}
}