feat(city): add bump-to-talk conversations with persistent context

Implements proximity-based street conversations between player characters and peers (human or agentic NPC). New PersonTalk model, controller, and migrations store daily message threads that persist across sessions and provide context for future interactions. Agent replies include backoff throttling and fallback persona handling to avoid dead-end chats.

Frontend changes refactor the chat panel to support both character and agent peers, opening automatically when the player moves within range, polling for replies, rendering day separators, and displaying rate-limit warnings. Styling updated to a transparent blurred panel. Translations extended with new hint text and rate-limit messages across en, es, and pt_BR.
This commit is contained in:
2026-10-31 17:59:22 -03:00
parent 50a6d40e8e
commit 59963de995
13 changed files with 1475 additions and 57 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

@@ -0,0 +1,535 @@
<?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.";
$reply = self::askModel($agent, $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
// and the dialogue itself, which already carries whatever the
// peers chose to type.
$system = 'Você é um morador da cidade virtual City10, conversando na rua com alguém que chegou perto de você.'
."\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 por um nome na conversa, aquele é o seu nome — aceite-o e não se diga outra pessoa nem se recuse 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.";
$reply = self::askModel(null, $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(),
];
}
/**
* Call OpenRouter with the given system prompt and the full daily
* conversation of the pair. Returns null on any failure.
*/
private static function askModel(?AiAgent $agent, 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];
}
}
$model = ($agent === null ? null : $agent->model);
if (! in_array($model, config('ai.allowed_models', []), true)) {
$model = config('ai.default_model');
}
// 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);
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' => $model,
'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', ['error' => $e->getMessage()]);
return null;
}
if ($response->failed()) {
Log::warning('Person talk upstream failed', ['status' => $response->status()]);
return null;
}
$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', [
'finish_reason' => $response->json('choices.0.finish_reason'),
'raw' => substr((string) json_encode($response->json('choices.0.message'), JSON_UNESCAPED_UNICODE), 0, 800),
]);
}
return is_string($reply) && trim($reply) !== '' ? $reply : 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,
]);
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* One entry of a daily street conversation.
*
* Exactly one "from" side (character or agent) and one "to" side are set
* per row. The full ordered set of rows between a pair is the persistent
* context used to resume the conversation on later days.
*/
#[Fillable([
'from_character_id', 'from_agent_id',
'to_character_id', 'to_agent_id',
'body', 'talked_on', 'is_fallback',
])]
class PersonTalk extends Model
{
/**
* Peer kind addressing an agentic NPC.
*
* @var string
*/
public const KIND_AGENT = 'agent';
/**
* Peer kind addressing another user's character.
*
* @var string
*/
public const KIND_CHARACTER = 'character';
/**
* Attributes that should be cast to native types.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'talked_on' => 'date',
'is_fallback' => 'boolean',
];
}
/**
* Character that sent the message.
*
* @return BelongsTo<Character, $this>
*/
public function fromCharacter(): BelongsTo
{
return $this->belongsTo(Character::class, 'from_character_id');
}
/**
* Agent that sent the message.
*
* @return BelongsTo<AiAgent, $this>
*/
public function fromAgent(): BelongsTo
{
return $this->belongsTo(AiAgent::class, 'from_agent_id');
}
/**
* Character that received the message.
*
* @return BelongsTo<Character, $this>
*/
public function toCharacter(): BelongsTo
{
return $this->belongsTo(Character::class, 'to_character_id');
}
/**
* Agent that received the message.
*
* @return BelongsTo<AiAgent, $this>
*/
public function toAgent(): BelongsTo
{
return $this->belongsTo(AiAgent::class, 'to_agent_id');
}
/**
* Ordered conversation rows between a character and a peer.
*
* @param int $characterId the local character
* @param string $peerKind PersonTalk::KIND_AGENT or PersonTalk::KIND_CHARACTER
* @param int $peerId agent id or character id of the peer
* @return Builder<self>
*/
public static function conversation(int $characterId, string $peerKind, int $peerId): Builder
{
return self::query()->where(function (Builder $outer) use ($characterId, $peerKind, $peerId): void {
if ($peerKind === self::KIND_AGENT) {
$outer->where(function (Builder $q) use ($characterId, $peerId): void {
$q->where('from_character_id', $characterId)->where('to_agent_id', $peerId);
})->orWhere(function (Builder $q) use ($characterId, $peerId): void {
$q->where('from_agent_id', $peerId)->where('to_character_id', $characterId);
});
return;
}
$outer->where(function (Builder $q) use ($characterId, $peerId): void {
$q->where('from_character_id', $characterId)->where('to_character_id', $peerId);
})->orWhere(function (Builder $q) use ($characterId, $peerId): void {
$q->where('from_character_id', $peerId)->where('to_character_id', $characterId);
});
});
}
}
@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Creates the "person_talks" table.
*
* Daily-registered street conversations between a player character and
* either another character (human peer) or an agentic NPC (AiAgent).
* Rows are the persistent context used to keep the thread of previous
* days when the pair talks again.
*/
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('person_talks', function (Blueprint $table): void {
$table->id();
$table->foreignId('from_character_id')->nullable()->constrained('characters')->cascadeOnDelete();
$table->foreignId('from_agent_id')->nullable()->constrained('ai_agents')->cascadeOnDelete();
$table->foreignId('to_character_id')->nullable()->constrained('characters')->cascadeOnDelete();
$table->foreignId('to_agent_id')->nullable()->constrained('ai_agents')->cascadeOnDelete();
$table->string('body', 1000);
$table->date('talked_on');
$table->timestamps();
$table->index(['talked_on']);
$table->index(['from_character_id', 'to_character_id']);
$table->index(['from_character_id', 'to_agent_id']);
$table->index(['from_agent_id', 'to_character_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('person_talks');
}
};
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Adds the "is_fallback" flag to "person_talks".
*
* Error-fallback rows keep the chat from hanging, but they must never be
* fed back to the model as part of the conversation (an apology would
* pollute the persona's context and skew future replies).
*/
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('person_talks', function (Blueprint $table): void {
$table->boolean('is_fallback')->default(false);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('person_talks', function (Blueprint $table): void {
$table->dropColumn('is_fallback');
});
}
};
+2 -1
View File
@@ -88,7 +88,7 @@
"city.error": "Failed to load city data.",
"city.stores_count": "stores",
"city.agents_count": "agents",
"city.hint": "Click the scene to control · WASD to walk · Mouse to look · Esc to exit",
"city.hint": "Click the scene to control · WASD to walk · Mouse to look · Esc to exit · Get close to someone to talk",
"city.refresh": "Reload",
"city.selected": "Selected",
"city.open": "Open",
@@ -102,6 +102,7 @@
"city.chat_responding": "responding…",
"city.chat_accepted": "Message sent to agent.",
"city.chat_error": "Sorry, I can't reply right now. Please try again.",
"city.talk_ratelimited": "Too many messages — wait a moment and try again.",
"city.chat_send": "Send",
"city.chat_with": "Chatting with",
"city.clock": "Time",
+2 -1
View File
@@ -88,7 +88,7 @@
"city.error": "Error al cargar los datos de la ciudad.",
"city.stores_count": "tiendas",
"city.agents_count": "agentes",
"city.hint": "Haz clic en la escena para controlar · WASD para caminar · Ratón para mirar · Esc para salir",
"city.hint": "Haz clic en la escena para controlar · WASD para caminar · Ratón para mirar · Esc para salir · Acércate a alguien para hablar",
"city.refresh": "Recargar",
"city.selected": "Seleccionada",
"city.open": "Abierta",
@@ -102,6 +102,7 @@
"city.chat_responding": "respondiendo…",
"city.chat_accepted": "Mensaje enviado al agente.",
"city.chat_error": "Lo siento, no puedo responder ahora. Inténtalo de nuevo.",
"city.talk_ratelimited": "Muchos mensajes — espera un instante e inténtalo de nuevo.",
"city.chat_send": "Enviar",
"city.chat_with": "Chateando con",
"city.clock": "Hora",
+2 -1
View File
@@ -88,7 +88,7 @@
"city.error": "Falha ao carregar os dados da cidade.",
"city.stores_count": "lojas",
"city.agents_count": "agentes",
"city.hint": "Clique na tela para controlar · WASD para andar · Mouse para olhar · Esc para sair",
"city.hint": "Clique na tela para controlar · WASD para andar · Mouse para olhar · Esc para sair · Chegue perto de alguém para conversar",
"city.refresh": "Recarregar",
"city.selected": "Selecionada",
"city.open": "Aberta",
@@ -102,6 +102,7 @@
"city.chat_responding": "respondendo…",
"city.chat_accepted": "Mensagem enviada ao agente.",
"city.chat_error": "Desculpe, não consegui responder agora. Tente novamente.",
"city.talk_ratelimited": "Muitas mensagens — aguarde um instante e tente de novo.",
"city.chat_send": "Enviar",
"city.chat_with": "Conversando com",
"city.clock": "Horário",
+193 -48
View File
@@ -3524,7 +3524,13 @@ renderer.domElement.addEventListener('pointerup', (e) => {
let obj = agentHits[0].object;
while (obj && !obj.userData?.agent) obj = obj.parent;
if (obj?.userData?.agent) {
openChat(obj.userData.agent);
const agent = obj.userData.agent;
openTalkChat({
kind: 'agent',
id: agent.id,
name: agent.name,
status: agent.status || 'active',
});
return;
}
}
@@ -3541,7 +3547,12 @@ renderer.domElement.addEventListener('pointerup', (e) => {
});
// ------------------------------------------------------------------
// ChatPanel
// ChatPanel — street conversations via /talk/person
//
// Opens automatically when the player bumps into another citizen
// (human peer) or agentic NPC, polls for replies while open, closes
// when the pair moves apart, and renders the daily history that backs
// the persistent context of future conversations.
// ------------------------------------------------------------------
const chatPanel = document.getElementById('chat-panel');
const chatDot = document.getElementById('chat-dot');
@@ -3553,8 +3564,24 @@ const chatMessages = document.getElementById('chat-messages');
const chatForm = document.getElementById('chat-form');
const chatInput = document.getElementById('chat-input');
let currentAgent = null;
let eventSource = null;
const TALK_OPEN_RADIUS = 2.6;
const TALK_CLOSE_RADIUS = 3.6;
const TALK_POLL_MS = 2500;
let currentPeer = null; // { kind, id, name, status, key }
let talkPollTimer = null;
let talkLastDay = null;
let talkErrorShown = false;
let dismissedPeerKey = null;
const talkSeenIds = new Set();
function peerKey(kind, id) {
return `${kind}:${id}`;
}
function clockNow() {
return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function addBubble(text, who) {
const div = document.createElement('div');
@@ -3564,9 +3591,18 @@ function addBubble(text, who) {
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function addDaySeparator(day) {
const div = document.createElement('div');
div.className = 'chat-bubble meta';
const parsed = new Date(`${day}T12:00:00`);
div.textContent = Number.isNaN(parsed.getTime()) ? day : parsed.toLocaleDateString();
chatMessages.appendChild(div);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function setStatus(status) {
if (!currentAgent) return;
currentAgent.status = status;
if (!currentPeer) return;
currentPeer.status = status;
const map = {
thinking: ['#f59e0b', i18n.chatThinking || 'thinking…'],
responding: ['#f59e0b', i18n.chatResponding || 'responding…'],
@@ -3576,69 +3612,175 @@ function setStatus(status) {
const [color, label] = map[status] || map.active;
chatDot.style.background = color;
chatStatus.textContent = label;
const mesh = agentMeshes.find(m => m.userData?.agent?.id === currentAgent.id);
if (currentPeer.kind === 'agent') {
const mesh = agentMeshes.find(m => m.userData?.agent?.id === currentPeer.id);
if (mesh) refreshLabel(mesh, status);
}
}
function openChat(agent) {
currentAgent = agent;
chatPanel.style.display = 'flex';
chatName.textContent = agent.name;
chatTime.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
chatMessages.innerHTML = '';
const history = agent.last_decision || [];
history.forEach(m => {
if (m.role === 'user') addBubble(m.content, 'user');
else if (m.role === 'assistant') addBubble(m.content, 'npc');
});
setStatus(agent.status || 'active');
if (eventSource) { eventSource.close(); eventSource = null; }
eventSource = new EventSource(`/city/chat/${agent.id}/stream`);
eventSource.addEventListener('message', (e) => {
/** Poll the daily conversation and append anything not seen yet. */
async function pullTalk() {
if (!currentPeer) return;
const peer = currentPeer;
try {
const payload = JSON.parse(e.data);
if (payload.type === 'thinking') {
setStatus('thinking');
addBubble(i18n.chatThinking || 'thinking…', 'meta');
} else if (payload.type === 'reply') {
setStatus('active');
addBubble(payload.reply, 'npc');
chatTime.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (payload.type === 'error') {
setStatus('error');
addBubble(payload.reply || i18n.chatError || 'error', 'meta');
}
} catch (_) { /* ignore */ }
const res = await fetch(`/talk/person?kind=${encodeURIComponent(peer.kind)}&peer=${encodeURIComponent(peer.id)}`, {
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
cache: 'no-store',
});
eventSource.onerror = () => { /* auto-retry */ };
if (!res.ok || !currentPeer || currentPeer.key !== peer.key) return;
const data = await res.json();
const messages = data.messages || [];
let arrived = false;
for (const m of messages) {
if (talkSeenIds.has(m.id)) continue;
if (m.talked_on && m.talked_on !== talkLastDay) {
talkLastDay = m.talked_on;
addDaySeparator(m.talked_on);
}
talkSeenIds.add(m.id);
addBubble(m.body, m.fallback ? 'meta' : (m.from === 'me' ? 'user' : 'npc'));
if (m.at) chatTime.textContent = new Date(m.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
arrived = true;
}
if (data.thinking) {
setStatus('thinking');
} else if (peer.kind === 'agent') {
setStatus(data.peer?.status || 'active');
const last = messages[messages.length - 1];
if (data.peer?.status === 'error' && last?.from === 'me' && !talkErrorShown) {
talkErrorShown = true;
addBubble(i18n.chatError || 'error', 'meta');
}
} else if (currentPeer.status === 'thinking') {
setStatus('active');
}
if (arrived) talkErrorShown = false;
} catch (_) { /* offline-safe */ }
}
function closeChat() {
async function openTalkChat(peer) {
if (currentPeer && currentPeer.key === peerKey(peer.kind, peer.id)) return;
currentPeer = { ...peer, key: peerKey(peer.kind, peer.id) };
talkSeenIds.clear();
talkLastDay = null;
talkErrorShown = false;
chatMessages.innerHTML = '';
chatPanel.style.display = 'flex';
chatName.textContent = peer.name;
chatTime.textContent = clockNow();
setStatus(peer.status || 'active');
if (talkPollTimer) clearInterval(talkPollTimer);
talkPollTimer = setInterval(pullTalk, TALK_POLL_MS);
await pullTalk();
}
function closeChat(manual = false) {
chatPanel.style.display = 'none';
currentAgent = null;
if (eventSource) { eventSource.close(); eventSource = null; }
if (manual && currentPeer) dismissedPeerKey = currentPeer.key;
currentPeer = null;
if (talkPollTimer) {
clearInterval(talkPollTimer);
talkPollTimer = null;
}
}
chatClose.addEventListener('click', closeChat);
// ------------------------------------------------------------------
// Bump detection: auto-open near a citizen, auto-close when apart
// ------------------------------------------------------------------
const _talkPos = new THREE.Vector3();
let talkProximityTimer = 0;
function talkCandidates() {
const out = [];
for (const mesh of agentMeshes) {
const agent = mesh.userData?.agent;
if (!agent || typeof agent.id !== 'number') continue;
mesh.getWorldPosition(_talkPos);
out.push({
kind: 'agent',
id: agent.id,
name: agent.name,
status: agent.status || 'active',
d: Math.hypot(_talkPos.x - player.pos.x, _talkPos.z - player.pos.z),
});
}
for (const mesh of remoteCitizens.values()) {
const citizen = mesh.userData?.citizen;
if (!citizen) continue;
out.push({
kind: 'character',
id: citizen.id,
name: citizen.name,
status: 'active',
d: Math.hypot(mesh.position.x - player.pos.x, mesh.position.z - player.pos.z),
});
}
return out;
}
function updateTalkProximity(dt) {
talkProximityTimer -= dt;
if (talkProximityTimer > 0) return;
talkProximityTimer = 0.25;
const candidates = talkCandidates();
// A manually dismissed chat only reopens after a fresh bump.
if (dismissedPeerKey) {
const dis = candidates.find(c => peerKey(c.kind, c.id) === dismissedPeerKey);
if (!dis || dis.d > TALK_CLOSE_RADIUS) dismissedPeerKey = null;
}
if (currentPeer) {
const cur = candidates.find(c => peerKey(c.kind, c.id) === currentPeer.key);
if (!cur || cur.d > TALK_CLOSE_RADIUS) closeChat(false);
return;
}
let best = null;
for (const c of candidates) {
if (!best || c.d < best.d) best = c;
}
if (best && best.d <= TALK_OPEN_RADIUS && peerKey(best.kind, best.id) !== dismissedPeerKey) {
openTalkChat(best);
}
}
chatClose.addEventListener('click', () => closeChat(true));
chatForm.addEventListener('submit', async (e) => {
e.preventDefault();
const msg = chatInput.value.trim();
if (!msg || !currentAgent) return;
if (!msg || !currentPeer) return;
chatInput.value = '';
addBubble(msg, 'user');
setStatus('thinking');
if (currentPeer.kind === 'agent') setStatus('thinking');
talkErrorShown = false;
try {
await fetch(`/city/chat/${currentAgent.id}`, {
const res = await fetch('/talk/person', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'Accept': 'application/json' },
body: JSON.stringify({ message: msg }),
body: JSON.stringify({ kind: currentPeer.kind, peer: currentPeer.id, message: msg }),
});
if (res.ok) {
const data = await res.json();
// Day separator first so live and polled rows read in order.
if (data.talked_on && data.talked_on !== talkLastDay) {
talkLastDay = data.talked_on;
addDaySeparator(data.talked_on);
}
addBubble(msg, 'user');
if (data.id) talkSeenIds.add(data.id);
if (currentPeer.kind !== 'agent') chatTime.textContent = clockNow();
} else {
// 429 has its own cause — do not reuse the AI fallback text.
addBubble(res.status === 429
? (i18n.talkRateLimited || 'rate limited')
: (i18n.chatError || 'error'), 'meta');
if (currentPeer.kind === 'agent') setStatus(currentPeer.status || 'active');
}
} catch (_) {
addBubble(i18n.chatError || 'error', 'meta');
if (currentPeer.kind === 'agent') setStatus(currentPeer.status || 'active');
}
});
@@ -3982,6 +4124,7 @@ function animate() {
updateHUD();
updateVehicleHeadlights();
syncPositionThrottled();
updateTalkProximity(dt);
drawMinimap();
// Footsteps while the local player is actually walking (not just turning).
@@ -4013,6 +4156,8 @@ function upsertRemoteCitizen(c) {
status: c.status || 'active',
id: c.id,
}, true);
// Human peer marker used by the bump-to-talk detection.
mesh.userData.citizen = { kind: 'character', id: c.id, name: c.name };
const [cx, , cz] = c.position;
mesh.position.set(cx, 0, cz);
worldGroup.add(mesh);
+6 -4
View File
@@ -237,14 +237,15 @@
padding:.35rem .6rem; font-size:.75rem; color:var(--muted);">{{ __('city.hint') }}</span>
</div>
{{-- Chat panel (bottom-right, like example images) --}}
{{-- Chat panel (bottom-right, transparent — opens on bump) --}}
<div id="chat-panel"
style="position:absolute; right:.6rem; bottom:3.2rem; z-index:15; width:280px; display:none;
background:rgba(15,23,42,.96); border:1px solid var(--border); border-radius:.6rem;
overflow:hidden; box-shadow:0 12px 30px rgba(0,0,0,.5); flex-direction:column;">
background:rgba(15,23,42,.62); backdrop-filter:blur(9px); -webkit-backdrop-filter:blur(9px);
border:1px solid rgba(148,163,184,.3); border-radius:.6rem;
overflow:hidden; box-shadow:0 12px 30px rgba(0,0,0,.45); flex-direction:column;">
<div id="chat-header"
style="display:flex; align-items:center; justify-content:space-between; gap:.5rem;
padding:.55rem .7rem; background:rgba(30,41,59,.9); border-bottom:1px solid var(--border);">
padding:.55rem .7rem; background:rgba(30,41,59,.55); border-bottom:1px solid rgba(148,163,184,.25);">
<div style="display:flex; align-items:center; gap:.5rem; min-width:0;">
<span id="chat-dot" style="width:8px; height:8px; border-radius:50%; background:#22c55e; flex-shrink:0;"></span>
<div style="min-width:0;">
@@ -323,6 +324,7 @@
chatWith: @json(__('city.chat_with')),
chatSend: @json(__('city.chat_send')),
chatError: @json(__('city.chat_error')),
talkRateLimited: @json(__('city.talk_ratelimited')),
clock: @json(__('city.clock')),
weather: @json(__('city.weather')),
weather_clear: @json(__('city.weather_clear')),
+9
View File
@@ -17,6 +17,7 @@ use App\Http\Controllers\Web\CityViewerController;
use App\Http\Controllers\Web\DashboardController;
use App\Http\Controllers\Web\LanguageController;
use App\Http\Controllers\Web\LegalController;
use App\Http\Controllers\Web\PersonTalkController;
use App\Http\Middleware\EnsureTermsAccepted;
use App\Http\Middleware\EnsureUserIsAdmin;
use Illuminate\Support\Facades\Route;
@@ -120,6 +121,14 @@ Route::middleware('auth')->group(function (): void {
->middleware('throttle:30,1')
->name('city.chat.send');
Route::get('/city/chat/{agent}/stream', [ChatController::class, 'stream'])->name('city.chat.stream');
// Daily street conversations (bump-to-talk) with persistent context.
Route::get('/talk/person', [PersonTalkController::class, 'index'])
->middleware('throttle:120,1')
->name('talk.person.index');
Route::post('/talk/person', [PersonTalkController::class, 'send'])
->middleware('throttle:30,1')
->name('talk.person.send');
});
});
+524
View File
@@ -0,0 +1,524 @@
<?php
namespace Tests\Feature;
use App\Models\AiAgent;
use App\Models\Character;
use App\Models\PersonTalk;
use App\Models\User;
use Database\Seeders\CityWorldSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Db;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Tests\TestCase;
/**
* Daily street conversations (bump-to-talk) coverage: registration per
* day, peer validation, agent replies with multi-day context and PII
* kept out of upstream payloads.
*/
class PersonTalkTest extends TestCase
{
use RefreshDatabase;
/**
* Seed world fixtures.
*/
protected function setUp(): void
{
parent::setUp();
$this->seed(CityWorldSeeder::class);
}
/**
* Create an active user paired with its in-world character.
*/
private function makeCitizen(): array
{
$user = User::factory()->create([
'status' => User::STATUS_ACTIVE,
'is_active' => true,
]);
return [$user, Character::ensureForUser($user)];
}
/**
* Simulate a human actively using the account (fresh session row).
*/
private function makePeerLive(User $user): void
{
Db::table('sessions')->insert([
'id' => Str::random(40),
'user_id' => $user->id,
'ip_address' => '127.0.0.1',
'user_agent' => 'phpunit',
'payload' => '',
'last_activity' => now()->getTimestamp(),
]);
}
/**
* Character-to-character talks are stored per day and visible from
* both sides of the conversation.
*/
public function test_character_talk_is_registered_per_day_for_both_sides(): void
{
[$userA, $me] = $this->makeCitizen();
[, $them] = $this->makeCitizen();
$this->actingAs($userA)
->postJson('/talk/person', [
'kind' => 'character',
'peer' => $them->id,
'message' => 'Oi, tudo bem?',
])
->assertCreated()
->assertJsonPath('from', 'me')
->assertJsonPath('talked_on', now()->toDateString());
$this->assertDatabaseHas('person_talks', [
'from_character_id' => $me->id,
'to_character_id' => $them->id,
'to_agent_id' => null,
'body' => 'Oi, tudo bem?',
]);
$stored = PersonTalk::query()->where('body', 'Oi, tudo bem?')->firstOrFail();
$this->assertSame(now()->toDateString(), $stored->talked_on->toDateString());
}
/**
* The peer reads the same row as an incoming message and can answer
* back through the same endpoint.
*/
public function test_conversation_is_shared_between_both_characters(): void
{
[$userA, $me] = $this->makeCitizen();
[$userB, $them] = $this->makeCitizen();
// Both sides driven by live humans → pure relay, no auto-replies.
$this->makePeerLive($userA);
$this->makePeerLive($userB);
$this->actingAs($userA)
->postJson('/talk/person', [
'kind' => 'character',
'peer' => $them->id,
'message' => 'Oi, tudo bem?',
])
->assertCreated();
$this->actingAs($userB)
->postJson('/talk/person', [
'kind' => 'character',
'peer' => $me->id,
'message' => 'Tudo ótimo!',
])
->assertCreated();
$this->actingAs($userB)
->getJson('/talk/person?kind=character&peer='.$me->id)
->assertOk()
->assertJsonPath('peer.kind', 'character')
->assertJsonPath('messages.0.from', 'them')
->assertJsonPath('messages.0.body', 'Oi, tudo bem?')
->assertJsonPath('messages.1.from', 'me')
->assertJsonPath('messages.1.body', 'Tudo ótimo!');
}
/**
* Peer and payload validation on the send endpoint.
*/
public function test_talk_validates_peer_and_message(): void
{
[$userA, $me] = $this->makeCitizen();
$this->actingAs($userA)
->postJson('/talk/person', [
'kind' => 'character',
'peer' => $me->id,
'message' => 'oi',
])
->assertStatus(422);
$this->actingAs($userA)
->postJson('/talk/person', [
'kind' => 'alien',
'peer' => 1,
'message' => 'oi',
])
->assertStatus(422);
$this->actingAs($userA)
->postJson('/talk/person', [
'kind' => 'character',
'peer' => 999999,
'message' => 'oi',
])
->assertNotFound();
$this->actingAs($userA)
->postJson('/talk/person', [
'kind' => 'character',
'peer' => 999999,
])
->assertStatus(422);
}
/**
* Agent talks store the message, answer after the response and send
* the multi-day history upstream while keeping PII out (RULES.md 2.2).
*/
public function test_agent_talk_replies_using_daily_history_as_context(): void
{
config(['ai.openrouter.api_key' => 'test-key']);
Http::fake([
'openrouter.ai/*' => Http::response([
'choices' => [['message' => ['content' => 'Praça renovada, como combinamos ontem!']]],
]),
]);
[$userA, $me] = $this->makeCitizen();
$agent = AiAgent::create(['name' => 'Prefeito', 'type' => 'mayor']);
// Yesterday's exchange is part of the persistent context.
PersonTalk::create([
'from_character_id' => $me->id,
'to_agent_id' => $agent->id,
'body' => 'Ontem conversamos sobre a praça',
'talked_on' => now()->subDay()->toDateString(),
]);
$this->actingAs($userA)
->postJson('/talk/person', [
'kind' => 'agent',
'peer' => $agent->id,
'message' => 'E a praça, fica pronta hoje?',
])
->assertCreated();
// The after-response job ran during the request lifecycle.
$this->assertDatabaseHas('person_talks', [
'from_agent_id' => $agent->id,
'to_character_id' => $me->id,
'body' => 'Praça renovada, como combinamos ontem!',
]);
$reply = PersonTalk::query()->where('body', 'Praça renovada, como combinamos ontem!')->firstOrFail();
$this->assertSame(now()->toDateString(), $reply->talked_on->toDateString());
$agent->refresh();
$this->assertSame('active', $agent->status);
$this->assertSame(0, (int) $agent->error_count);
$this->assertNotNull($agent->last_think_at);
$this->assertNotEmpty($agent->last_decision);
$userEmail = $userA->email;
$userName = $userA->name;
Http::assertSent(function ($request) use ($userEmail, $userName): bool {
$payload = $request->data();
$encoded = json_encode($payload, JSON_UNESCAPED_UNICODE);
// Yesterday's line and today's line reach the model (thread kept).
$hasHistory = str_contains($encoded, 'Ontem conversamos sobre a praça')
&& str_contains($encoded, 'E a praça, fica pronta hoje?');
// No registration data upstream (RULES.md 2.2).
$leaksPii = str_contains($encoded, $userEmail)
|| str_contains($encoded, $userName);
return $hasHistory && ! $leaksPii;
});
$this->actingAs($userA)
->getJson('/talk/person?kind=agent&peer='.$agent->id)
->assertOk()
->assertJsonPath('peer.kind', 'agent')
->assertJsonPath('peer.status', 'active')
->assertJsonPath('thinking', false)
->assertJsonPath('messages.0.body', 'Ontem conversamos sobre a praça')
->assertJsonPath('messages.0.from', 'me')
->assertJsonPath('messages.1.body', 'E a praça, fica pronta hoje?')
->assertJsonPath('messages.2.body', 'Praça renovada, como combinamos ontem!')
->assertJsonPath('messages.2.from', 'them')
->assertJsonPath('messages.2.talked_on', now()->toDateString());
}
/**
* Without an upstream key the conversation still completes: the
* agent stores the localized fallback and counts the failure for
* backoff (RULES.md 2.4).
*/
public function test_agent_talk_without_api_key_stores_fallback_reply(): void
{
config(['ai.openrouter.api_key' => null]);
Http::preventStrayRequests();
[$userA, $me] = $this->makeCitizen();
$agent = AiAgent::create(['name' => 'Lojista', 'type' => 'shopkeeper']);
$this->actingAs($userA)
->postJson('/talk/person', [
'kind' => 'agent',
'peer' => $agent->id,
'message' => 'Bom dia!',
])
->assertCreated();
$this->assertDatabaseHas('person_talks', [
'from_agent_id' => $agent->id,
'to_character_id' => $me->id,
'body' => __('city.chat_error'),
'is_fallback' => true,
]);
$fallback = PersonTalk::query()->where('body', __('city.chat_error'))->firstOrFail();
$this->assertSame(now()->toDateString(), $fallback->talked_on->toDateString());
$this->assertTrue((bool) $fallback->is_fallback);
$agent->refresh();
$this->assertSame(1, (int) $agent->error_count);
$this->actingAs($userA)
->getJson('/talk/person?kind=agent&peer='.$agent->id)
->assertOk()
->assertJsonPath('thinking', false);
}
/**
* While the agent reply is still pending the UI is told to keep
* showing the thinking state.
*/
public function test_agent_talk_reports_thinking_until_reply_lands(): void
{
[$userA, $me] = $this->makeCitizen();
$agent = AiAgent::create(['name' => 'Guia', 'type' => 'pedestrian']);
// Pending row without dispatching a reply (simulate in-flight).
PersonTalk::create([
'from_character_id' => $me->id,
'to_agent_id' => $agent->id,
'body' => 'Oi!',
'talked_on' => now()->toDateString(),
]);
$this->actingAs($userA)
->getJson('/talk/person?kind=agent&peer='.$agent->id)
->assertOk()
->assertJsonPath('thinking', true);
}
/**
* History spans multiple days so later conversations keep the thread.
*/
public function test_history_returns_previous_days_in_order(): void
{
[$userA, $me] = $this->makeCitizen();
[, $them] = $this->makeCitizen();
foreach ([
[now()->subDays(2), 'Primeira conversa'],
[now()->subDay(), 'Segunda conversa'],
[now(), 'Terceira conversa'],
] as [$day, $body]) {
PersonTalk::create([
'from_character_id' => $me->id,
'to_character_id' => $them->id,
'body' => $body,
'talked_on' => $day->toDateString(),
]);
}
$response = $this->actingAs($userA)
->getJson('/talk/person?kind=character&peer='.$them->id)
->assertOk();
$messages = $response->json('messages');
$this->assertCount(3, $messages);
$this->assertSame('Primeira conversa', $messages[0]['body']);
$this->assertSame(now()->subDays(2)->toDateString(), $messages[0]['talked_on']);
$this->assertSame('Terceira conversa', $messages[2]['body']);
$this->assertSame(now()->toDateString(), $messages[2]['talked_on']);
}
/**
* A character peer without a live human session (seeded citizen,
* demo account like "Maria") answers through the AI persona, using
* the daily history as context and keeping PII out of the payload.
*/
public function test_silent_citizen_peer_answers_with_ai_reply(): void
{
config(['ai.openrouter.api_key' => 'test-key']);
Http::fake([
'openrouter.ai/*' => Http::response([
'choices' => [['message' => ['content' => 'Oi! Tudo tranquilo na cidade hoje.']]],
]),
]);
[$userA, $me] = $this->makeCitizen();
[$userB, $them] = $this->makeCitizen(); // no session → silent peer
PersonTalk::create([
'from_character_id' => $me->id,
'to_character_id' => $them->id,
'body' => 'Ontem te vi na praça',
'talked_on' => now()->subDay()->toDateString(),
]);
$this->actingAs($userA)
->postJson('/talk/person', [
'kind' => 'character',
'peer' => $them->id,
'message' => 'Oi, tudo bem por aí?',
])
->assertCreated();
// The silent peer replied during the after-response phase.
$reply = PersonTalk::query()
->where('from_character_id', $them->id)
->where('to_character_id', $me->id)
->first();
$this->assertNotNull($reply);
$this->assertSame('Oi! Tudo tranquilo na cidade hoje.', $reply->body);
$this->assertSame(now()->toDateString(), $reply->talked_on->toDateString());
$peerEmail = $userB->email;
Http::assertSent(function ($request) use ($peerEmail): bool {
$encoded = json_encode($request->data(), JSON_UNESCAPED_UNICODE);
// Thread kept… and no registration data upstream (RULES.md 2.2).
return str_contains($encoded, 'Ontem te vi na praça')
&& str_contains($encoded, 'Oi, tudo bem por aí?')
&& ! str_contains($encoded, $peerEmail);
});
$this->actingAs($userA)
->getJson('/talk/person?kind=character&peer='.$them->id)
->assertOk()
->assertJsonPath('thinking', false);
}
/**
* A peer driven by a live human session is relayed as-is and never
* answered by the AI on their behalf.
*/
public function test_live_human_peer_is_not_impersonated(): void
{
config(['ai.openrouter.api_key' => 'test-key']);
Http::preventStrayRequests();
[$userA, $me] = $this->makeCitizen();
[$userB, $them] = $this->makeCitizen();
$this->makePeerLive($userB);
$this->actingAs($userA)
->postJson('/talk/person', [
'kind' => 'character',
'peer' => $them->id,
'message' => 'Você está aí?',
])
->assertCreated();
$this->assertSame(0, PersonTalk::query()
->where('from_character_id', $them->id)
->where('to_character_id', $me->id)
->count());
$this->actingAs($userA)
->getJson('/talk/person?kind=character&peer='.$them->id)
->assertOk()
->assertJsonPath('thinking', false);
}
/**
* Without an API key the silent peer still answers with the stored
* fallback so the chat never hangs waiting.
*/
public function test_silent_citizen_falls_back_without_api_key(): void
{
config(['ai.openrouter.api_key' => null]);
Http::preventStrayRequests();
[$userA, $me] = $this->makeCitizen();
[, $them] = $this->makeCitizen();
$this->actingAs($userA)
->postJson('/talk/person', [
'kind' => 'character',
'peer' => $them->id,
'message' => 'Olá?',
])
->assertCreated();
$this->assertDatabaseHas('person_talks', [
'from_character_id' => $them->id,
'to_character_id' => $me->id,
'body' => __('city.chat_error'),
'is_fallback' => true,
]);
$this->actingAs($userA)
->getJson('/talk/person?kind=character&peer='.$them->id)
->assertOk()
->assertJsonPath('thinking', false);
}
/**
* Error-fallback rows stay visible in the chat but are never sent
* back to the model as dialogue — an apology in the context would
* skew the persona into a support-bot tone or identity confusion.
*/
public function test_fallback_rows_are_excluded_from_model_context(): void
{
config(['ai.openrouter.api_key' => 'test-key']);
Http::fake([
'openrouter.ai/*' => Http::response([
'choices' => [['message' => ['content' => 'Praça fechada para obra, já passa.']]],
]),
]);
[$userA, $me] = $this->makeCitizen();
[$userB, $them] = $this->makeCitizen();
// Yesterday: a real line plus an error placeholder.
PersonTalk::create([
'from_character_id' => $me->id,
'to_character_id' => $them->id,
'body' => 'E a praça, abre hoje?',
'talked_on' => now()->subDay()->toDateString(),
]);
PersonTalk::create([
'from_character_id' => $them->id,
'to_character_id' => $me->id,
'body' => __('city.chat_error'),
'talked_on' => now()->subDay()->toDateString(),
'is_fallback' => true,
]);
$this->actingAs($userA)
->postJson('/talk/person', [
'kind' => 'character',
'peer' => $them->id,
'message' => 'E aí, e a obra?',
])
->assertCreated();
Http::assertSent(function ($request): bool {
$encoded = json_encode($request->data(), JSON_UNESCAPED_UNICODE);
return str_contains($encoded, 'E a praça, abre hoje?')
&& str_contains($encoded, 'E aí, e a obra?')
&& ! str_contains($encoded, __('city.chat_error'));
});
// The stored reply is a real answer, not another placeholder.
$reply = PersonTalk::query()
->where('from_character_id', $them->id)
->where('is_fallback', false)
->orderByDesc('id')
->first();
$this->assertNotNull($reply);
$this->assertSame('Praça fechada para obra, já passa.', $reply->body);
}
}