feat(world): add persistent citizen simulation with needs, jobs, and social features
- Adds Character model with survival needs (hunger, thirst, energy) and state machine - Implements city:tick world ticker for offline progression every minute - Adds city territory expansion and building construction system - Introduces job economy, inventory, and transaction audit trail - Adds social graph (friends, marriage, children) and character chat - Implements admin approval workflow for new registrations - Adds hospital admission system for traffic accidents - Includes comprehensive feature tests and world seeders
This commit is contained in:
Regular → Executable
+195
-94
@@ -4,120 +4,221 @@ namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AiAgent;
|
||||
use App\Models\Building;
|
||||
use App\Models\Character;
|
||||
use App\Models\City;
|
||||
use App\Models\Job;
|
||||
use App\Models\Store;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 3D city viewer.
|
||||
* City viewer entry point and JSON payload.
|
||||
*
|
||||
* Serves the Three.js scene page plus a lightweight JSON payload of stores
|
||||
* and agents. The JSON endpoint reuses the web session so the browser can
|
||||
* fetch world data without a separate API token.
|
||||
* Renders the 3D scene for a chosen city and exposes characters, buildings
|
||||
* and jobs so the frontend can simulate a living persistent world.
|
||||
*/
|
||||
class CityViewerController extends Controller
|
||||
{
|
||||
/**
|
||||
* Render the city viewer page.
|
||||
* Render the 3D city viewer page.
|
||||
*/
|
||||
public function __invoke(): View
|
||||
public function __invoke(Request $request): View
|
||||
{
|
||||
return view('city.viewer');
|
||||
$city = $this->resolveCity($request);
|
||||
|
||||
return view('city.viewer', [
|
||||
'city' => $city,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the world snapshot consumed by the viewer scene.
|
||||
* JSON payload driving the 3D scene for the selected city.
|
||||
*/
|
||||
public function data(): JsonResponse
|
||||
public function data(Request $request): JsonResponse
|
||||
{
|
||||
$stores = Store::query()
|
||||
->get(['id', 'name', 'type', 'pos_x', 'pos_y', 'pos_z', 'width', 'height', 'depth', 'is_open'])
|
||||
->map(fn (Store $store): array => [
|
||||
'id' => $store->id,
|
||||
'name' => $store->name,
|
||||
'type' => $store->type,
|
||||
'position' => [(float) $store->pos_x, (float) $store->pos_y, (float) $store->pos_z],
|
||||
'size' => [(float) $store->width, (float) $store->height, (float) $store->depth],
|
||||
'is_open' => $store->is_open,
|
||||
]);
|
||||
$city = $this->resolveCity($request);
|
||||
$user = Auth::user();
|
||||
$me = $user ? Character::ensureForUser($user, $city) : null;
|
||||
|
||||
$agents = AiAgent::query()
|
||||
->where('status', '!=', 'paused')
|
||||
->get(['id', 'name', 'type', 'status', 'pos_x', 'pos_y', 'pos_z', 'rotation_y', 'last_decision'])
|
||||
->map(fn (AiAgent $agent): array => [
|
||||
'id' => $agent->id,
|
||||
'name' => $agent->name,
|
||||
'type' => $agent->type,
|
||||
'status' => $agent->status,
|
||||
'position' => [(float) $agent->pos_x, (float) $agent->pos_y, (float) $agent->pos_z],
|
||||
'rotation_y' => (float) $agent->rotation_y,
|
||||
'last_decision' => $agent->last_decision,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'stores' => $stores,
|
||||
'agents' => $agents,
|
||||
'layout' => self::cityLayout($stores->all()),
|
||||
])->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a deterministic street layout from store positions.
|
||||
*
|
||||
* Returns road segments and block rectangles so the frontend can build
|
||||
* a consistent grid-based city without additional database tables.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $stores
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function cityLayout(array $stores): array
|
||||
{
|
||||
if ($stores === []) {
|
||||
return ['roads' => [], 'blocks' => []];
|
||||
}
|
||||
|
||||
$xs = array_map(fn ($s) => $s['position'][0], $stores);
|
||||
$zs = array_map(fn ($s) => $s['position'][2], $stores);
|
||||
|
||||
$minX = floor(min($xs) / 40) * 40 - 40;
|
||||
$maxX = ceil(max($xs) / 40) * 40 + 40;
|
||||
$minZ = floor(min($zs) / 40) * 40 - 40;
|
||||
$maxZ = ceil(max($zs) / 40) * 40 + 40;
|
||||
|
||||
$roads = [];
|
||||
|
||||
// Vertical roads every 40 units.
|
||||
for ($x = $minX; $x <= $maxX; $x += 40) {
|
||||
$roads[] = [
|
||||
'from' => [(float) $x, 0.0, (float) $minZ],
|
||||
'to' => [(float) $x, 0.0, (float) $maxZ],
|
||||
'width' => 8.0,
|
||||
'axis' => 'z',
|
||||
];
|
||||
}
|
||||
|
||||
// Horizontal roads every 40 units.
|
||||
for ($z = $minZ; $z <= $maxZ; $z += 40) {
|
||||
$roads[] = [
|
||||
'from' => [(float) $minX, 0.0, (float) $z],
|
||||
'to' => [(float) $maxX, 0.0, (float) $z],
|
||||
'width' => 8.0,
|
||||
'axis' => 'x',
|
||||
];
|
||||
}
|
||||
|
||||
// Blocks = cells between roads.
|
||||
$blocks = [];
|
||||
for ($x = $minX; $x < $maxX; $x += 40) {
|
||||
for ($z = $minZ; $z < $maxZ; $z += 40) {
|
||||
$blocks[] = [
|
||||
'min' => [(float) $x + 5.0, 0.0, (float) $z + 5.0],
|
||||
'max' => [(float) $x + 35.0, 0.0, (float) $z + 35.0],
|
||||
];
|
||||
if ($me && (int) $me->city_id !== (int) $city->id) {
|
||||
// Keep the player's home city authoritative when no explicit ?city= given.
|
||||
if (! $request->filled('city')) {
|
||||
$city = $me->city;
|
||||
}
|
||||
}
|
||||
|
||||
return ['roads' => $roads, 'blocks' => $blocks];
|
||||
$stores = Store::query()
|
||||
->orderBy('id')
|
||||
->get(['id', 'name', 'type', 'pos_x', 'pos_y', 'pos_z', 'width', 'height', 'depth', 'is_open', 'owner_id'])
|
||||
->map(fn (Store $s): array => [
|
||||
'id' => $s->id,
|
||||
'name' => $s->name,
|
||||
'type' => $s->type,
|
||||
'position' => [(float) $s->pos_x, (float) $s->pos_y, (float) $s->pos_z],
|
||||
'size' => [(float) $s->width, (float) $s->height, (float) $s->depth],
|
||||
'is_open' => (bool) $s->is_open,
|
||||
'owner_id' => $s->owner_id,
|
||||
]);
|
||||
|
||||
$characters = Character::query()
|
||||
->where('city_id', $city->id)
|
||||
->with('user:id,name')
|
||||
->get()
|
||||
->map(fn (Character $c): array => [
|
||||
'id' => $c->id,
|
||||
'name' => $c->name,
|
||||
'type' => $c->user_id === null ? 'child' : 'citizen',
|
||||
'status' => $c->state,
|
||||
'position' => [(float) $c->pos_x, (float) $c->pos_y, (float) $c->pos_z],
|
||||
'is_online' => (bool) $c->is_online,
|
||||
'is_me' => $me !== null && (int) $me->id === (int) $c->id,
|
||||
'owner_name' => $c->user?->name,
|
||||
]);
|
||||
|
||||
$buildings = Building::query()
|
||||
->where('city_id', $city->id)
|
||||
->get()
|
||||
->map(fn (Building $b): array => [
|
||||
'id' => $b->id,
|
||||
'type' => $b->type,
|
||||
'name' => $b->name,
|
||||
'position' => [(float) $b->pos_x, 0, (float) $b->pos_z],
|
||||
'size' => [(float) $b->width, (float) $b->height, (float) $b->depth],
|
||||
'is_complete' => (bool) $b->is_complete,
|
||||
'build_progress' => (int) $b->build_progress,
|
||||
'owner_character_id' => $b->owner_character_id,
|
||||
]);
|
||||
|
||||
$jobs = Job::query()
|
||||
->where('city_id', $city->id)
|
||||
->get()
|
||||
->map(fn (Job $j): array => [
|
||||
'id' => $j->id,
|
||||
'title' => $j->title,
|
||||
'salary' => (float) $j->salary,
|
||||
'requires_education' => (int) $j->requires_education,
|
||||
'store_id' => $j->store_id,
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'city' => [
|
||||
'id' => $city->id,
|
||||
'name' => $city->name,
|
||||
'slug' => $city->slug,
|
||||
'bounds' => [
|
||||
'min_x' => (float) $city->bounds_min_x,
|
||||
'max_x' => (float) $city->bounds_max_x,
|
||||
'min_z' => (float) $city->bounds_min_z,
|
||||
'max_z' => (float) $city->bounds_max_z,
|
||||
],
|
||||
],
|
||||
'stores' => $stores,
|
||||
'characters' => $characters,
|
||||
'buildings' => $buildings,
|
||||
'jobs' => $jobs,
|
||||
'me' => $me ? [
|
||||
'id' => $me->id,
|
||||
'name' => $me->name,
|
||||
'state' => $me->state,
|
||||
'money' => (float) $me->money,
|
||||
'energy' => (int) $me->energy,
|
||||
'hunger' => (int) $me->hunger,
|
||||
'thirst' => (int) $me->thirst,
|
||||
'education' => (int) $me->education,
|
||||
'job_id' => $me->job_id,
|
||||
'job_title' => $me->job?->title,
|
||||
'house_id' => $me->house_id,
|
||||
'position' => [(float) $me->pos_x, (float) $me->pos_y, (float) $me->pos_z],
|
||||
'hospitalized_until' => $me->hospitalized_until?->toIso8601String(),
|
||||
'inventory' => $me->inventory()->get()->mapWithKeys(fn ($i) => [$i->item => (int) $i->qty])->all(),
|
||||
] : null,
|
||||
'layout' => $this->cityLayout($city, $stores->all()),
|
||||
'agents' => AiAgent::orderBy('id')->get()->map(fn ($a): array => [
|
||||
'id' => $a->id,
|
||||
'name' => $a->name,
|
||||
'type' => $a->type,
|
||||
'status' => $a->status,
|
||||
'position' => [(float) $a->pos_x, (float) $a->pos_y, (float) $a->pos_z],
|
||||
'rotation_y' => (float) $a->rotation_y,
|
||||
'current_goal' => $a->current_goal,
|
||||
'last_decision' => $a->last_decision,
|
||||
]),
|
||||
];
|
||||
|
||||
return response()->json($payload)->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the city from the query string or the active default.
|
||||
*/
|
||||
private function resolveCity(Request $request): City
|
||||
{
|
||||
$slug = $request->query('city');
|
||||
|
||||
if (is_string($slug) && $slug !== '') {
|
||||
$city = City::query()
|
||||
->where('slug', $slug)
|
||||
->orWhere('id', ctype_digit($slug) ? (int) $slug : 0)
|
||||
->first();
|
||||
|
||||
if ($city) {
|
||||
return $city;
|
||||
}
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
if ($user) {
|
||||
$character = Character::query()->where('user_id', $user->id)->first();
|
||||
if ($character?->city) {
|
||||
return $character->city;
|
||||
}
|
||||
}
|
||||
|
||||
return City::active() ?? City::query()->firstOrFail();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the street grid layout from city bounds and store anchors.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $storeRows
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function cityLayout(City $city, array $storeRows): array
|
||||
{
|
||||
$minX = (int) floor(((float) $city->bounds_min_x) / 40) * 40 - 40;
|
||||
$maxX = (int) ceil(((float) $city->bounds_max_x) / 40) * 40 + 40;
|
||||
$minZ = (int) floor(((float) $city->bounds_min_z) / 40) * 40 - 40;
|
||||
$maxZ = (int) ceil(((float) $city->bounds_max_z) / 40) * 40 + 40;
|
||||
|
||||
$roads = [];
|
||||
for ($x = $minX; $x <= $maxX; $x += 40) {
|
||||
$roads[] = ['from' => [$x, 0, $minZ], 'to' => [$x, 0, $maxZ], 'width' => 8, 'axis' => 'z'];
|
||||
}
|
||||
for ($z = $minZ; $z <= $maxZ; $z += 40) {
|
||||
$roads[] = ['from' => [$minX, 0, $z], 'to' => [$maxX, 0, $z], 'width' => 8, 'axis' => 'x'];
|
||||
}
|
||||
|
||||
$blocks = [];
|
||||
for ($x = $minX; $x < $maxX; $x += 40) {
|
||||
for ($z = $minZ; $z < $maxZ; $z += 40) {
|
||||
$blocks[] = ['min' => [$x + 5, 0, $z + 5], 'max' => [$x + 35, 0, $z + 35]];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'roads' => $roads,
|
||||
'blocks' => $blocks,
|
||||
'origin' => [0, 0, 0],
|
||||
'bounds' => [
|
||||
'min_x' => $minX,
|
||||
'max_x' => $maxX,
|
||||
'min_z' => $minZ,
|
||||
'max_z' => $maxZ,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user