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:
Binary file not shown.
|
After Width: | Height: | Size: 256 KiB |
@@ -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();
|
||||
|
||||
|
||||
+62
-6
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user