- 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.
78 lines
2.7 KiB
PHP
Executable File
78 lines
2.7 KiB
PHP
Executable File
<?php
|
|
|
|
namespace App\Http\Controllers\Web;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\Character;
|
|
use App\Models\City;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Cache;
|
|
|
|
/**
|
|
* Lightweight multiplayer presence feed.
|
|
*
|
|
* Returns the positions and states of characters active in a city so the
|
|
* viewer can render other citizens in near-real-time without WebSockets.
|
|
*/
|
|
class CityPresenceController extends Controller
|
|
{
|
|
/**
|
|
* Snapshot of citizens in the requested (or default) city.
|
|
*/
|
|
public function __invoke(Request $request): JsonResponse
|
|
{
|
|
$user = $request->user();
|
|
abort_unless($user !== null, 401);
|
|
|
|
$me = Character::ensureForUser($user);
|
|
$slug = $request->query('city');
|
|
$city = $me->city;
|
|
if (is_string($slug) && $slug !== '') {
|
|
$city = City::query()
|
|
->where('slug', $slug)
|
|
->orWhere('id', ctype_digit($slug) ? (int) $slug : 0)
|
|
->first() ?? $city;
|
|
}
|
|
|
|
// Rows are shared across users for one second, so is_me must NOT
|
|
// be baked into the cached payload — it is computed per viewer
|
|
// below (otherwise user B sees user A's is_me flags and could see
|
|
// their own character as a remote citizen).
|
|
$cacheKey = 'presence.'.$city->id.'.'.now()->timestamp;
|
|
$citizens = Cache::remember($cacheKey, 1, function () use ($city) {
|
|
return Character::query()
|
|
->where('city_id', $city->id)
|
|
->where(function ($q): void {
|
|
$q->where('is_online', true)
|
|
->orWhere('updated_at', '>=', now()->subMinutes(10));
|
|
})
|
|
->with('user:id,name')
|
|
->get()
|
|
->map(fn (Character $c): array => [
|
|
'id' => $c->id,
|
|
'name' => $c->name,
|
|
'type' => $c->user_id === null ? 'child' : 'citizen',
|
|
'status' => $c->state,
|
|
'position' => [(float) $c->pos_x, (float) $c->pos_y, (float) $c->pos_z],
|
|
'is_online' => (bool) $c->is_online,
|
|
'owner_name' => $c->user?->name,
|
|
])
|
|
->values()
|
|
->all();
|
|
});
|
|
|
|
$viewerId = (int) $me->id;
|
|
$citizens = array_map(
|
|
static fn (array $c): array => $c + ['is_me' => (int) $c['id'] === $viewerId],
|
|
$citizens
|
|
);
|
|
|
|
return response()->json([
|
|
'city_id' => $city->id,
|
|
'server_time' => now()->toIso8601String(),
|
|
'citizens' => $citizens,
|
|
])->header('Cache-Control', 'no-store');
|
|
}
|
|
}
|