feat(city): add airports, ATC agent, luminous signs, and AI model fallback chain
- Add two airports (north/south) with full geometry: runways, taxiways,
aprons, terminals, hangars, stands, and helipads; walkable airport
grounds extend beyond city bounds.
- Introduce ATC AI agent ("Torre de Controle City10") using AI_ATC_MODEL
to generate takeoff clearances; includes dispatch endpoint with
30-second cooldown and deterministic fallback on failure.
- Implement AI model fallback chain: preferred model → AI_FALLBACK_MODEL
before failing, applied in ChatController and PersonTalkController.
- Add luminous sign labels (letreiros) for plazas, beach, food, hospital,
school, city hall, and airports with day/night glow.
- Fix city presence cache: compute is_me per viewer instead of baking
into shared per-second cache to prevent cross-user flag leakage.
- Add tests: AirportTest, CityPresenceTest, SignLabelsTest, and
PersonTalkTest fallback model retry scenario.
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Character;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\CityWorldSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Presence feed correctness: the per-second rows are shared, but the
|
||||
* is_me flag must always reflect the viewer of the current request —
|
||||
* otherwise a citizen sees their own character as a remote player and
|
||||
* the bump-to-talk chat opens with themselves.
|
||||
*/
|
||||
class CityPresenceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Boot world fixtures.
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->seed(CityWorldSeeder::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Two viewers polling within the same second each get their own
|
||||
* is_me flags from the shared cache.
|
||||
*/
|
||||
public function test_is_me_is_per_viewer_with_shared_cache(): void
|
||||
{
|
||||
[$userA, $charA] = $this->makeCitizen();
|
||||
[$userB, $charB] = $this->makeCitizen();
|
||||
|
||||
// Viewer A seeds the shared per-second cache first.
|
||||
$first = $this->actingAs($userA)
|
||||
->getJson('/city/presence')
|
||||
->assertOk()
|
||||
->json('citizens');
|
||||
|
||||
$this->assertTrue($this->flagFor($first, $charA->id), 'A must see itself as me');
|
||||
$this->assertFalse($this->flagFor($first, $charB->id), 'A must not see B as me');
|
||||
|
||||
// Viewer B hits the SAME cache entry moments later.
|
||||
$second = $this->actingAs($userB)
|
||||
->getJson('/city/presence')
|
||||
->assertOk()
|
||||
->json('citizens');
|
||||
|
||||
$this->assertTrue($this->flagFor($second, $charB->id), 'B must see itself as me');
|
||||
$this->assertFalse($this->flagFor($second, $charA->id), 'B must not see A as me');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an active user paired with its in-world character.
|
||||
*
|
||||
* @return array{0: User, 1: Character}
|
||||
*/
|
||||
private function makeCitizen(): array
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'status' => User::STATUS_ACTIVE,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
return [$user, Character::ensureForUser($user)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the is_me flag for a character id from a presence payload.
|
||||
*
|
||||
* @param list<array<string, mixed>> $citizens
|
||||
*/
|
||||
private function flagFor(array $citizens, int $characterId): bool
|
||||
{
|
||||
foreach ($citizens as $c) {
|
||||
if ((int) $c['id'] === $characterId) {
|
||||
return (bool) $c['is_me'];
|
||||
}
|
||||
}
|
||||
|
||||
$this->fail("Character {$characterId} missing from presence payload");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user