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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user