feat(city): add humanoid worker robots as city agents

Seed five worker robot agents (TR-01 to TR-05) with Portuguese
system prompts and active status. Extend the 3D humanoid renderer
with metallic materials, glowing visor, antenna beacon, and chest
status light for the worker_robot type. Add feature tests covering
payload serialization and city bounds validation.
This commit is contained in:
2026-10-31 22:31:03 -03:00
parent 46b15ec7be
commit ecf5291027
4 changed files with 181 additions and 6 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

+31
View File
@@ -67,6 +67,37 @@ class CityWorldSeeder extends Seeder
], ],
); );
// --- Humanoid worker robots (walk the city like pedestrians) ---
$robotSpawns = [
['Robô TR-01', 12, -16],
['Robô TR-02', -18, 10],
['Robô TR-03', 100, -14],
['Robô TR-04', -60, 14],
['Robô TR-05', 60, 26],
];
$robotModel = (string) config('ai.default_model');
if (! in_array($robotModel, config('ai.allowed_models', []), true)) {
$robotModel = 'deepseek/deepseek-v4-flash-0731';
}
foreach ($robotSpawns as [$name, $rx, $rz]) {
AiAgent::updateOrCreate(
['name' => $name],
[
'type' => 'worker_robot',
'model' => $robotModel,
'system_prompt' => 'Você é '.$name.', um robô humanoide trabalhador da cidade '
.'virtual City10. Você circula pelas ruas transportando materiais e ajudando '
.'nas lojas. Fale de forma curta, educada e um pouco robótica, sempre em '
.'português, sem inventar dados pessoais de usuários.',
'current_goal' => 'Circular entre as lojas auxiliando nas tarefas do dia',
'status' => 'active',
'pos_x' => $rx,
'pos_y' => 0,
'pos_z' => $rz,
],
);
}
$bakery = Store::where('type', 'bakery')->orderBy('id')->first(); $bakery = Store::where('type', 'bakery')->orderBy('id')->first();
$market = Store::where('type', 'market')->orderBy('id')->first(); $market = Store::where('type', 'market')->orderBy('id')->first();
+62 -6
View File
@@ -1817,14 +1817,17 @@ function buildHumanoid(agent, showLabel = true) {
surfer: { skin: 0xf1c27d, hair: 0x3b2314, shirt: 0x3fb6c9, pants: 0x1c1c1c, scale: 1 }, surfer: { skin: 0xf1c27d, hair: 0x3b2314, shirt: 0x3fb6c9, pants: 0x1c1c1c, scale: 1 },
basket: { skin: 0xd2956b, hair: 0x111111, shirt: 0xe8802a, pants: 0xf2f2f2, scale: 1 }, basket: { skin: 0xd2956b, hair: 0x111111, shirt: 0xe8802a, pants: 0xf2f2f2, scale: 1 },
atc: { skin: 0xe0ac69, hair: 0x1a1a1a, shirt: 0x223366, pants: 0x0f1420, scale: 1.02 }, atc: { skin: 0xe0ac69, hair: 0x1a1a1a, shirt: 0x223366, pants: 0x0f1420, scale: 1.02 },
// Humanoid worker robots: brushed metal body + safety-orange vest.
worker_robot: { skin: 0xc9d1d9, hair: 0x9aa4b2, shirt: 0xd97706, pants: 0x47515e, scale: 1, metalness: 0.5 },
}; };
const cfgc = configs[type] || configs.pedestrian; const cfgc = configs[type] || configs.pedestrian;
const s = cfgc.scale; const s = cfgc.scale;
const metalness = cfgc.metalness ?? 0;
const skinMat = new THREE.MeshStandardMaterial({ color: cfgc.skin, roughness: 0.7 }); const skinMat = new THREE.MeshStandardMaterial({ color: cfgc.skin, roughness: 0.7 - metalness * 0.4, metalness });
const hairMat = new THREE.MeshStandardMaterial({ color: cfgc.hair, roughness: 0.6 }); const hairMat = new THREE.MeshStandardMaterial({ color: cfgc.hair, roughness: 0.6, metalness });
const shirtMat = new THREE.MeshStandardMaterial({ color: cfgc.shirt, roughness: 0.8 }); const shirtMat = new THREE.MeshStandardMaterial({ color: cfgc.shirt, roughness: 0.8 - metalness * 0.3, metalness });
const pantsMat = new THREE.MeshStandardMaterial({ color: cfgc.pants, roughness: 0.85 }); const pantsMat = new THREE.MeshStandardMaterial({ color: cfgc.pants, roughness: 0.85 - metalness * 0.3, metalness });
const shoeMat = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.7 }); const shoeMat = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.7 });
const head = new THREE.Mesh(new THREE.SphereGeometry(0.28 * s, 12, 12), skinMat); const head = new THREE.Mesh(new THREE.SphereGeometry(0.28 * s, 12, 12), skinMat);
@@ -1837,6 +1840,50 @@ function buildHumanoid(agent, showLabel = true) {
hair.castShadow = true; hair.castShadow = true;
g.add(hair); g.add(hair);
if (type === 'worker_robot') {
// Glowing visor across the face.
const visor = new THREE.Mesh(
new THREE.BoxGeometry(0.34 * s, 0.1 * s, 0.06 * s),
new THREE.MeshStandardMaterial({
color: 0x0e7490, emissive: 0x22d3ee, emissiveIntensity: 1.5, roughness: 0.3,
})
);
visor.position.set(0, 1.63 * s, 0.255 * s);
g.add(visor);
// Antenna with a blinking beacon (ramps/pulses with the sign system).
const antennaRod = new THREE.Mesh(
new THREE.CylinderGeometry(0.014, 0.02, 0.24, 6),
new THREE.MeshStandardMaterial({ color: 0x6b7280, roughness: 0.5, metalness: 0.7 })
);
antennaRod.position.set(0, 1.97 * s, 0);
g.add(antennaRod);
const beacon = new THREE.Mesh(
new THREE.SphereGeometry(0.045, 8, 8),
new THREE.MeshStandardMaterial({ color: 0xff3333, emissive: 0xff3333, emissiveIntensity: 2 })
);
beacon.position.set(0, 2.1 * s, 0);
g.add(beacon);
signMaterials.push({
mat: beacon.material, base: 1.2, nightBoost: 1.0, pulse: 0.9,
phase: Math.random() * Math.PI * 2,
});
// Chest status light (amber, softer pulse).
const chest = new THREE.Mesh(
new THREE.BoxGeometry(0.15, 0.11, 0.05),
new THREE.MeshStandardMaterial({
color: 0x78350f, emissive: 0xf59e0b, emissiveIntensity: 1.1, roughness: 0.4,
})
);
chest.position.set(0, 1.2 * s, 0.21 * s);
g.add(chest);
signMaterials.push({
mat: chest.material, base: 0.8, nightBoost: 0.7, pulse: 0.35,
phase: Math.random() * Math.PI * 2,
});
}
const torso = new THREE.Mesh(new THREE.CapsuleGeometry(0.24 * s, 0.55 * s, 4, 8), shirtMat); const torso = new THREE.Mesh(new THREE.CapsuleGeometry(0.24 * s, 0.55 * s, 4, 8), shirtMat);
torso.position.y = 1.12 * s; torso.position.y = 1.12 * s;
torso.castShadow = true; torso.castShadow = true;
@@ -2029,7 +2076,7 @@ function initPedestrians(g, agents) {
if (i % 3 === 0) routes.push({ route: crossingRoute(sq, g), kind: 'cross' }); if (i % 3 === 0) routes.push({ route: crossingRoute(sq, g), kind: 'cross' });
}); });
const pedAgents = agents.filter(a => a.type === 'pedestrian'); const pedAgents = agents.filter(a => a.type === 'pedestrian' || a.type === 'worker_robot');
pedAgents.forEach((a, i) => { pedAgents.forEach((a, i) => {
const pick = routes[i % routes.length]; const pick = routes[i % routes.length];
const mesh = buildHumanoid(a, true); const mesh = buildHumanoid(a, true);
@@ -2038,6 +2085,7 @@ function initPedestrians(g, agents) {
const w = { const w = {
mesh, route: pick.route, wp: 0, speed: 1.15 + Math.random() * 0.4, mesh, route: pick.route, wp: 0, speed: 1.15 + Math.random() * 0.4,
wait: Math.random() * 2, pauseAt: 4 + Math.random() * 8, pauseTimer: 0, wait: Math.random() * 2, pauseAt: 4 + Math.random() * 8, pauseTimer: 0,
robot: a.type === 'worker_robot',
}; };
const start = w.route.waypoints[0]; const start = w.route.waypoints[0];
mesh.position.set(start.x, 0, start.z); mesh.position.set(start.x, 0, start.z);
@@ -4909,7 +4957,7 @@ function buildCity(data) {
roofBatch.flush(worldGroup); roofBatch.flush(worldGroup);
// ---- Agents (mayor, shopkeepers handled in their systems; pedestrians walk) ---- // ---- Agents (mayor, shopkeepers handled in their systems; pedestrians walk) ----
const walkingTypes = new Set(['pedestrian', 'mayor', 'shopkeeper']); const walkingTypes = new Set(['pedestrian', 'mayor', 'shopkeeper', 'worker_robot']);
const placed = new Set(); const placed = new Set();
// Shopkeepers + mayor + pedestrians get behaviours; any leftover static agents render in place. // Shopkeepers + mayor + pedestrians get behaviours; any leftover static agents render in place.
@@ -6643,6 +6691,14 @@ window.__city10 = {
sidewalkSegments: sidewalkSegmentCount, sidewalkSegments: sidewalkSegmentCount,
beach: beachSystem ? beachSystem.counts() : null, beach: beachSystem ? beachSystem.counts() : null,
flights: airplaneSystem ? airplaneSystem.flights() : null, flights: airplaneSystem ? airplaneSystem.flights() : null,
robots: pedestrianSystem
? pedestrianSystem.walkers.filter(w => w.robot).length
: 0,
robotWalkers: pedestrianSystem
? pedestrianSystem.walkers
.filter(w => w.robot)
.map(w => ({ x: +w.mesh.position.x.toFixed(1), z: +w.mesh.position.z.toFixed(1) }))
: [],
signs: { signs: {
stores: signCounts.stores, stores: signCounts.stores,
plazas: signCounts.plazas, plazas: signCounts.plazas,
+88
View File
@@ -0,0 +1,88 @@
<?php
namespace Tests\Feature;
use App\Models\AiAgent;
use App\Models\Character;
use App\Models\User;
use Database\Seeders\CityWorldSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Humanoid worker robots: seeded as walkable agents with allow-listed
* models and in-city positions so the viewer spawns them on routes.
*/
class WorkerRobotTest extends TestCase
{
use RefreshDatabase;
/**
* Boot world fixtures.
*/
protected function setUp(): void
{
parent::setUp();
$this->seed(CityWorldSeeder::class);
$user = User::factory()->create(['status' => User::STATUS_ACTIVE, 'is_active' => true]);
Character::ensureForUser($user);
$this->actingAs($user);
}
/**
* The payload carries the five worker robots; their AI personas
* (model/prompt) live only in the database — never in the public
* world payload.
*/
public function test_payload_contains_worker_robots(): void
{
$payload = $this->getJson('/city/data')->assertOk()->json();
$robots = array_values(array_filter(
$payload['agents'],
static fn (array $a): bool => $a['type'] === 'worker_robot'
));
$this->assertCount(5, $robots);
$this->assertSame(
['Robô TR-01', 'Robô TR-02', 'Robô TR-03', 'Robô TR-04', 'Robô TR-05'],
array_column($robots, 'name')
);
foreach ($robots as $robot) {
$this->assertSame('active', $robot['status']);
}
// Personas are stored server-side only.
$this->assertArrayNotHasKey('model', $robots[0]);
$this->assertArrayNotHasKey('system_prompt', $robots[0]);
foreach (AiAgent::where('type', 'worker_robot')->get() as $agent) {
$this->assertContains($agent->model, config('ai.allowed_models'));
$this->assertNotEmpty($agent->system_prompt);
$this->assertStringContainsString('robô', $agent->system_prompt);
}
}
/**
* Robot spawn points sit inside the city bounds (they seed the DB
* row; the viewer re-places them on walking routes).
*/
public function test_robot_positions_stay_inside_city_bounds(): void
{
$payload = $this->getJson('/city/data')->assertOk()->json();
$bounds = $payload['city']['bounds'];
$robots = array_values(array_filter(
$payload['agents'],
static fn (array $a): bool => $a['type'] === 'worker_robot'
));
$this->assertNotEmpty($robots);
foreach ($robots as $robot) {
$this->assertGreaterThanOrEqual($bounds['min_x'], $robot['position'][0]);
$this->assertLessThanOrEqual($bounds['max_x'], $robot['position'][0]);
$this->assertGreaterThanOrEqual($bounds['min_z'], $robot['position'][2]);
$this->assertLessThanOrEqual($bounds['max_z'], $robot['position'][2]);
}
}
}