feat(city): add city landmarks, stores, luminous signs, and ATC echo guard

Add university and fire station civic buildings plus 7 new store types,
each with facade and luminous blade signs. Extend landmark material
colors and sign accents. Fix ATC dispatch to reject prompt-echoing AI
responses via heuristic guard. Add CityPlacesTest for collision and
roadway avoidance, and extend AirportTest and SignLabelsTest.
This commit is contained in:
2026-10-31 22:22:44 -03:00
parent e7498e1566
commit 46b15ec7be
14 changed files with 297 additions and 13 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

+5 -1
View File
@@ -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 - **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 `−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 - **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 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). all-red; veículos param na linha de parada **antes** das faixas).
@@ -365,13 +365,49 @@ class CityViewerController extends Controller
} }
if (is_string($text) && trim($text) !== '') { 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; 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. * Resolve the city from the query string or the active default.
*/ */
+1 -1
View File
@@ -119,7 +119,7 @@ class CityContentSeeder extends Seeder
'description' => 'Material de construção e ferramentas.', 'description' => 'Material de construção e ferramentas.',
'type' => 'hardware', 'type' => 'hardware',
'owner_id' => $shopkeeper?->id, '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, 'width' => 10, 'height' => 7, 'depth' => 10,
'is_open' => true, 'is_open' => true,
], ],
+59
View File
@@ -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( Mission::updateOrCreate(
['code' => 'exercise-5'], ['code' => 'exercise-5'],
[ [
+2
View File
@@ -226,6 +226,8 @@
"city.sign_hospital": "Hospital", "city.sign_hospital": "Hospital",
"city.sign_school": "School", "city.sign_school": "School",
"city.sign_cityhall": "City Hall", "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_equipment": "Get closer to plaza gym equipment.",
"city.exercise_need_awake": "You must be awake to exercise.", "city.exercise_need_awake": "You must be awake to exercise.",
"city.exercise_tired": "You are too tired to exercise.", "city.exercise_tired": "You are too tired to exercise.",
+2
View File
@@ -226,6 +226,8 @@
"city.sign_hospital": "Hospital", "city.sign_hospital": "Hospital",
"city.sign_school": "Escuela", "city.sign_school": "Escuela",
"city.sign_cityhall": "Alcaldía", "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_equipment": "Acérquese a un equipo de la plaza.",
"city.exercise_need_awake": "Debe estar despierto para ejercitarse.", "city.exercise_need_awake": "Debe estar despierto para ejercitarse.",
"city.exercise_tired": "Está demasiado cansado para ejercitarse.", "city.exercise_tired": "Está demasiado cansado para ejercitarse.",
+2
View File
@@ -226,6 +226,8 @@
"city.sign_hospital": "Hospital", "city.sign_hospital": "Hospital",
"city.sign_school": "Escola", "city.sign_school": "Escola",
"city.sign_cityhall": "Prefeitura", "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_equipment": "Aproxime-se de um equipamento da praça.",
"city.exercise_need_awake": "Você precisa estar acordado para se exercitar.", "city.exercise_need_awake": "Você precisa estar acordado para se exercitar.",
"city.exercise_tired": "Você está cansado demais para se exercitar.", "city.exercise_tired": "Você está cansado demais para se exercitar.",
+13 -9
View File
@@ -4932,8 +4932,10 @@ function buildCity(data) {
const [bx, , bz] = b.position; const [bx, , bz] = b.position;
const [bw, bh, bd] = b.size; const [bw, bh, bd] = b.size;
const isHospital = b.type === 'hospital'; const isHospital = b.type === 'hospital';
const landmarkBodyColors = { university: 0x7b87c9, fire_station: 0xc0442e };
const mat = new THREE.MeshStandardMaterial({ 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, roughness: 0.9,
transparent: !b.is_complete, transparent: !b.is_complete,
opacity: b.is_complete ? 1 : 0.75, opacity: b.is_complete ? 1 : 0.75,
@@ -4956,19 +4958,21 @@ function buildCity(data) {
} }
// Luminous roofline letreiro for the landmark buildings. // Luminous roofline letreiro for the landmark buildings.
const landmarkLabels = { const landmarkStyle = {
hospital: i18n.signHospital || 'Hospital', hospital: { label: i18n.signHospital || 'Hospital', accent: '#f87171' },
school: i18n.signSchool || 'Escola', school: { label: i18n.signSchool || 'Escola', accent: '#4ade80' },
city_hall: i18n.signCityhall || 'Prefeitura', 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 lFace = faceNearestRoad(bx, bz, grid);
const [lOffX, lOffZ] = facingOffset(lFace, bw / 2 + 0.35, bd / 2 + 0.35); 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), w: Math.min(11, Math.max(bw, bd) * 0.8),
h: 1.6, h: 1.6,
accent: b.type === 'hospital' ? '#f87171' accent: landmark.accent,
: (b.type === 'school' ? '#4ade80' : '#7dd3fc'),
}); });
landmarkSign.position.set(bx + lOffX, bh + 1.35, bz + lOffZ); landmarkSign.position.set(bx + lOffX, bh + 1.35, bz + lOffZ);
landmarkSign.rotation.y = lFace; landmarkSign.rotation.y = lFace;
+2
View File
@@ -374,6 +374,8 @@
signHospital: @json(__('city.sign_hospital')), signHospital: @json(__('city.sign_hospital')),
signSchool: @json(__('city.sign_school')), signSchool: @json(__('city.sign_school')),
signCityhall: @json(__('city.sign_cityhall')), signCityhall: @json(__('city.sign_cityhall')),
signUniversity: @json(__('city.sign_university')),
signFirestation: @json(__('city.sign_firestation')),
exerciseStopHint: @json(__('city.exercise_stopHint')), exerciseStopHint: @json(__('city.exercise_stopHint')),
exerciseStart: @json(__('city.exerciseStart')), exerciseStart: @json(__('city.exerciseStart')),
exerciseStop: @json(__('city.exerciseStop')), exerciseStop: @json(__('city.exerciseStop')),
+25
View File
@@ -231,4 +231,29 @@ class AirportTest extends TestCase
$this->assertSame('xiaomi/mimo-v2.6-flash', $models[2]); $this->assertSame('xiaomi/mimo-v2.6-flash', $models[2]);
$this->assertSame('xiaomi/mimo-v2.6-flash', config('ai.fallback_model')); $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);
}
} }
+146
View File
@@ -0,0 +1,146 @@
<?php
namespace Tests\Feature;
use App\Models\Character;
use App\Models\User;
use Database\Seeders\CityContentSeeder;
use Database\Seeders\CityWorldSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* City places (stores + civic buildings): the new landmarks exist with
* the right types, never sit on a roadway, never overlap each other and
* do not steal cells from the plaza pattern.
*/
class CityPlacesTest extends TestCase
{
use RefreshDatabase;
/**
* Boot world fixtures.
*/
protected function setUp(): void
{
parent::setUp();
// Same order as DatabaseSeeder: content (stores/agents) then world.
$this->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');
}
}
+3 -1
View File
@@ -34,6 +34,8 @@ class SignLabelsTest extends TestCase
'city.sign_hospital', 'city.sign_hospital',
'city.sign_school', 'city.sign_school',
'city.sign_cityhall', 'city.sign_cityhall',
'city.sign_university',
'city.sign_firestation',
]; ];
/** /**
@@ -69,7 +71,7 @@ class SignLabelsTest extends TestCase
$html = $this->get('/city')->assertOk()->getContent(); $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}"); $this->assertStringContainsString($jsKey.':', $html, "Missing i18n entry {$jsKey}");
} }
} }