Compare commits
4
Commits
main
...
59963de995
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59963de995 | ||
|
|
50a6d40e8e | ||
|
|
4f497844bc | ||
|
|
bd6801994a |
Binary file not shown.
|
After Width: | Height: | Size: 239 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 186 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 258 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 239 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 252 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -88,7 +88,7 @@
|
|||||||
"city.error": "Failed to load city data.",
|
"city.error": "Failed to load city data.",
|
||||||
"city.stores_count": "stores",
|
"city.stores_count": "stores",
|
||||||
"city.agents_count": "agents",
|
"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.refresh": "Reload",
|
||||||
"city.selected": "Selected",
|
"city.selected": "Selected",
|
||||||
"city.open": "Open",
|
"city.open": "Open",
|
||||||
@@ -102,6 +102,7 @@
|
|||||||
"city.chat_responding": "responding…",
|
"city.chat_responding": "responding…",
|
||||||
"city.chat_accepted": "Message sent to agent.",
|
"city.chat_accepted": "Message sent to agent.",
|
||||||
"city.chat_error": "Sorry, I can't reply right now. Please try again.",
|
"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_send": "Send",
|
||||||
"city.chat_with": "Chatting with",
|
"city.chat_with": "Chatting with",
|
||||||
"city.clock": "Time",
|
"city.clock": "Time",
|
||||||
|
|||||||
+2
-1
@@ -88,7 +88,7 @@
|
|||||||
"city.error": "Error al cargar los datos de la ciudad.",
|
"city.error": "Error al cargar los datos de la ciudad.",
|
||||||
"city.stores_count": "tiendas",
|
"city.stores_count": "tiendas",
|
||||||
"city.agents_count": "agentes",
|
"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.refresh": "Recargar",
|
||||||
"city.selected": "Seleccionada",
|
"city.selected": "Seleccionada",
|
||||||
"city.open": "Abierta",
|
"city.open": "Abierta",
|
||||||
@@ -102,6 +102,7 @@
|
|||||||
"city.chat_responding": "respondiendo…",
|
"city.chat_responding": "respondiendo…",
|
||||||
"city.chat_accepted": "Mensaje enviado al agente.",
|
"city.chat_accepted": "Mensaje enviado al agente.",
|
||||||
"city.chat_error": "Lo siento, no puedo responder ahora. Inténtalo de nuevo.",
|
"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_send": "Enviar",
|
||||||
"city.chat_with": "Chateando con",
|
"city.chat_with": "Chateando con",
|
||||||
"city.clock": "Hora",
|
"city.clock": "Hora",
|
||||||
|
|||||||
+2
-1
@@ -88,7 +88,7 @@
|
|||||||
"city.error": "Falha ao carregar os dados da cidade.",
|
"city.error": "Falha ao carregar os dados da cidade.",
|
||||||
"city.stores_count": "lojas",
|
"city.stores_count": "lojas",
|
||||||
"city.agents_count": "agentes",
|
"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.refresh": "Recarregar",
|
||||||
"city.selected": "Selecionada",
|
"city.selected": "Selecionada",
|
||||||
"city.open": "Aberta",
|
"city.open": "Aberta",
|
||||||
@@ -102,6 +102,7 @@
|
|||||||
"city.chat_responding": "respondendo…",
|
"city.chat_responding": "respondendo…",
|
||||||
"city.chat_accepted": "Mensagem enviada ao agente.",
|
"city.chat_accepted": "Mensagem enviada ao agente.",
|
||||||
"city.chat_error": "Desculpe, não consegui responder agora. Tente novamente.",
|
"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_send": "Enviar",
|
||||||
"city.chat_with": "Conversando com",
|
"city.chat_with": "Conversando com",
|
||||||
"city.clock": "Horário",
|
"city.clock": "Horário",
|
||||||
|
|||||||
+379
-83
@@ -556,13 +556,15 @@ function initTrafficLights(g) {
|
|||||||
group.add(pole);
|
group.add(pole);
|
||||||
|
|
||||||
// Mast arm over the vertical road (northbound lane), facing
|
// Mast arm over the vertical road (northbound lane), facing
|
||||||
// south so approaching traffic sees the signal.
|
// south so approaching traffic sees the signal. Northbound
|
||||||
|
// drives on the east half (right-hand traffic); the SE-corner
|
||||||
|
// pole sits on that same (right) side.
|
||||||
const armNS = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.1, 2.2), poleMat);
|
const armNS = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.1, 2.2), poleMat);
|
||||||
armNS.position.set(x + sw, 4.0, z + sw - 1.1);
|
armNS.position.set(x + sw, 4.0, z + sw - 1.1);
|
||||||
group.add(armNS);
|
group.add(armNS);
|
||||||
const headNS = makeSignalHead();
|
const headNS = makeSignalHead();
|
||||||
headNS.position.set(x + sw, 3.7, z + sw - 2.2);
|
headNS.position.set(x + sw, 3.7, z + sw - 2.2);
|
||||||
headNS.rotation.y = Math.PI; // face -Z (south)
|
headNS.rotation.y = 0; // face +Z (south — toward northbound traffic)
|
||||||
group.add(headNS);
|
group.add(headNS);
|
||||||
heads.push({ head: headNS, axis: 'z' });
|
heads.push({ head: headNS, axis: 'z' });
|
||||||
|
|
||||||
@@ -578,23 +580,30 @@ function initTrafficLights(g) {
|
|||||||
|
|
||||||
// Pedestrian signals: short posts on the same corner, well
|
// Pedestrian signals: short posts on the same corner, well
|
||||||
// inside the sidewalk, facing the crosswalks they control.
|
// inside the sidewalk, facing the crosswalks they control.
|
||||||
const pedZ = makePedSignal();
|
//
|
||||||
const pedZPole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.06, 2.4, 6), pedPoleMat);
|
// The east crosswalk crosses the horizontal (x) road, so its
|
||||||
pedZPole.position.set(x + sw + 0.6, 1.2, z + sw - 0.6);
|
// signal follows the x-axis vehicle state. It sits on the east
|
||||||
group.add(pedZPole);
|
// sidewalk facing north, for walkers arriving from the north.
|
||||||
pedZ.position.set(x + sw + 0.6, 2.5, z + sw - 0.6);
|
const pedEast = makePedSignal();
|
||||||
pedZ.rotation.y = Math.PI; // faces south crosswalk
|
const pedEastPole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.06, 2.4, 6), pedPoleMat);
|
||||||
group.add(pedZ);
|
pedEastPole.position.set(x + sw + 0.6, 1.2, z + sw - 0.6);
|
||||||
pedHeads.push({ head: pedZ, axis: 'z' });
|
group.add(pedEastPole);
|
||||||
|
pedEast.position.set(x + sw + 0.6, 2.5, z + sw - 0.6);
|
||||||
|
pedEast.rotation.y = Math.PI; // faces north (approach from the north)
|
||||||
|
group.add(pedEast);
|
||||||
|
pedHeads.push({ head: pedEast, axis: 'x' });
|
||||||
|
|
||||||
const pedX = makePedSignal();
|
// The south crosswalk crosses the vertical (z) road, so its
|
||||||
const pedXPole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.06, 2.4, 6), pedPoleMat);
|
// signal follows the z-axis vehicle state. It sits on the south
|
||||||
pedXPole.position.set(x + sw - 0.6, 1.2, z + sw + 0.6);
|
// sidewalk facing west, for walkers arriving from the east.
|
||||||
group.add(pedXPole);
|
const pedSouth = makePedSignal();
|
||||||
pedX.position.set(x + sw - 0.6, 2.5, z + sw + 0.6);
|
const pedSouthPole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.06, 2.4, 6), pedPoleMat);
|
||||||
pedX.rotation.y = -Math.PI / 2; // faces west crosswalk
|
pedSouthPole.position.set(x + sw - 0.6, 1.2, z + sw + 0.6);
|
||||||
group.add(pedX);
|
group.add(pedSouthPole);
|
||||||
pedHeads.push({ head: pedX, axis: 'x' });
|
pedSouth.position.set(x + sw - 0.6, 2.5, z + sw + 0.6);
|
||||||
|
pedSouth.rotation.y = -Math.PI / 2; // faces west (approach from the east)
|
||||||
|
group.add(pedSouth);
|
||||||
|
pedHeads.push({ head: pedSouth, axis: 'z' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3089,27 +3098,84 @@ const player = {
|
|||||||
};
|
};
|
||||||
const keys = {};
|
const keys = {};
|
||||||
let locked = false;
|
let locked = false;
|
||||||
|
// Gate dismissed without pointer lock (touch devices, or when the browser
|
||||||
|
// denies pointer lock). The camera is then steered with drag-to-look.
|
||||||
|
let touchMode = false;
|
||||||
|
let lookDrag = null;
|
||||||
|
|
||||||
|
function applyLook(dx, dy, sens) {
|
||||||
|
player.yaw -= dx * sens;
|
||||||
|
player.pitch -= dy * sens;
|
||||||
|
player.pitch = Math.max(-Math.PI / 2 + 0.1, Math.min(Math.PI / 2 - 0.1, player.pitch));
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissGate() {
|
||||||
|
touchMode = true;
|
||||||
|
startGate.style.display = 'none';
|
||||||
|
posPanel.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryRequestLock() {
|
||||||
|
try {
|
||||||
|
const p = renderer.domElement.requestPointerLock?.();
|
||||||
|
if (p && typeof p.catch === 'function') p.catch(() => dismissGate());
|
||||||
|
} catch (_) {
|
||||||
|
dismissGate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
renderer.domElement.addEventListener('click', () => {
|
renderer.domElement.addEventListener('click', () => {
|
||||||
if (!locked) renderer.domElement.requestPointerLock();
|
if (locked || touchMode) return;
|
||||||
|
tryRequestLock();
|
||||||
});
|
});
|
||||||
startGate.addEventListener('click', () => {
|
startGate.addEventListener('click', () => {
|
||||||
renderer.domElement.requestPointerLock();
|
// Touch devices have no pointer lock: start immediately and steer the
|
||||||
|
// camera by dragging on the scene (the virtual joystick drives walking).
|
||||||
|
if (window.matchMedia?.('(pointer: coarse)')?.matches) {
|
||||||
|
dismissGate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tryRequestLock();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('pointerlockchange', () => {
|
document.addEventListener('pointerlockchange', () => {
|
||||||
locked = document.pointerLockElement === renderer.domElement;
|
locked = document.pointerLockElement === renderer.domElement;
|
||||||
startGate.style.display = locked ? 'none' : 'grid';
|
startGate.style.display = (locked || touchMode) ? 'none' : 'grid';
|
||||||
posPanel.style.display = locked ? 'block' : 'none';
|
posPanel.style.display = (locked || touchMode) ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('pointerlockerror', () => {
|
||||||
|
// Lock denied (embedded context, automation, permissions): fall back to
|
||||||
|
// drag-to-look so the player is never stuck behind the gate.
|
||||||
|
dismissGate();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('mousemove', (e) => {
|
document.addEventListener('mousemove', (e) => {
|
||||||
if (!locked) return;
|
if (!locked) return;
|
||||||
player.yaw -= e.movementX * 0.0022;
|
applyLook(e.movementX, e.movementY, 0.0022);
|
||||||
player.pitch -= e.movementY * 0.0022;
|
|
||||||
player.pitch = Math.max(-Math.PI / 2 + 0.1, Math.min(Math.PI / 2 - 0.1, player.pitch));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Drag-to-look fallback when pointer lock is not in effect: drag anywhere
|
||||||
|
// on the scene to turn (standard mobile first-person controls).
|
||||||
|
renderer.domElement.addEventListener('pointerdown', (e) => {
|
||||||
|
if (locked) return;
|
||||||
|
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||||
|
lookDrag = { id: e.pointerId, x: e.clientX, y: e.clientY };
|
||||||
|
try { renderer.domElement.setPointerCapture(e.pointerId); } catch (_) { /* ignore */ }
|
||||||
|
});
|
||||||
|
renderer.domElement.addEventListener('pointermove', (e) => {
|
||||||
|
if (locked || !lookDrag || lookDrag.id !== e.pointerId) return;
|
||||||
|
applyLook(e.clientX - lookDrag.x, e.clientY - lookDrag.y, 0.005);
|
||||||
|
lookDrag.x = e.clientX;
|
||||||
|
lookDrag.y = e.clientY;
|
||||||
|
});
|
||||||
|
const endLookDrag = (e) => {
|
||||||
|
if (lookDrag && lookDrag.id === e.pointerId) lookDrag = null;
|
||||||
|
};
|
||||||
|
renderer.domElement.addEventListener('pointerup', endLookDrag);
|
||||||
|
renderer.domElement.addEventListener('pointercancel', endLookDrag);
|
||||||
|
renderer.domElement.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||||
|
|
||||||
function isTypingTarget(el) {
|
function isTypingTarget(el) {
|
||||||
if (!el) return false;
|
if (!el) return false;
|
||||||
const tag = (el.tagName || '').toLowerCase();
|
const tag = (el.tagName || '').toLowerCase();
|
||||||
@@ -3118,6 +3184,13 @@ function isTypingTarget(el) {
|
|||||||
|
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (isTypingTarget(e.target)) return;
|
if (isTypingTarget(e.target)) return;
|
||||||
|
// Keep arrows/space from scrolling the page while the scene controls
|
||||||
|
// are active (arrows turn the camera, space jumps).
|
||||||
|
if ((locked || touchMode)
|
||||||
|
&& (e.code === 'ArrowLeft' || e.code === 'ArrowRight'
|
||||||
|
|| e.code === 'ArrowUp' || e.code === 'ArrowDown' || e.code === 'Space')) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
keys[e.code] = true;
|
keys[e.code] = true;
|
||||||
});
|
});
|
||||||
document.addEventListener('keyup', (e) => {
|
document.addEventListener('keyup', (e) => {
|
||||||
@@ -3351,16 +3424,23 @@ function updatePlayer(dt) {
|
|||||||
const right = new THREE.Vector3(Math.cos(player.yaw), 0, -Math.sin(player.yaw));
|
const right = new THREE.Vector3(Math.cos(player.yaw), 0, -Math.sin(player.yaw));
|
||||||
const wish = new THREE.Vector3();
|
const wish = new THREE.Vector3();
|
||||||
|
|
||||||
// Allow walking with WASD/arrows even before pointer lock so the
|
// Allow walking with WASD even before pointer lock so the character is
|
||||||
// character is never completely stuck on the overlay screen.
|
// never completely stuck on the overlay screen. A/D strafe; the arrow
|
||||||
|
// keys turn the first-person camera instead of strafing, so every
|
||||||
|
// forward step always follows the direction the camera is facing.
|
||||||
if (keys['KeyW'] || keys['ArrowUp']) wish.add(forward);
|
if (keys['KeyW'] || keys['ArrowUp']) wish.add(forward);
|
||||||
if (keys['KeyS'] || keys['ArrowDown']) wish.sub(forward);
|
if (keys['KeyS'] || keys['ArrowDown']) wish.sub(forward);
|
||||||
if (keys['KeyA'] || keys['ArrowLeft']) wish.sub(right);
|
if (keys['KeyA']) wish.sub(right);
|
||||||
if (keys['KeyD'] || keys['ArrowRight']) wish.add(right);
|
if (keys['KeyD']) wish.add(right);
|
||||||
|
const turn = (keys['ArrowLeft'] ? 1 : 0) - (keys['ArrowRight'] ? 1 : 0);
|
||||||
|
if (turn) player.yaw += turn * 2.4 * dt;
|
||||||
if (mobileInput.active) {
|
if (mobileInput.active) {
|
||||||
// Joystick: +y on screen is forward for the player.
|
// Joystick: vertical walks along the camera facing, horizontal
|
||||||
|
// turns the camera (mobile first-person convention).
|
||||||
wish.addScaledVector(forward, -mobileInput.y);
|
wish.addScaledVector(forward, -mobileInput.y);
|
||||||
wish.addScaledVector(right, mobileInput.x);
|
if (Math.abs(mobileInput.x) > 0.15) {
|
||||||
|
player.yaw -= mobileInput.x * 2.4 * dt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wish.lengthSq() > 0) {
|
if (wish.lengthSq() > 0) {
|
||||||
@@ -3444,7 +3524,13 @@ renderer.domElement.addEventListener('pointerup', (e) => {
|
|||||||
let obj = agentHits[0].object;
|
let obj = agentHits[0].object;
|
||||||
while (obj && !obj.userData?.agent) obj = obj.parent;
|
while (obj && !obj.userData?.agent) obj = obj.parent;
|
||||||
if (obj?.userData?.agent) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3461,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 chatPanel = document.getElementById('chat-panel');
|
||||||
const chatDot = document.getElementById('chat-dot');
|
const chatDot = document.getElementById('chat-dot');
|
||||||
@@ -3473,8 +3564,24 @@ const chatMessages = document.getElementById('chat-messages');
|
|||||||
const chatForm = document.getElementById('chat-form');
|
const chatForm = document.getElementById('chat-form');
|
||||||
const chatInput = document.getElementById('chat-input');
|
const chatInput = document.getElementById('chat-input');
|
||||||
|
|
||||||
let currentAgent = null;
|
const TALK_OPEN_RADIUS = 2.6;
|
||||||
let eventSource = null;
|
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) {
|
function addBubble(text, who) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
@@ -3484,9 +3591,18 @@ function addBubble(text, who) {
|
|||||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
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) {
|
function setStatus(status) {
|
||||||
if (!currentAgent) return;
|
if (!currentPeer) return;
|
||||||
currentAgent.status = status;
|
currentPeer.status = status;
|
||||||
const map = {
|
const map = {
|
||||||
thinking: ['#f59e0b', i18n.chatThinking || 'thinking…'],
|
thinking: ['#f59e0b', i18n.chatThinking || 'thinking…'],
|
||||||
responding: ['#f59e0b', i18n.chatResponding || 'responding…'],
|
responding: ['#f59e0b', i18n.chatResponding || 'responding…'],
|
||||||
@@ -3496,69 +3612,175 @@ function setStatus(status) {
|
|||||||
const [color, label] = map[status] || map.active;
|
const [color, label] = map[status] || map.active;
|
||||||
chatDot.style.background = color;
|
chatDot.style.background = color;
|
||||||
chatStatus.textContent = label;
|
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);
|
if (mesh) refreshLabel(mesh, status);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openChat(agent) {
|
/** Poll the daily conversation and append anything not seen yet. */
|
||||||
currentAgent = agent;
|
async function pullTalk() {
|
||||||
chatPanel.style.display = 'flex';
|
if (!currentPeer) return;
|
||||||
chatName.textContent = agent.name;
|
const peer = currentPeer;
|
||||||
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) => {
|
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(e.data);
|
const res = await fetch(`/talk/person?kind=${encodeURIComponent(peer.kind)}&peer=${encodeURIComponent(peer.id)}`, {
|
||||||
if (payload.type === 'thinking') {
|
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
setStatus('thinking');
|
cache: 'no-store',
|
||||||
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 */ }
|
|
||||||
});
|
});
|
||||||
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';
|
chatPanel.style.display = 'none';
|
||||||
currentAgent = null;
|
if (manual && currentPeer) dismissedPeerKey = currentPeer.key;
|
||||||
if (eventSource) { eventSource.close(); eventSource = null; }
|
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) => {
|
chatForm.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const msg = chatInput.value.trim();
|
const msg = chatInput.value.trim();
|
||||||
if (!msg || !currentAgent) return;
|
if (!msg || !currentPeer) return;
|
||||||
chatInput.value = '';
|
chatInput.value = '';
|
||||||
addBubble(msg, 'user');
|
if (currentPeer.kind === 'agent') setStatus('thinking');
|
||||||
setStatus('thinking');
|
talkErrorShown = false;
|
||||||
try {
|
try {
|
||||||
await fetch(`/city/chat/${currentAgent.id}`, {
|
const res = await fetch('/talk/person', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'Accept': 'application/json' },
|
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 (_) {
|
} catch (_) {
|
||||||
addBubble(i18n.chatError || 'error', 'meta');
|
addBubble(i18n.chatError || 'error', 'meta');
|
||||||
|
if (currentPeer.kind === 'agent') setStatus(currentPeer.status || 'active');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3902,11 +4124,13 @@ function animate() {
|
|||||||
updateHUD();
|
updateHUD();
|
||||||
updateVehicleHeadlights();
|
updateVehicleHeadlights();
|
||||||
syncPositionThrottled();
|
syncPositionThrottled();
|
||||||
|
updateTalkProximity(dt);
|
||||||
drawMinimap();
|
drawMinimap();
|
||||||
|
|
||||||
// Footsteps while the local player is actually walking.
|
// Footsteps while the local player is actually walking (not just turning).
|
||||||
const walking = locked && (keys['KeyW'] || keys['KeyS'] || keys['KeyA'] || keys['KeyD']
|
const walking = (locked || touchMode) && (keys['KeyW'] || keys['KeyS'] || keys['KeyA'] || keys['KeyD']
|
||||||
|| keys['ArrowUp'] || keys['ArrowDown'] || keys['ArrowLeft'] || keys['ArrowRight']);
|
|| keys['ArrowUp'] || keys['ArrowDown']
|
||||||
|
|| (mobileInput.active && Math.abs(mobileInput.y) > 0.15));
|
||||||
cityAudio.footsteps(dt, walking && !isHospitalizedNow());
|
cityAudio.footsteps(dt, walking && !isHospitalizedNow());
|
||||||
|
|
||||||
statsTimer += dt;
|
statsTimer += dt;
|
||||||
@@ -3932,6 +4156,8 @@ function upsertRemoteCitizen(c) {
|
|||||||
status: c.status || 'active',
|
status: c.status || 'active',
|
||||||
id: c.id,
|
id: c.id,
|
||||||
}, true);
|
}, 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;
|
const [cx, , cz] = c.position;
|
||||||
mesh.position.set(cx, 0, cz);
|
mesh.position.set(cx, 0, cz);
|
||||||
worldGroup.add(mesh);
|
worldGroup.add(mesh);
|
||||||
@@ -4155,7 +4381,7 @@ function bindMobileJoystick() {
|
|||||||
|
|
||||||
base.addEventListener('pointerdown', (e) => {
|
base.addEventListener('pointerdown', (e) => {
|
||||||
activeId = e.pointerId;
|
activeId = e.pointerId;
|
||||||
base.setPointerCapture(e.pointerId);
|
try { base.setPointerCapture(e.pointerId); } catch (_) { /* synthetic or released pointer */ }
|
||||||
const rect = base.getBoundingClientRect();
|
const rect = base.getBoundingClientRect();
|
||||||
setKnob(e.clientX - (rect.left + rect.width / 2), e.clientY - (rect.top + rect.height / 2));
|
setKnob(e.clientX - (rect.left + rect.width / 2), e.clientY - (rect.top + rect.height / 2));
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -4329,7 +4555,77 @@ window.__city10 = {
|
|||||||
if (trafficSystem) trafficSystem.setPhase(name);
|
if (trafficSystem) trafficSystem.setPhase(name);
|
||||||
return this.trafficPhase;
|
return this.trafficPhase;
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Snapshot of every signal head (position, facing, axis and the lamp
|
||||||
|
* currently lit) so automated tests can assert placement and states.
|
||||||
|
*/
|
||||||
|
trafficLightHeads() {
|
||||||
|
if (!trafficSystem) return { vehicle: [], pedestrian: [] };
|
||||||
|
const vehicle = trafficSystem.heads.map(({ head, axis }) => {
|
||||||
|
const intensities = head.userData.lamps.map(
|
||||||
|
l => +l.material.emissiveIntensity.toFixed(2)
|
||||||
|
);
|
||||||
|
const active = ['red', 'yellow', 'green'][intensities.indexOf(Math.max(...intensities))];
|
||||||
|
return {
|
||||||
|
axis,
|
||||||
|
x: +head.position.x.toFixed(2),
|
||||||
|
y: +head.position.y.toFixed(2),
|
||||||
|
z: +head.position.z.toFixed(2),
|
||||||
|
rotationY: +head.rotation.y.toFixed(3),
|
||||||
|
active,
|
||||||
|
intensities,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const pedestrian = trafficSystem.pedHeads.map(({ head, axis }) => {
|
||||||
|
const red = +head.userData.lamps.red.material.emissiveIntensity.toFixed(2);
|
||||||
|
const green = +head.userData.lamps.green.material.emissiveIntensity.toFixed(2);
|
||||||
|
return {
|
||||||
|
axis,
|
||||||
|
x: +head.position.x.toFixed(2),
|
||||||
|
y: +head.position.y.toFixed(2),
|
||||||
|
z: +head.position.z.toFixed(2),
|
||||||
|
rotationY: +head.rotation.y.toFixed(3),
|
||||||
|
active: red > green ? 'red' : 'green',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { vehicle, pedestrian };
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Per-vehicle stop-line probe: travel axis, distance to the next stop
|
||||||
|
* line and current speed, for red-light compliance checks.
|
||||||
|
*/
|
||||||
|
trafficProbe() {
|
||||||
|
if (!vehicleSystem) return [];
|
||||||
|
const out = [];
|
||||||
|
for (const v of vehicleSystem.vehicles) {
|
||||||
|
if (!v.pts) continue;
|
||||||
|
const probe = probeAhead(v.pts, v.s, v.L);
|
||||||
|
const cur = new THREE.Vector3();
|
||||||
|
posOnPath(v.pts, v.s, v.L, cur);
|
||||||
|
out.push({
|
||||||
|
x: +cur.x.toFixed(2),
|
||||||
|
z: +cur.z.toFixed(2),
|
||||||
|
speed: +(v.speed || 0).toFixed(2),
|
||||||
|
axis: probe.axis,
|
||||||
|
distToStop: +distanceToStopLine(probe, cur).toFixed(2),
|
||||||
|
state: trafficSystem ? trafficSystem.axisState(probe.axis) : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
playerCrossingAllowed,
|
playerCrossingAllowed,
|
||||||
|
/** Local player pose for movement/look tests. */
|
||||||
|
playerPose() {
|
||||||
|
return {
|
||||||
|
x: +player.pos.x.toFixed(3),
|
||||||
|
y: +player.pos.y.toFixed(3),
|
||||||
|
z: +player.pos.z.toFixed(3),
|
||||||
|
yaw: +player.yaw.toFixed(4),
|
||||||
|
pitch: +player.pitch.toFixed(4),
|
||||||
|
locked,
|
||||||
|
touchMode,
|
||||||
|
};
|
||||||
|
},
|
||||||
get barriersDown() { return trainSystem ? trainSystem.blocks : null; },
|
get barriersDown() { return trainSystem ? trainSystem.blocks : null; },
|
||||||
setHour(h) {
|
setHour(h) {
|
||||||
sim.hour = ((h % 24) + 24) % 24;
|
sim.hour = ((h % 24) + 24) % 24;
|
||||||
|
|||||||
@@ -379,7 +379,9 @@ class CityAudioEngine {
|
|||||||
this._tone(466.16, 0.25, { type: 'sawtooth', peak: 0.16 });
|
this._tone(466.16, 0.25, { type: 'sawtooth', peak: 0.16 });
|
||||||
break;
|
break;
|
||||||
case 'footstep':
|
case 'footstep':
|
||||||
this._tone(90 + Math.random() * 40, 0.06, { type: 'triangle', peak: 0.12, glideTo: 60 });
|
// Louder than the other one-shots: footsteps are the main
|
||||||
|
// feedback while walking and sit low on the spectrum.
|
||||||
|
this._tone(90 + Math.random() * 40, 0.07, { type: 'triangle', peak: 0.3, glideTo: 60 });
|
||||||
break;
|
break;
|
||||||
case 'thunder':
|
case 'thunder':
|
||||||
this.thunder();
|
this.thunder();
|
||||||
|
|||||||
@@ -237,14 +237,15 @@
|
|||||||
padding:.35rem .6rem; font-size:.75rem; color:var(--muted);">{{ __('city.hint') }}</span>
|
padding:.35rem .6rem; font-size:.75rem; color:var(--muted);">{{ __('city.hint') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Chat panel (bottom-right, like example images) --}}
|
{{-- Chat panel (bottom-right, transparent — opens on bump) --}}
|
||||||
<div id="chat-panel"
|
<div id="chat-panel"
|
||||||
style="position:absolute; right:.6rem; bottom:3.2rem; z-index:15; width:280px; display:none;
|
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;
|
background:rgba(15,23,42,.62); backdrop-filter:blur(9px); -webkit-backdrop-filter:blur(9px);
|
||||||
overflow:hidden; box-shadow:0 12px 30px rgba(0,0,0,.5); flex-direction:column;">
|
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"
|
<div id="chat-header"
|
||||||
style="display:flex; align-items:center; justify-content:space-between; gap:.5rem;
|
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;">
|
<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>
|
<span id="chat-dot" style="width:8px; height:8px; border-radius:50%; background:#22c55e; flex-shrink:0;"></span>
|
||||||
<div style="min-width:0;">
|
<div style="min-width:0;">
|
||||||
@@ -323,6 +324,7 @@
|
|||||||
chatWith: @json(__('city.chat_with')),
|
chatWith: @json(__('city.chat_with')),
|
||||||
chatSend: @json(__('city.chat_send')),
|
chatSend: @json(__('city.chat_send')),
|
||||||
chatError: @json(__('city.chat_error')),
|
chatError: @json(__('city.chat_error')),
|
||||||
|
talkRateLimited: @json(__('city.talk_ratelimited')),
|
||||||
clock: @json(__('city.clock')),
|
clock: @json(__('city.clock')),
|
||||||
weather: @json(__('city.weather')),
|
weather: @json(__('city.weather')),
|
||||||
weather_clear: @json(__('city.weather_clear')),
|
weather_clear: @json(__('city.weather_clear')),
|
||||||
@@ -372,5 +374,5 @@
|
|||||||
citySlug: @json($city->slug),
|
citySlug: @json($city->slug),
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<script type="module" src="{{ asset('js/city/app.js') }}"></script>
|
<script type="module" src="{{ asset('js/city/app.js') }}?v={{ filemtime(public_path('js/city/app.js')) }}"></script>
|
||||||
@endpush
|
@endpush
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use App\Http\Controllers\Web\CityViewerController;
|
|||||||
use App\Http\Controllers\Web\DashboardController;
|
use App\Http\Controllers\Web\DashboardController;
|
||||||
use App\Http\Controllers\Web\LanguageController;
|
use App\Http\Controllers\Web\LanguageController;
|
||||||
use App\Http\Controllers\Web\LegalController;
|
use App\Http\Controllers\Web\LegalController;
|
||||||
|
use App\Http\Controllers\Web\PersonTalkController;
|
||||||
use App\Http\Middleware\EnsureTermsAccepted;
|
use App\Http\Middleware\EnsureTermsAccepted;
|
||||||
use App\Http\Middleware\EnsureUserIsAdmin;
|
use App\Http\Middleware\EnsureUserIsAdmin;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
@@ -120,6 +121,14 @@ Route::middleware('auth')->group(function (): void {
|
|||||||
->middleware('throttle:30,1')
|
->middleware('throttle:30,1')
|
||||||
->name('city.chat.send');
|
->name('city.chat.send');
|
||||||
Route::get('/city/chat/{agent}/stream', [ChatController::class, 'stream'])->name('city.chat.stream');
|
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user