diff --git a/.playwright-mcp/local-novo-shopping-noite.png b/.playwright-mcp/local-novo-shopping-noite.png new file mode 100644 index 0000000..fa315d9 Binary files /dev/null and b/.playwright-mcp/local-novo-shopping-noite.png differ diff --git a/.playwright-mcp/local-novo-universidade-noite.png b/.playwright-mcp/local-novo-universidade-noite.png new file mode 100644 index 0000000..48e60c7 Binary files /dev/null and b/.playwright-mcp/local-novo-universidade-noite.png differ diff --git a/README.md b/README.md index 168b968..d9048df 100755 --- a/README.md +++ b/README.md @@ -46,7 +46,11 @@ O visualizador `/city` carrega o mundo completo em tempo real: - **Cidade de 72 quarteirões** (grade 9 × 8 de blocos de 40 m; bounds `−120..160 × −120..120`), com rio e ponte a oeste, extensão rodoviária ao - sul, hospital, escola, prefeitura e casas de jogadores. + sul, casas de jogadores e um conjunto de lugares públicos com letreiros + luminosos: **hospital, escola, prefeitura, universidade, corpo de + bombeiros** (prédios cívicos) e **supermercado, drogaria, restaurante, + cabeleireiro, banco, hotel e shopping** (11 lojas no total, todas com + placa frontal + letreiro vertical blade). - **Trânsito**: carros, motos, bikes e ônibus com tráfego à direita, loop por quarteirão e **semáforos por cruzamento** (fases NS/EW com amarelos e all-red; veículos param na linha de parada **antes** das faixas). diff --git a/app/Http/Controllers/Web/CityViewerController.php b/app/Http/Controllers/Web/CityViewerController.php index 01f206a..af40a5f 100755 --- a/app/Http/Controllers/Web/CityViewerController.php +++ b/app/Http/Controllers/Web/CityViewerController.php @@ -365,13 +365,49 @@ class CityViewerController extends Controller } if (is_string($text) && trim($text) !== '') { - return mb_substr(trim($text), 0, 240); + $text = mb_substr(trim($text), 0, 240); + if (self::looksLikeClearance($text, $flight)) { + return $text; + } + // Model echoed the instruction instead of the clearance: + // reject and continue down the chain (next model or the + // deterministic fallback in the caller). + Log::warning('ATC clearance rejected (not a clearance)', ['model' => $model]); } } return null; } + /** + * Heuristic guard: a clearance is short, speech-like and about the + * requested flight — never a restatement of the prompt itself. + * + * @param array{number: string, airline: string, runway: string} $flight + */ + private static function looksLikeClearance(string $text, array $flight): bool + { + $lower = mb_strtolower($text); + + $promptMeta = [ + 'the user wants', + 'respond only', + 'no quotes', + 'atc style', + 'short sentence', + 'for example', + 'instructions', + ]; + foreach ($promptMeta as $needle) { + if (str_contains($lower, $needle)) { + return false; + } + } + + return str_contains($text, $flight['number']) + || preg_match('/autoriz|pista|runway|cleared|descolagem|decolag/iu', $text) === 1; + } + /** * Resolve the city from the query string or the active default. */ diff --git a/database/seeders/CityContentSeeder.php b/database/seeders/CityContentSeeder.php index c126c74..ac972a8 100755 --- a/database/seeders/CityContentSeeder.php +++ b/database/seeders/CityContentSeeder.php @@ -119,7 +119,7 @@ class CityContentSeeder extends Seeder 'description' => 'Material de construção e ferramentas.', 'type' => 'hardware', 'owner_id' => $shopkeeper?->id, - 'pos_x' => 8, 'pos_y' => 0, 'pos_z' => 6, + 'pos_x' => 11, 'pos_y' => 0, 'pos_z' => 10, 'width' => 10, 'height' => 7, 'depth' => 10, 'is_open' => true, ], diff --git a/database/seeders/CityWorldSeeder.php b/database/seeders/CityWorldSeeder.php index 9b9b555..5ccf9dd 100755 --- a/database/seeders/CityWorldSeeder.php +++ b/database/seeders/CityWorldSeeder.php @@ -136,6 +136,65 @@ class CityWorldSeeder extends Seeder ], ); + // More civic landmarks (rendered with luminous letreiros). + Building::updateOrCreate( + ['city_id' => $city->id, 'type' => 'university'], + [ + 'owner_character_id' => null, + 'name' => 'Universidade City10', + 'pos_x' => 100, + 'pos_z' => 140, + 'width' => 26, + 'depth' => 18, + 'height' => 12, + 'is_complete' => true, + 'build_progress' => 100, + ], + ); + Building::updateOrCreate( + ['city_id' => $city->id, 'type' => 'fire_station'], + [ + 'owner_character_id' => null, + 'name' => 'Corpo de Bombeiros', + 'pos_x' => -100, + 'pos_z' => -20, + 'width' => 20, + 'depth' => 14, + 'height' => 9, + 'is_complete' => true, + 'build_progress' => 100, + ], + ); + + // ---- City places (all get facade + front board + blade letreiro) + // Each sits on its own free block zone (not on a plaza row), so + // the plaza pattern and the AABB audit both stay clean. + $places = [ + ['Supermercado Bom Preço', 'supermarket', 100, -20, 22, 9, 16, 'Grande sortimento, hortifrúti e padaria interna.'], + ['Drogaria Saúde Total', 'pharmacy', 60, 20, 14, 7, 12, 'Remédios, cosméticos e atendimento 24 horas.'], + ['Restaurante Sabor da City', 'restaurant', 100, 100, 16, 8, 14, 'Comida caseira, churrasco e culinária regional.'], + ['Cabeleireiro Estilo', 'salon', -60, 20, 12, 6, 10, 'Cortes, colorações e escovas com hora marcada.'], + ['Banco Central City10', 'bank', 140, 20, 16, 10, 14, 'Conta corrente, crédito e cooperativa da cidade.'], + ['Hotel City10 Palace', 'hotel', 60, 100, 20, 14, 16, 'Suítes com vista para a praça e Wi-Fi gratuito.'], + ['Shopping City10', 'mall', -60, 100, 26, 16, 22, 'Lojas, praça de alimentação e cinema.'], + ]; + foreach ($places as [$name, $type, $px, $pz, $width, $height, $depth, $description]) { + Store::updateOrCreate( + ['name' => $name], + [ + 'description' => $description, + 'type' => $type, + 'pos_x' => $px, + 'pos_y' => 0, + 'pos_z' => $pz, + 'width' => $width, + 'height' => $height, + 'depth' => $depth, + 'is_open' => true, + ], + ); + } + Mission::updateOrCreate( ['code' => 'exercise-5'], [ diff --git a/lang/en.json b/lang/en.json index 47952ce..3994ce8 100755 --- a/lang/en.json +++ b/lang/en.json @@ -226,6 +226,8 @@ "city.sign_hospital": "Hospital", "city.sign_school": "School", "city.sign_cityhall": "City Hall", + "city.sign_university": "University", + "city.sign_firestation": "Fire Department", "city.exercise_need_equipment": "Get closer to plaza gym equipment.", "city.exercise_need_awake": "You must be awake to exercise.", "city.exercise_tired": "You are too tired to exercise.", diff --git a/lang/es.json b/lang/es.json index 75858e6..f8bc983 100755 --- a/lang/es.json +++ b/lang/es.json @@ -226,6 +226,8 @@ "city.sign_hospital": "Hospital", "city.sign_school": "Escuela", "city.sign_cityhall": "Alcaldía", + "city.sign_university": "Universidad", + "city.sign_firestation": "Cuerpo de Bomberos", "city.exercise_need_equipment": "Acérquese a un equipo de la plaza.", "city.exercise_need_awake": "Debe estar despierto para ejercitarse.", "city.exercise_tired": "Está demasiado cansado para ejercitarse.", diff --git a/lang/pt_BR.json b/lang/pt_BR.json index dd9a958..53dd34e 100755 --- a/lang/pt_BR.json +++ b/lang/pt_BR.json @@ -226,6 +226,8 @@ "city.sign_hospital": "Hospital", "city.sign_school": "Escola", "city.sign_cityhall": "Prefeitura", + "city.sign_university": "Universidade", + "city.sign_firestation": "Corpo de Bombeiros", "city.exercise_need_equipment": "Aproxime-se de um equipamento da praça.", "city.exercise_need_awake": "Você precisa estar acordado para se exercitar.", "city.exercise_tired": "Você está cansado demais para se exercitar.", diff --git a/public/js/city/app.js b/public/js/city/app.js index 7c38a34..a181c7c 100755 --- a/public/js/city/app.js +++ b/public/js/city/app.js @@ -4932,8 +4932,10 @@ function buildCity(data) { const [bx, , bz] = b.position; const [bw, bh, bd] = b.size; const isHospital = b.type === 'hospital'; + const landmarkBodyColors = { university: 0x7b87c9, fire_station: 0xc0442e }; const mat = new THREE.MeshStandardMaterial({ - color: isHospital ? 0xe8eef5 : (b.is_complete ? 0x8b6f47 : 0x6b7280), + color: isHospital ? 0xe8eef5 + : (landmarkBodyColors[b.type] ?? (b.is_complete ? 0x8b6f47 : 0x6b7280)), roughness: 0.9, transparent: !b.is_complete, opacity: b.is_complete ? 1 : 0.75, @@ -4956,19 +4958,21 @@ function buildCity(data) { } // Luminous roofline letreiro for the landmark buildings. - const landmarkLabels = { - hospital: i18n.signHospital || 'Hospital', - school: i18n.signSchool || 'Escola', - city_hall: i18n.signCityhall || 'Prefeitura', + const landmarkStyle = { + hospital: { label: i18n.signHospital || 'Hospital', accent: '#f87171' }, + school: { label: i18n.signSchool || 'Escola', accent: '#4ade80' }, + city_hall: { label: i18n.signCityhall || 'Prefeitura', accent: '#7dd3fc' }, + university: { label: i18n.signUniversity || 'Universidade', accent: '#a5b4fc' }, + fire_station: { label: i18n.signFirestation || 'Corpo de Bombeiros', accent: '#fb923c' }, }; - if (b.is_complete && landmarkLabels[b.type]) { + const landmark = landmarkStyle[b.type]; + if (b.is_complete && landmark) { const lFace = faceNearestRoad(bx, bz, grid); const [lOffX, lOffZ] = facingOffset(lFace, bw / 2 + 0.35, bd / 2 + 0.35); - const landmarkSign = buildLitSign(landmarkLabels[b.type], { + const landmarkSign = buildLitSign(landmark.label, { w: Math.min(11, Math.max(bw, bd) * 0.8), h: 1.6, - accent: b.type === 'hospital' ? '#f87171' - : (b.type === 'school' ? '#4ade80' : '#7dd3fc'), + accent: landmark.accent, }); landmarkSign.position.set(bx + lOffX, bh + 1.35, bz + lOffZ); landmarkSign.rotation.y = lFace; diff --git a/resources/views/city/viewer.blade.php b/resources/views/city/viewer.blade.php index 26d639c..24a16b2 100755 --- a/resources/views/city/viewer.blade.php +++ b/resources/views/city/viewer.blade.php @@ -374,6 +374,8 @@ signHospital: @json(__('city.sign_hospital')), signSchool: @json(__('city.sign_school')), signCityhall: @json(__('city.sign_cityhall')), + signUniversity: @json(__('city.sign_university')), + signFirestation: @json(__('city.sign_firestation')), exerciseStopHint: @json(__('city.exercise_stopHint')), exerciseStart: @json(__('city.exerciseStart')), exerciseStop: @json(__('city.exerciseStop')), diff --git a/tests/Feature/AirportTest.php b/tests/Feature/AirportTest.php index 6fee5d5..8a7c2dd 100644 --- a/tests/Feature/AirportTest.php +++ b/tests/Feature/AirportTest.php @@ -231,4 +231,29 @@ class AirportTest extends TestCase $this->assertSame('xiaomi/mimo-v2.6-flash', $models[2]); $this->assertSame('xiaomi/mimo-v2.6-flash', config('ai.fallback_model')); } + + /** + * A model that echoes the prompt instead of speaking as the tower is + * rejected and the chain falls through to the next model. + */ + public function test_dispatch_rejects_prompt_echo_replies(): 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, authorizing takeoff of flight C10-138 (Pampa Air) on runway 36. Must respond only with the sentence, no quotes.']]]], 200) + ->push(['choices' => [['message' => ['content' => 'C10-138, Pampa Air, autorizado para decolagem na pista 36.']]]], 200), + ]); + + $this->postJson('/city/airport/dispatch') + ->assertStatus(202) + ->assertJsonPath('accepted', true); + + $clearance = cache()->get('airport.clearance'); + $this->assertNotNull($clearance); + $this->assertSame('model', $clearance['source']); + $this->assertSame('C10-138, Pampa Air, autorizado para decolagem na pista 36.', $clearance['text']); + Http::assertSentCount(2); + } } diff --git a/tests/Feature/CityPlacesTest.php b/tests/Feature/CityPlacesTest.php new file mode 100644 index 0000000..8a87f26 --- /dev/null +++ b/tests/Feature/CityPlacesTest.php @@ -0,0 +1,146 @@ +seed(CityContentSeeder::class); + $this->seed(CityWorldSeeder::class); + $user = User::factory()->create(['status' => User::STATUS_ACTIVE, 'is_active' => true]); + Character::ensureForUser($user); + $this->actingAs($user); + } + + /** + * The payload carries every requested place with its type. + */ + public function test_new_city_places_exist(): void + { + $payload = $this->getJson('/city/data')->assertOk()->json(); + + $storeTypes = array_column($payload['stores'], 'type'); + $storeNames = array_column($payload['stores'], 'name'); + foreach ([ + ['supermarket', 'Supermercado Bom Preço'], + ['pharmacy', 'Drogaria Saúde Total'], + ['restaurant', 'Restaurante Sabor da City'], + ['salon', 'Cabeleireiro Estilo'], + ['bank', 'Banco Central City10'], + ['hotel', 'Hotel City10 Palace'], + ['mall', 'Shopping City10'], + ] as [$type, $name]) { + $this->assertContains($type, $storeTypes, "Missing store type {$type}"); + $this->assertContains($name, $storeNames, "Missing store {$name}"); + } + // Pre-existing places from the original request. + $this->assertContains('bakery', $storeTypes, 'Padaria must remain'); + $this->assertContains('market', $storeTypes, 'Mercado must remain'); + + $buildingTypes = array_column($payload['buildings'], 'type'); + foreach (['hospital', 'school', 'city_hall', 'university', 'fire_station'] as $type) { + $this->assertContains($type, $buildingTypes, "Missing building type {$type}"); + } + } + + /** + * Full AABB audit: no structure touches a roadway (half width 4) + * and no two structures overlap. + */ + public function test_places_avoid_roads_and_each_other(): void + { + $payload = $this->getJson('/city/data')->assertOk()->json(); + + $xs = []; + $zs = []; + foreach ($payload['layout']['roads'] as $road) { + if ($road['axis'] === 'z') { + $xs[] = (float) $road['from'][0]; + } else { + $zs[] = (float) $road['from'][2]; + } + } + $xs = array_values(array_unique($xs)); + $zs = array_values(array_unique($zs)); + $this->assertNotEmpty($xs); + $this->assertNotEmpty($zs); + + $structures = []; + foreach ($payload['stores'] as $s) { + $structures[] = [ + 'label' => 'store:'.$s['name'], + 'x0' => $s['position'][0] - $s['size'][0] / 2, + 'x1' => $s['position'][0] + $s['size'][0] / 2, + 'z0' => $s['position'][2] - $s['size'][2] / 2, + 'z1' => $s['position'][2] + $s['size'][2] / 2, + ]; + } + foreach ($payload['buildings'] as $b) { + $structures[] = [ + 'label' => 'building:'.$b['type'], + 'x0' => $b['position'][0] - $b['size'][0] / 2, + 'x1' => $b['position'][0] + $b['size'][0] / 2, + 'z0' => $b['position'][2] - $b['size'][2] / 2, + 'z1' => $b['position'][2] + $b['size'][2] / 2, + ]; + } + + $roadHits = []; + foreach ($structures as $st) { + foreach ($xs as $vx) { + if ($st['x0'] < $vx + 4 && $st['x1'] > $vx - 4) { + $roadHits[] = "{$st['label']} on z-road@{$vx}"; + } + } + foreach ($zs as $vz) { + if ($st['z0'] < $vz + 4 && $st['z1'] > $vz - 4) { + $roadHits[] = "{$st['label']} on x-road@{$vz}"; + } + } + } + $this->assertSame([], $roadHits, 'Structures over roadways: '.implode(', ', $roadHits)); + + $collisions = []; + for ($i = 0; $i < count($structures); $i++) { + for ($j = $i + 1; $j < count($structures); $j++) { + $a = $structures[$i]; + $b = $structures[$j]; + if ($a['x0'] < $b['x1'] && $a['x1'] > $b['x0'] && $a['z0'] < $b['z1'] && $a['z1'] > $b['z0']) { + $collisions[] = "{$a['label']} × {$b['label']}"; + } + } + } + $this->assertSame([], $collisions, 'Overlapping structures: '.implode(', ', $collisions)); + } + + /** + * The new places sit off the plaza rows, so the16-plaza pattern is + * unchanged. + */ + public function test_plaza_pattern_survives_new_places(): void + { + $payload = $this->getJson('/city/data')->assertOk()->json(); + + $this->assertCount(16, $payload['plazas'], 'Plaza count must stay 16 after adding places'); + } +} diff --git a/tests/Feature/SignLabelsTest.php b/tests/Feature/SignLabelsTest.php index 4ec0abf..8b8a241 100644 --- a/tests/Feature/SignLabelsTest.php +++ b/tests/Feature/SignLabelsTest.php @@ -34,6 +34,8 @@ class SignLabelsTest extends TestCase 'city.sign_hospital', 'city.sign_school', 'city.sign_cityhall', + 'city.sign_university', + 'city.sign_firestation', ]; /** @@ -69,7 +71,7 @@ class SignLabelsTest extends TestCase $html = $this->get('/city')->assertOk()->getContent(); - foreach (['signPlaza', 'signBeach', 'signFood', 'signHospital', 'signSchool', 'signCityhall'] as $jsKey) { + foreach (['signPlaza', 'signBeach', 'signFood', 'signHospital', 'signSchool', 'signCityhall', 'signUniversity', 'signFirestation'] as $jsKey) { $this->assertStringContainsString($jsKey.':', $html, "Missing i18n entry {$jsKey}"); } }