Files
City10/tests/Feature/PersonTalkTest.php
marcocosta10000 e7498e1566 feat(city): add airports, ATC agent, luminous signs, and AI model fallback chain
- Add two airports (north/south) with full geometry: runways, taxiways,
  aprons, terminals, hangars, stands, and helipads; walkable airport
  grounds extend beyond city bounds.
- Introduce ATC AI agent ("Torre de Controle City10") using AI_ATC_MODEL
  to generate takeoff clearances; includes dispatch endpoint with
  30-second cooldown and deterministic fallback on failure.
- Implement AI model fallback chain: preferred model → AI_FALLBACK_MODEL
  before failing, applied in ChatController and PersonTalkController.
- Add luminous sign labels (letreiros) for plazas, beach, food, hospital,
  school, city hall, and airports with day/night glow.
- Fix city presence cache: compute is_me per viewer instead of baking
  into shared per-second cache to prevent cross-user flag leakage.
- Add tests: AirportTest, CityPresenceTest, SignLabelsTest, and
  PersonTalkTest fallback model retry scenario.
2026-10-31 22:06:18 -03:00

689 lines
24 KiB
PHP

<?php
namespace Tests\Feature;
use App\Http\Controllers\Web\PersonTalkController;
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);
}
/**
* Model resolution priority: column → env map (id/name) → pool slot,
* always inside the allow-list (RULES.md 2.1) and stable per id.
*/
public function test_model_resolution_priority_and_allow_list(): void
{
$mapped = 'openrouter/free';
$pooled = 'nvidia/nemotron-3.5-lightning:free';
config([
'ai.models' => [$pooled, $mapped],
'ai.character_models' => [
'maria' => $mapped,
'7' => $pooled,
'bad' => 'not/allowed-model',
],
]);
// 1) Character column wins over everything.
$this->assertSame(
$mapped,
PersonTalkController::resolveModel($mapped, 'Qualquer', 99)
);
// 2) Env map by name (case-insensitive) and by id.
$this->assertSame($mapped, PersonTalkController::resolveModel(null, 'Maria', 3));
$this->assertSame($pooled, PersonTalkController::resolveModel(null, 'Outro', 7));
// 3) Non allow-listed entries are ignored → deterministic pool slot.
$fromPool = PersonTalkController::resolveModel('not/allowed-model', 'Bad', 4);
$this->assertContains($fromPool, [$pooled, $mapped]);
$this->assertNotSame('not/allowed-model', $fromPool);
// 4) Empty map/pool falls back to the allow-listed default.
config(['ai.character_models' => [], 'ai.models' => []]);
$default = config('ai.default_model');
$this->assertContains($default, config('ai.allowed_models'));
$this->assertSame($default, PersonTalkController::resolveModel(null, 'X', 1));
// 5) Same character always resolves to the same model.
config(['ai.models' => [$pooled, $mapped]]);
$stable = PersonTalkController::resolveModel(null, 'Estável', 5);
$this->assertSame($stable, PersonTalkController::resolveModel(null, 'Estável', 5));
}
/**
* The identity/personality profile and the resolved model travel in
* the upstream payload while registration data stays out (RULES.md 2.2).
*/
public function test_identity_and_model_flow_into_upstream_payload(): void
{
config([
'ai.openrouter.api_key' => 'test-key',
'ai.models' => ['liquid/lfm-2.5-2.6b:free'],
'ai.character_models' => [],
]);
Http::fake([
'openrouter.ai/*' => Http::response([
'choices' => [['message' => ['content' => 'Oi! Tudo tranquilo na cidade.']]],
]),
]);
[$userA, $me] = $this->makeCitizen();
[$userB, $them] = $this->makeCitizen();
$them->update([
'ai_age' => 34,
'ai_gender' => 'feminino',
'ai_personality' => 'Extrovertido e curiosa, adora puxar conversa.',
'ai_bio' => 'Moradora do centro, vive nas novidades do bairro.',
]);
$this->actingAs($userA)
->postJson('/talk/person', [
'kind' => 'character',
'peer' => $them->id,
'message' => 'Oi, tudo bem?',
])
->assertCreated();
$peerEmail = $userB->email;
$peerName = $them->name;
Http::assertSent(function ($request) use ($peerEmail, $peerName): bool {
$payload = $request->data();
$encoded = json_encode($payload, JSON_UNESCAPED_UNICODE);
$system = (string) ($payload['messages'][0]['content'] ?? '');
return $payload['model'] === 'liquid/lfm-2.5-2.6b:free'
&& str_contains($system, 'Idade: 34')
&& str_contains($system, 'Sexo: feminino')
&& str_contains($system, 'Extrovertido e curiosa')
&& str_contains($system, 'Moradora do centro')
&& str_contains($system, 'Nome: '.$peerName)
&& ! str_contains($encoded, $peerEmail);
});
// The reply from the fake model is stored normally.
$this->assertNotNull(PersonTalk::query()
->where('from_character_id', $them->id)
->where('body', 'Oi! Tudo tranquilo na cidade.')
->first());
}
/**
* Fresh citizens are created with a ready-to-use default persona.
*/
public function test_new_characters_receive_default_persona(): void
{
[, $character] = $this->makeCitizen();
$this->assertNotNull($character->ai_age);
$this->assertGreaterThanOrEqual(18, $character->ai_age);
$this->assertLessThanOrEqual(68, $character->ai_age);
$this->assertContains($character->ai_personality, Character::AI_PERSONALITIES);
}
/**
* When the resolved primary model fails, the call retries with
* AI_FALLBACK_MODEL (owner-approved xiaomi/mimo-v2.6-flash).
*/
public function test_person_talk_retries_with_fallback_model(): void
{
config([
'ai.openrouter.api_key' => 'test-key',
'ai.models' => ['openrouter/free'],
'ai.character_models' => [],
]);
Http::fake([
'openrouter.ai/*' => Http::sequence()
->push(['message' => 'Model overloaded'], 503)
->push(['choices' => [['message' => ['content' => 'Tudo bem por aqui!']]]], 200),
]);
[$userA, $me] = $this->makeCitizen();
[, $them] = $this->makeCitizen(); // silent peer → AI reply path
$this->actingAs($userA)
->postJson('/talk/person', [
'kind' => 'character',
'peer' => $them->id,
'message' => 'Alô?',
])
->assertCreated();
$reply = PersonTalk::query()
->where('from_character_id', $them->id)
->where('is_fallback', false)
->orderByDesc('id')
->first();
$this->assertNotNull($reply);
$this->assertSame('Tudo bem por aqui!', $reply->body);
$models = [];
Http::assertSent(function ($request) use (&$models): bool {
$models[] = $request['model'];
return true;
});
$this->assertSame(['openrouter/free', 'xiaomi/mimo-v2.6-flash'], $models);
}
}