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); } }