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.
525 lines
18 KiB
PHP
525 lines
18 KiB
PHP
<?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);
|
|
}
|
|
}
|