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:
Binary file not shown.
|
After Width: | Height: | Size: 221 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 269 KiB |
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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'],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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')),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_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}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user