diff --git a/.playwright-mcp/robo-trabalhador-andando.png b/.playwright-mcp/robo-trabalhador-andando.png new file mode 100644 index 0000000..75a7fda Binary files /dev/null and b/.playwright-mcp/robo-trabalhador-andando.png differ diff --git a/database/seeders/CityWorldSeeder.php b/database/seeders/CityWorldSeeder.php index 5ccf9dd..caa07a0 100755 --- a/database/seeders/CityWorldSeeder.php +++ b/database/seeders/CityWorldSeeder.php @@ -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(); $market = Store::where('type', 'market')->orderBy('id')->first(); diff --git a/public/js/city/app.js b/public/js/city/app.js index a181c7c..d76b361 100755 --- a/public/js/city/app.js +++ b/public/js/city/app.js @@ -1817,14 +1817,17 @@ function buildHumanoid(agent, showLabel = true) { surfer: { skin: 0xf1c27d, hair: 0x3b2314, shirt: 0x3fb6c9, pants: 0x1c1c1c, 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 }, + // 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 s = cfgc.scale; + const metalness = cfgc.metalness ?? 0; - const skinMat = new THREE.MeshStandardMaterial({ color: cfgc.skin, roughness: 0.7 }); - const hairMat = new THREE.MeshStandardMaterial({ color: cfgc.hair, roughness: 0.6 }); - const shirtMat = new THREE.MeshStandardMaterial({ color: cfgc.shirt, roughness: 0.8 }); - const pantsMat = new THREE.MeshStandardMaterial({ color: cfgc.pants, roughness: 0.85 }); + 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, metalness }); + 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 - metalness * 0.3, metalness }); const shoeMat = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.7 }); 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; 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); torso.position.y = 1.12 * s; torso.castShadow = true; @@ -2029,7 +2076,7 @@ function initPedestrians(g, agents) { 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) => { const pick = routes[i % routes.length]; const mesh = buildHumanoid(a, true); @@ -2038,6 +2085,7 @@ function initPedestrians(g, agents) { const w = { mesh, route: pick.route, wp: 0, speed: 1.15 + Math.random() * 0.4, wait: Math.random() * 2, pauseAt: 4 + Math.random() * 8, pauseTimer: 0, + robot: a.type === 'worker_robot', }; const start = w.route.waypoints[0]; mesh.position.set(start.x, 0, start.z); @@ -4909,7 +4957,7 @@ function buildCity(data) { roofBatch.flush(worldGroup); // ---- 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(); // Shopkeepers + mayor + pedestrians get behaviours; any leftover static agents render in place. @@ -6643,6 +6691,14 @@ window.__city10 = { sidewalkSegments: sidewalkSegmentCount, beach: beachSystem ? beachSystem.counts() : 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: { stores: signCounts.stores, plazas: signCounts.plazas, diff --git a/tests/Feature/WorkerRobotTest.php b/tests/Feature/WorkerRobotTest.php new file mode 100644 index 0000000..dd5a697 --- /dev/null +++ b/tests/Feature/WorkerRobotTest.php @@ -0,0 +1,88 @@ +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]); + } + } +}