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