Files
City10/app/Http/Controllers/Web/PersonTalkController.php
T
marcocosta10000 e7498e1566 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.
2026-10-31 22:06:18 -03:00

621 lines
23 KiB
PHP

<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\AiAgent;
use App\Models\Character;
use App\Models\PersonTalk;
use App\Models\Store;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Db;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Daily street conversations between the local character and another
* character (human peer) or an agentic NPC.
*
* GET /talk/person — conversation history (all days) used both by the
* chat UI and as the persistent context that keeps the thread of previous
* conversations. POST /talk/person — registers one message per day; for
* agent peers it also triggers the OpenRouter reply asynchronously.
*
* Peers without a live human session (seeded citizens, NPCs, idle demo
* accounts) answer through a generic city-inhabitant persona so chats
* never dead-end; a peer with a recent session is relayed as-is and is
* never impersonated.
*
* Only simulated city context and the dialogue itself are sent upstream,
* never registration data (RULES.md 2.2). Failures are retried with
* backoff bookkeeping (RULES.md 2.4) and decisions are persisted for
* audit (RULES.md 2.5).
*/
class PersonTalkController extends Controller
{
/**
* Maximum messages returned for the UI and model context.
*
* @var int
*/
private const HISTORY_LIMIT = 300;
/**
* Per-peer spacing between two reasoning calls, in seconds.
*
* Conversational replies are user-driven (one call per message), and
* this damper keeps rapid bursts from hammering the upstream API.
*
* @var int
*/
private const THINK_SPACING_SECONDS = 3;
/**
* How recent a peer session must be to count as a live human.
*
* @var int
*/
private const LIVE_PEER_SECONDS = 300;
/**
* Conversation history (newest last) plus the peer summary.
*/
public function index(Request $request): JsonResponse
{
$me = Character::ensureForUser($request->user());
[$kind, $peer] = $this->resolvePeer($request);
$rows = PersonTalk::conversation($me->id, $kind, (int) $peer->id)
->orderBy('id')
->limit(self::HISTORY_LIMIT)
->get();
$messages = $rows->map(fn (PersonTalk $talk): array => [
'id' => (int) $talk->id,
'from' => (int) $talk->from_character_id === (int) $me->id ? 'me' : 'them',
'body' => $talk->body,
'at' => $talk->created_at?->toIso8601String(),
'talked_on' => $talk->talked_on?->toDateString(),
'fallback' => (bool) $talk->is_fallback,
])->values();
$last = $messages->last();
$awaitingMe = $last !== null && $last['from'] === 'me';
$thinking = $kind === PersonTalk::KIND_AGENT
? $awaitingMe
: ($awaitingMe && cache()->has(self::pendingKey((int) $peer->id)));
return response()->json([
'me' => ['character_id' => (int) $me->id, 'name' => $me->name],
'peer' => [
'kind' => $kind,
'id' => (int) $peer->id,
'name' => $peer->name,
'status' => $kind === PersonTalk::KIND_AGENT ? $peer->status : 'active',
],
'messages' => $messages,
'thinking' => $thinking,
])->header('Cache-Control', 'no-store');
}
/**
* Register one message of the day and reply when nobody human is
* manning the other side.
*/
public function send(Request $request): JsonResponse
{
$me = Character::ensureForUser($request->user());
$data = $request->validate([
'message' => ['required', 'string', 'max:1000'],
]);
[$kind, $peer] = $this->resolvePeer($request);
if ($kind === PersonTalk::KIND_CHARACTER && (int) $peer->id === (int) $me->id) {
return response()->json(['message' => __('city.social_self')], 422);
}
$talk = PersonTalk::create([
'from_character_id' => $me->id,
'to_character_id' => $kind === PersonTalk::KIND_CHARACTER ? $peer->id : null,
'to_agent_id' => $kind === PersonTalk::KIND_AGENT ? $peer->id : null,
'body' => $data['message'],
'talked_on' => now()->toDateString(),
]);
$talkId = (int) $talk->id;
if ($kind === PersonTalk::KIND_AGENT) {
/** @var AiAgent $peer */
$peer->update(['status' => 'thinking']);
// Answer after the HTTP response so the chat stays snappy;
// runs in-process (no queue worker required).
dispatch(function () use ($talkId): void {
self::think($talkId);
})->afterResponse();
} elseif (! self::characterPeerIsLive($peer)) {
// No live human on the other side (seeded citizen, NPC child,
// demo account): answer through the generic persona so the
// conversation keeps flowing.
/** @var Character $peer */
cache()->put(self::pendingKey((int) $peer->id), 1, now()->addSeconds(60));
dispatch(function () use ($talkId): void {
self::thinkAsCitizen($talkId);
})->afterResponse();
}
return response()->json([
'id' => (int) $talk->id,
'from' => 'me',
'body' => $talk->body,
'at' => $talk->created_at?->toIso8601String(),
'talked_on' => $talk->talked_on->toDateString(),
], 201);
}
/**
* Generate the agent reply for a pending talk message.
*
* Public static so it can be exercised directly in tests with a
* faked HTTP client. Reads the whole daily conversation from the
* database, which keeps the thread of previous days intact.
*/
public static function think(int $talkId): void
{
try {
$talk = PersonTalk::query()->find($talkId);
if ($talk === null || $talk->to_agent_id === null) {
return;
}
$agent = AiAgent::query()->find($talk->to_agent_id);
$me = Character::query()->find($talk->from_character_id);
if ($agent === null || $me === null) {
return;
}
if (! self::acquireThinkLock('agent.'.$agent->id)) {
return;
}
// Another reply already landed after this message (serialised
// bursts) — nothing left to answer.
$answered = PersonTalk::conversation($me->id, PersonTalk::KIND_AGENT, (int) $agent->id)
->where('from_agent_id', $agent->id)
->where('id', '>', $talk->id)
->exists();
if ($answered) {
return;
}
if (! filled(config('ai.openrouter.api_key'))) {
self::recordAgentFailure($agent);
self::storeReply(
['from_agent_id' => $agent->id, 'to_character_id' => $me->id],
__('city.chat_error'),
true
);
return;
}
$system = ($agent->system_prompt ?: "Você é {$agent->name}, um habitante da cidade virtual City10.")
."\n\nContexto simulado da cidade: ".json_encode(self::simulatedCityContext(), JSON_UNESCAPED_UNICODE)
."\nAs mensagens abaixo são o histórico da conversa de rua com este cidadão, incluindo dias anteriores."
.' Mantenha o fio da meada: referencie o que já foi dito quando fizer sentido.'
."\nResponda de forma curta e natural, em português. Não invente dados pessoais de usuários.";
$model = self::resolveModel($agent->model, $agent->name, (int) $agent->id);
$reply = self::askModel($model, $system, $me->id, $talk);
if ($reply === null) {
self::recordAgentFailure($agent);
self::storeReply(
['from_agent_id' => $agent->id, 'to_character_id' => $me->id],
__('city.chat_error'),
true
);
return;
}
self::storeReply(
['from_agent_id' => $agent->id, 'to_character_id' => $me->id],
$reply
);
$historyLog = $agent->last_decision ?? [];
$historyLog[] = ['role' => 'user', 'content' => $talk->body, 'at' => $talk->created_at?->toIso8601String()];
$historyLog[] = ['role' => 'assistant', 'content' => $reply, 'at' => now()->toIso8601String()];
$agent->update([
'last_decision' => array_slice($historyLog, -20),
'status' => 'active',
'error_count' => 0,
'last_think_at' => now(),
]);
} catch (\Throwable $e) {
Log::error('Person talk think failed', ['talk' => $talkId, 'error' => $e->getMessage()]);
}
}
/**
* Generate a reply on behalf of a character peer that has no live
* human behind it, using an anonymous city-inhabitant persona.
*
* The payload never includes registration data — no names, e-mails
* or any other PII (RULES.md 2.2); only simulated city context and
* the dialogue itself travel upstream.
*/
public static function thinkAsCitizen(int $talkId): void
{
$pendingKey = null;
try {
$talk = PersonTalk::query()->find($talkId);
if ($talk === null || $talk->to_character_id === null) {
return;
}
$citizen = Character::query()->find($talk->to_character_id);
$me = Character::query()->find($talk->from_character_id);
if ($citizen === null || $me === null) {
return;
}
$pendingKey = self::pendingKey((int) $citizen->id);
$replySides = [
'from_character_id' => $citizen->id,
'to_character_id' => $me->id,
];
if (! self::acquireThinkLock('character.'.$citizen->id)) {
return;
}
// A human (or a serialised sibling job) already answered.
$answered = PersonTalk::conversation($me->id, PersonTalk::KIND_CHARACTER, (int) $citizen->id)
->where('from_character_id', $citizen->id)
->where('id', '>', $talk->id)
->exists();
if ($answered) {
cache()->forget($pendingKey);
return;
}
if (! filled(config('ai.openrouter.api_key'))) {
self::storeReply($replySides, __('city.chat_error'), true);
cache()->forget($pendingKey);
return;
}
// Persona plays the citizen in-world: no registration data
// travels upstream (RULES.md 2.2) — only simulated city state,
// the in-world identity broadcast to every client, and the
// dialogue itself.
$system = 'Você é um morador da cidade virtual City10, conversando na rua com alguém que chegou perto de você.'
."\n\n".self::identityBlock($citizen)
."\n\nContexto simulado da cidade: ".json_encode(self::simulatedCityContext(), JSON_UNESCAPED_UNICODE)
."\nAs mensagens abaixo são o histórico real da conversa, inclusive de dias anteriores:"
.' mantenha o fio da meada e referencie o que já foi dito quando couber.'
."\nSe te chamarem pelo nome na conversa, responda naturalmente — ele é seu; não se negue a responder."
."\nFrases curtas e naturais, tom de vizinho conversando na rua; nada de linguagem de atendimento, como 'como posso ajudar'."
."\nNunca forneça nem invente e-mail, senha, endereço, telefone ou dados de conta de ninguém."
."\nResponda em português.";
$model = self::resolveModel($citizen->ai_model, $citizen->name, (int) $citizen->id);
$reply = self::askModel($model, $system, $me->id, $talk);
if ($reply === null) {
self::storeReply($replySides, __('city.chat_error'), true);
cache()->forget($pendingKey);
return;
}
self::storeReply($replySides, $reply);
cache()->forget($pendingKey);
} catch (\Throwable $e) {
Log::error('Citizen talk think failed', ['talk' => $talkId, 'error' => $e->getMessage()]);
if ($pendingKey !== null) {
cache()->forget($pendingKey);
}
}
}
/**
* Validate and resolve the peer query parameters (kind + id).
*
* @return array{0: string, 1: AiAgent|Character}
*/
private function resolvePeer(Request $request): array
{
$data = $request->validate([
'kind' => ['required', 'in:agent,character'],
'peer' => ['required', 'integer', 'min:1'],
]);
$peer = $data['kind'] === PersonTalk::KIND_AGENT
? AiAgent::query()->findOrFail($data['peer'])
: Character::query()->findOrFail($data['peer']);
return [$data['kind'], $peer];
}
/**
* Whether a live human session currently drives this character.
*
* Based on the sessions table: a peer with a session touched within
* LIVE_PEER_SECONDS answers for itself and is never impersonated.
* Everything else (seeded citizens, NPC children, demo accounts) is
* answered by the generic persona.
*/
private static function characterPeerIsLive(Character $peer): bool
{
if ($peer->user_id === null) {
return false;
}
try {
$last = Db::table('sessions')
->where('user_id', $peer->user_id)
->max('last_activity');
} catch (\Throwable) {
return false;
}
return $last !== null
&& (int) $last >= now()->getTimestamp() - self::LIVE_PEER_SECONDS;
}
/**
* Cache key of a pending auto-reply for a character peer.
*/
private static function pendingKey(int $characterId): string
{
return 'talk.pending.c.'.$characterId;
}
/**
* Space out reasoning calls per peer (RULES.md 2.3 in spirit: never
* repeat API calls at high frequency for the same counterpart).
*/
private static function acquireThinkLock(string $scope): bool
{
$lockKey = 'talk.think.'.$scope;
$deadline = microtime(true) + (self::THINK_SPACING_SECONDS * 2);
while (! cache()->add($lockKey, 1, self::THINK_SPACING_SECONDS) && microtime(true) < $deadline) {
usleep(400000);
}
return true;
}
/**
* Simulated city snapshot sent upstream — no PII (RULES.md 2.2).
*
* @return array<string, mixed>
*/
private static function simulatedCityContext(): array
{
return [
'stores' => Store::query()
->get(['name', 'type', 'is_open'])
->map(fn ($s): array => ['name' => $s->name, 'type' => $s->type, 'open' => $s->is_open])
->all(),
'clock' => now()->toTimeString(),
];
}
/**
* Resolve the OpenRouter model for a character or agent.
*
* Priority: entity column (characters.ai_model / agents.model) →
* environment map (AI_CHARACTER_MODELS by id then by name) →
* deterministic pool slot (AI_MODELS, stable per id) → AI_DEFAULT_MODEL
* → first allow-listed model. Everything is validated against
* config('ai.allowed_models') (RULES.md 2.1).
*/
public static function resolveModel(?string $columnModel, string $name, int $id): string
{
$allowed = config('ai.allowed_models', []);
$map = config('ai.character_models', []);
$candidates = [
$columnModel,
$map[(string) $id] ?? null,
$map[strtolower($name)] ?? null,
];
foreach ($candidates as $candidate) {
if (is_string($candidate) && in_array($candidate, $allowed, true)) {
return $candidate;
}
}
$pool = array_values(array_filter(
(array) config('ai.models', []),
static fn ($model): bool => is_string($model) && in_array($model, $allowed, true)
));
if ($pool !== []) {
return $pool[$id % count($pool)];
}
$default = config('ai.default_model');
if (is_string($default) && in_array($default, $allowed, true)) {
return $default;
}
return (string) ($allowed[0] ?? $default);
}
/**
* Identity/personality block injected into the persona prompt.
*
* Only in-world data goes upstream: the display name already shown
* above heads and broadcast by the presence API, plus the profile
* fields below. Registration data (e-mail, password, account data)
* is never included (RULES.md 2.2).
*/
private static function identityBlock(Character $character): string
{
$lines = [
'Identidade do seu personagem — aja sempre de acordo com ela:',
'- Nome: '.$character->name,
'- Idade: '.($character->ai_age ?? 'não informada'),
'- Sexo: '.($character->ai_gender ?? 'não informado'),
];
if (filled($character->ai_bio)) {
$lines[] = '- Bio: '.$character->ai_bio;
}
if (filled($character->ai_personality)) {
$lines[] = '- Personalidade: '.$character->ai_personality;
}
return implode("\n", $lines);
}
/**
* Call OpenRouter with the given system prompt and the full daily
* conversation of the pair. Returns null on any failure.
*/
private static function askModel(string $model, string $system, int $meCharacterId, PersonTalk $talk): ?string
{
$peerKind = $talk->to_agent_id !== null ? PersonTalk::KIND_AGENT : PersonTalk::KIND_CHARACTER;
$peerId = (int) ($talk->to_agent_id ?? $talk->to_character_id);
$history = PersonTalk::conversation($meCharacterId, $peerKind, $peerId)
// Error placeholders are UI notes, never part of the dialogue.
// Legacy rows (stored before the flag existed) are matched by
// their localized body so old apologies stay excluded too.
->where('is_fallback', false)
->whereNotIn('body', self::fallbackBodies())
->orderBy('id')
->limit(80)
->get();
$messages = [['role' => 'system', 'content' => $system]];
foreach ($history as $row) {
$role = (int) $row->from_character_id === $meCharacterId ? 'user' : 'assistant';
$lastIdx = count($messages) - 1;
if ($lastIdx > 0 && $messages[$lastIdx]['role'] === $role) {
$messages[$lastIdx]['content'] .= "\n".$row->body;
} else {
$messages[] = ['role' => $role, 'content' => $row->body];
}
}
// Cap the upstream wait below PHP's default execution limit so
// the after-response job is never killed mid-flight.
$timeout = min((int) config('ai.openrouter.timeout', 30), 20);
// Preferred model first, then the configured fallback chain
// (AI_FALLBACK_MODEL) before giving up to the caller.
$models = [$model];
$fallback = config('ai.fallback_model');
if (is_string($fallback) && $fallback !== ''
&& $fallback !== $model
&& in_array($fallback, config('ai.allowed_models', []), true)) {
$models[] = $fallback;
}
foreach ($models as $candidate) {
try {
$response = Http::withHeaders([
'Authorization' => 'Bearer '.config('ai.openrouter.api_key'),
'HTTP-Referer' => config('ai.openrouter.app_url'),
'X-Title' => config('ai.openrouter.app_name'),
])->timeout($timeout)
->post(config('ai.openrouter.base_url').'/chat/completions', [
'model' => $candidate,
'messages' => $messages,
// Room for models that reason before answering — too low
// a budget starves the visible content (e.g. "Ah").
'max_tokens' => 700,
]);
} catch (\Throwable $e) {
Log::warning('Person talk upstream exception', ['model' => $candidate, 'error' => $e->getMessage()]);
continue;
}
if ($response->failed()) {
Log::warning('Person talk upstream failed', ['model' => $candidate, 'status' => $response->status()]);
continue;
}
$reply = $response->json('choices.0.message.content');
// Some models return content as an array of text parts.
if (is_array($reply)) {
$reply = implode('', array_map(
static fn ($part): string => is_array($part) ? (string) ($part['text'] ?? '') : (string) $part,
$reply
));
}
if (is_string($reply) && mb_strlen(trim($reply)) < 10) {
Log::warning('Person talk suspiciously short reply', [
'model' => $candidate,
'finish_reason' => $response->json('choices.0.finish_reason'),
'raw' => substr((string) json_encode($response->json('choices.0.message'), JSON_UNESCAPED_UNICODE), 0, 800),
]);
}
if (is_string($reply) && trim($reply) !== '') {
return $reply;
}
}
return null;
}
/**
* Localized error-fallback bodies (current and past locales).
*
* @return list<string>
*/
private static function fallbackBodies(): array
{
return array_values(array_unique(array_filter([
__('city.chat_error'),
__('city.chat_error', [], 'en'),
__('city.chat_error', [], 'es'),
])));
}
/**
* Persist one side of the conversation.
*
* @param array<string, int|null> $sides from/to identifiers
* @param bool $isFallback stored error placeholder (kept out of model context)
*/
private static function storeReply(array $sides, string $body, bool $isFallback = false): PersonTalk
{
return PersonTalk::create($sides + [
'body' => $body,
'talked_on' => now()->toDateString(),
'is_fallback' => $isFallback,
]);
}
/**
* Count a failed attempt against the agent (RULES.md 2.4).
*/
private static function recordAgentFailure(AiAgent $agent): void
{
$errors = (int) $agent->error_count + 1;
$agent->update([
'status' => $errors >= 5 ? 'error' : 'active',
'error_count' => $errors,
]);
}
}