Compare commits

...
4 Commits
Author SHA1 Message Date
marcocosta10000 59963de995 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.
2026-10-31 17:59:22 -03:00
marcocosta10000 50a6d40e8e fix(audio): increase footstep volume and duration
Raise peak amplitude from 0.12 to 0.3 and extend duration from 0.06 to 0.07 seconds for better walking feedback
2026-10-31 16:56:43 -03:00
marcocosta10000 4f497844bc feat(city): add touch mode and drag-to-look camera controls
- Add touchMode flag and lookDrag state for non-pointer-lock input
- Implement dismissGate() and tryRequestLock() for graceful fallback
- Add applyLook() helper for camera rotation with pitch clamping
- Support drag-to-look via pointer events on renderer.domElement
- Prevent context menu and page scroll during active camera control
2026-10-31 16:47:56 -03:00
marcocosta10000 bd6801994a fix(city): correct traffic signal orientation for right-hand traffic
- Fix vehicle signal head rotation to face approaching traffic correctly
- Refactor pedestrian signal positioning with descriptive naming
- Add getSignalHeads() method for snapshotting signal state
- Add cache-busting query parameter to app.js script tag
2026-10-31 16:25:04 -03:00
18 changed files with 1665 additions and 94 deletions
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,
]);
}
}
+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",
+379 -83
View File
@@ -556,13 +556,15 @@ function initTrafficLights(g) {
group.add(pole);
// 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);
armNS.position.set(x + sw, 4.0, z + sw - 1.1);
group.add(armNS);
const headNS = makeSignalHead();
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);
heads.push({ head: headNS, axis: 'z' });
@@ -578,23 +580,30 @@ function initTrafficLights(g) {
// Pedestrian signals: short posts on the same corner, well
// 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);
pedZPole.position.set(x + sw + 0.6, 1.2, z + sw - 0.6);
group.add(pedZPole);
pedZ.position.set(x + sw + 0.6, 2.5, z + sw - 0.6);
pedZ.rotation.y = Math.PI; // faces south crosswalk
group.add(pedZ);
pedHeads.push({ head: pedZ, axis: 'z' });
//
// The east crosswalk crosses the horizontal (x) road, so its
// signal follows the x-axis vehicle state. It sits on the east
// sidewalk facing north, for walkers arriving from the north.
const pedEast = makePedSignal();
const pedEastPole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.06, 2.4, 6), pedPoleMat);
pedEastPole.position.set(x + sw + 0.6, 1.2, z + sw - 0.6);
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();
const pedXPole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.06, 2.4, 6), pedPoleMat);
pedXPole.position.set(x + sw - 0.6, 1.2, z + sw + 0.6);
group.add(pedXPole);
pedX.position.set(x + sw - 0.6, 2.5, z + sw + 0.6);
pedX.rotation.y = -Math.PI / 2; // faces west crosswalk
group.add(pedX);
pedHeads.push({ head: pedX, axis: 'x' });
// The south crosswalk crosses the vertical (z) road, so its
// signal follows the z-axis vehicle state. It sits on the south
// sidewalk facing west, for walkers arriving from the east.
const pedSouth = makePedSignal();
const pedSouthPole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.06, 2.4, 6), pedPoleMat);
pedSouthPole.position.set(x + sw - 0.6, 1.2, z + sw + 0.6);
group.add(pedSouthPole);
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 = {};
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', () => {
if (!locked) renderer.domElement.requestPointerLock();
if (locked || touchMode) return;
tryRequestLock();
});
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', () => {
locked = document.pointerLockElement === renderer.domElement;
startGate.style.display = locked ? 'none' : 'grid';
posPanel.style.display = locked ? 'block' : 'none';
startGate.style.display = (locked || touchMode) ? 'none' : 'grid';
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) => {
if (!locked) return;
player.yaw -= e.movementX * 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));
applyLook(e.movementX, e.movementY, 0.0022);
});
// 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) {
if (!el) return false;
const tag = (el.tagName || '').toLowerCase();
@@ -3118,6 +3184,13 @@ function isTypingTarget(el) {
document.addEventListener('keydown', (e) => {
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;
});
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 wish = new THREE.Vector3();
// Allow walking with WASD/arrows even before pointer lock so the
// character is never completely stuck on the overlay screen.
// Allow walking with WASD even before pointer lock so the character is
// 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['KeyS'] || keys['ArrowDown']) wish.sub(forward);
if (keys['KeyA'] || keys['ArrowLeft']) wish.sub(right);
if (keys['KeyD'] || keys['ArrowRight']) wish.add(right);
if (keys['KeyA']) wish.sub(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) {
// 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(right, mobileInput.x);
if (Math.abs(mobileInput.x) > 0.15) {
player.yaw -= mobileInput.x * 2.4 * dt;
}
}
if (wish.lengthSq() > 0) {
@@ -3444,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;
}
}
@@ -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 chatDot = document.getElementById('chat-dot');
@@ -3473,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');
@@ -3484,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…'],
@@ -3496,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');
}
});
@@ -3902,11 +4124,13 @@ function animate() {
updateHUD();
updateVehicleHeadlights();
syncPositionThrottled();
updateTalkProximity(dt);
drawMinimap();
// Footsteps while the local player is actually walking.
const walking = locked && (keys['KeyW'] || keys['KeyS'] || keys['KeyA'] || keys['KeyD']
|| keys['ArrowUp'] || keys['ArrowDown'] || keys['ArrowLeft'] || keys['ArrowRight']);
// Footsteps while the local player is actually walking (not just turning).
const walking = (locked || touchMode) && (keys['KeyW'] || keys['KeyS'] || keys['KeyA'] || keys['KeyD']
|| keys['ArrowUp'] || keys['ArrowDown']
|| (mobileInput.active && Math.abs(mobileInput.y) > 0.15));
cityAudio.footsteps(dt, walking && !isHospitalizedNow());
statsTimer += dt;
@@ -3932,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);
@@ -4155,7 +4381,7 @@ function bindMobileJoystick() {
base.addEventListener('pointerdown', (e) => {
activeId = e.pointerId;
base.setPointerCapture(e.pointerId);
try { base.setPointerCapture(e.pointerId); } catch (_) { /* synthetic or released pointer */ }
const rect = base.getBoundingClientRect();
setKnob(e.clientX - (rect.left + rect.width / 2), e.clientY - (rect.top + rect.height / 2));
e.preventDefault();
@@ -4329,7 +4555,77 @@ window.__city10 = {
if (trafficSystem) trafficSystem.setPhase(name);
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,
/** 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; },
setHour(h) {
sim.hour = ((h % 24) + 24) % 24;
+3 -1
View File
@@ -379,7 +379,9 @@ class CityAudioEngine {
this._tone(466.16, 0.25, { type: 'sawtooth', peak: 0.16 });
break;
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;
case 'thunder':
this.thunder();
+7 -5
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')),
@@ -372,5 +374,5 @@
citySlug: @json($city->slug),
};
</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
+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);
}
}