Files
City10/app/Http/Controllers/Web/CityActionController.php
T
marcocosta10000 79ce2a1825 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
2026-10-17 21:46:23 -03:00

497 lines
16 KiB
PHP
Executable File

<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Building;
use App\Models\Character;
use App\Models\City;
use App\Models\InventoryItem;
use App\Models\Job;
use App\Models\Transaction;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Player-driven world actions: position sync and lifecycle verbs.
*
* Every action targets the authenticated user's own character only and
* respects city bounds and inventory/money rules from the design spec.
*/
class CityActionController extends Controller
{
public const BUILD_MATERIALS = 50;
/**
* Persist the caller's character position (throttled from the viewer).
*/
public function position(Request $request): JsonResponse
{
$character = $this->ownCharacter($request);
$character->releaseIfHospitalStayOver();
$data = $request->validate([
'pos_x' => ['required', 'numeric'],
'pos_z' => ['required', 'numeric'],
]);
if ($character->isHospitalized()) {
return response()->json([
'message' => __('city.hospital_bed'),
'pos_x' => $character->pos_x,
'pos_z' => $character->pos_z,
'state' => $character->state,
'hospitalized_until' => $character->hospitalized_until?->toIso8601String(),
]);
}
$city = $character->city;
$x = max((float) $city->bounds_min_x, min((float) $city->bounds_max_x, (float) $data['pos_x']));
$z = max((float) $city->bounds_min_z, min((float) $city->bounds_max_z, (float) $data['pos_z']));
$character->forceFill([
'pos_x' => $x,
'pos_z' => $z,
'pos_y' => 0,
'is_online' => true,
])->save();
return response()->json([
'pos_x' => $character->pos_x,
'pos_z' => $character->pos_z,
'state' => $character->state,
'hospitalized_until' => null,
]);
}
/**
* Dispatch a named world action for the caller's character.
*/
public function handle(Request $request, string $action): JsonResponse
{
$character = $this->ownCharacter($request);
$character->releaseIfHospitalStayOver();
// Traffic accidents are always accepted, even from the hospital bed.
if ($action === 'accident') {
return $this->accident($character);
}
if ($character->isHospitalized()) {
return response()->json([
'message' => __('city.hospital_blocked'),
'hospitalized_until' => $character->hospitalized_until?->toIso8601String(),
'character' => $this->characterData($character),
], 422);
}
return match ($action) {
'eat' => $this->eat($character),
'drink' => $this->drink($character),
'sleep' => $this->sleep($character),
'wake' => $this->wake($character),
'work' => $this->work($character),
'stop-work' => $this->stopWork($character),
'study' => $this->study($character),
'buy' => $this->buy($request, $character),
'sell' => $this->sell($request, $character),
'build' => $this->build($request, $character),
'apply-job' => $this->applyJob($request, $character),
default => response()->json(['message' => __('city.action_unknown')], 422),
};
}
/**
* Admit the character to the hospital after a vehicle collision.
*
* Stays two game days (Character::HOSPITAL_STAY_SECONDS).
*/
private function accident(Character $character): JsonResponse
{
if ($character->isHospitalized()) {
return response()->json([
'message' => __('city.hospital_already'),
'hospitalized_until' => $character->hospitalized_until?->toIso8601String(),
'character' => $this->characterData($character),
]);
}
[$x, $z] = $character->admitToHospital();
return response()->json([
'message' => __('city.hospital_hit'),
'hospitalized_until' => $character->hospitalized_until?->toIso8601String(),
'character' => $this->characterData($character),
'bed' => ['pos_x' => $x, 'pos_z' => $z],
]);
}
/**
* Resolve the character owned by the authenticated user.
*/
private function ownCharacter(Request $request): Character
{
$user = $request->user();
abort_unless($user !== null, 401);
return Character::ensureForUser($user);
}
/**
* Consume one food ration and restore hunger.
*/
private function eat(Character $character): JsonResponse
{
if (! $this->consume($character, InventoryItem::ITEM_FOOD, 1)) {
return response()->json(['message' => __('city.no_food')], 422);
}
$character->hunger = min(100, (int) $character->hunger + 40);
$character->save();
return $this->characterPayload($character);
}
/**
* Consume one water ration and restore thirst.
*/
private function drink(Character $character): JsonResponse
{
if (! $this->consume($character, InventoryItem::ITEM_WATER, 1)) {
return response()->json(['message' => __('city.no_water')], 422);
}
$character->thirst = min(100, (int) $character->thirst + 40);
$character->save();
return $this->characterPayload($character);
}
/**
* Lie down to sleep (requires a completed house).
*/
private function sleep(Character $character): JsonResponse
{
if ($character->state === Character::STATE_SLEEPING) {
return $this->characterPayload($character);
}
if ($character->house_id === null || ! $character->house?->is_complete) {
return response()->json(['message' => __('city.no_house')], 422);
}
$character->state = Character::STATE_SLEEPING;
$character->save();
return $this->characterPayload($character);
}
/**
* Leave the sleeping state.
*/
private function wake(Character $character): JsonResponse
{
if ($character->state === Character::STATE_SLEEPING) {
$character->state = Character::STATE_AWAKE;
$character->save();
}
return $this->characterPayload($character);
}
/**
* Start working (requires employment).
*/
private function work(Character $character): JsonResponse
{
if ($character->job_id === null) {
return response()->json(['message' => __('city.no_job')], 422);
}
$character->state = Character::STATE_WORKING;
$character->save();
return $this->characterPayload($character);
}
/**
* Stop working or studying.
*/
private function stopWork(Character $character): JsonResponse
{
if (in_array($character->state, [Character::STATE_WORKING, Character::STATE_STUDYING], true)) {
$character->state = Character::STATE_AWAKE;
$character->save();
}
return $this->characterPayload($character);
}
/**
* Study at the city school (requires a school building type presence or job city school).
*/
private function study(Character $character): JsonResponse
{
$hasSchool = Building::query()
->where('city_id', $character->city_id)
->whereIn('type', ['school', Building::TYPE_HOUSE])
->exists();
$hasCivicSchool = Building::query()
->where('city_id', $character->city_id)
->where('type', 'school')
->exists();
// City10 ships with a public school implied by the "Professor" job city.
if (! $hasCivicSchool && ! $hasSchool) {
$hasSchool = true;
}
if (! $hasSchool) {
return response()->json(['message' => __('city.no_school')], 422);
}
$character->state = Character::STATE_STUDYING;
$character->save();
return $this->characterPayload($character);
}
/**
* Accept an open job position in the character's city.
*/
private function applyJob(Request $request, Character $character): JsonResponse
{
$data = $request->validate([
'job_id' => ['required', 'integer', 'exists:job_positions,id'],
]);
$job = Job::query()->findOrFail($data['job_id']);
if ((int) $job->city_id !== (int) $character->city_id) {
return response()->json(['message' => __('city.job_other_city')], 422);
}
if ((int) $character->education < (int) $job->requires_education) {
return response()->json(['message' => __('city.job_education')], 422);
}
$character->job_id = $job->id;
$character->state = Character::STATE_AWAKE;
$character->save();
return $this->characterPayload($character);
}
/**
* Buy items from the city market using money.
*/
private function buy(Request $request, Character $character): JsonResponse
{
$data = $request->validate([
'item' => ['required', 'in:food,water,materials,product'],
'qty' => ['required', 'integer', 'min:1', 'max:50'],
'price' => ['required', 'numeric', 'min:0.01', 'max:1000'],
]);
$total = round($data['price'] * $data['qty'], 2);
if ($character->money < $total) {
return response()->json(['message' => __('city.no_money')], 422);
}
DB::transaction(function () use ($character, $data, $total): void {
$character->money = $character->money - $total;
$character->save();
$this->grant($character, $data['item'], (int) $data['qty']);
Transaction::create([
'from_character_id' => $character->id,
'item' => $data['item'],
'qty' => (int) $data['qty'],
'amount' => $total,
]);
});
return $this->characterPayload($character);
}
/**
* Sell inventory items for money.
*/
private function sell(Request $request, Character $character): JsonResponse
{
$data = $request->validate([
'item' => ['required', 'in:food,water,materials,product'],
'qty' => ['required', 'integer', 'min:1', 'max:50'],
'price' => ['required', 'numeric', 'min:0.01', 'max:1000'],
]);
if (! $this->consume($character, $data['item'], (int) $data['qty'])) {
return response()->json(['message' => __('city.no_inventory')], 422);
}
$total = round($data['price'] * $data['qty'], 2);
DB::transaction(function () use ($character, $data, $total): void {
$character->money = $character->money + $total;
$character->save();
Transaction::create([
'to_character_id' => $character->id,
'item' => $data['item'],
'qty' => (int) $data['qty'],
'amount' => $total,
]);
});
return $this->characterPayload($character);
}
/**
* Start constructing a house on free terrain.
*/
private function build(Request $request, Character $character): JsonResponse
{
$data = $request->validate([
'pos_x' => ['required', 'numeric'],
'pos_z' => ['required', 'numeric'],
'name' => ['nullable', 'string', 'max:120'],
]);
$city = $character->city;
$x = (float) $data['pos_x'];
$z = (float) $data['pos_z'];
if (! $city->contains($x, $z)) {
return response()->json(['message' => __('city.out_of_bounds')], 422);
}
if (! $this->consume($character, InventoryItem::ITEM_MATERIALS, self::BUILD_MATERIALS)) {
return response()->json(['message' => __('city.no_materials')], 422);
}
$width = 10;
$depth = 10;
$overlaps = Building::query()
->where('city_id', $city->id)
->get()
->contains(function (Building $b) use ($x, $z, $width, $depth): bool {
$minX = (float) $b->pos_x - (float) $b->width / 2;
$maxX = (float) $b->pos_x + (float) $b->width / 2;
$minZ = (float) $b->pos_z - (float) $b->depth / 2;
$maxZ = (float) $b->pos_z + (float) $b->depth / 2;
return ($x + $width / 2) > $minX && ($x - $width / 2) < $maxX
&& ($z + $depth / 2) > $minZ && ($z - $depth / 2) < $maxZ;
});
if ($overlaps) {
return response()->json(['message' => __('city.site_taken')], 422);
}
$building = Building::create([
'city_id' => $city->id,
'owner_character_id' => $character->id,
'type' => Building::TYPE_HOUSE,
'name' => $data['name'] ?? ($character->name."'s house"),
'pos_x' => $x,
'pos_z' => $z,
'width' => $width,
'depth' => $depth,
'height' => 5,
'is_complete' => false,
'build_progress' => 0,
]);
return response()->json([
'message' => __('city.build_started'),
'building' => [
'id' => $building->id,
'pos_x' => $building->pos_x,
'pos_z' => $building->pos_z,
'progress' => $building->build_progress,
],
'character' => $this->characterData($character->fresh()),
]);
}
/**
* Grant inventory quantity (upsert).
*/
private function grant(Character $character, string $item, int $qty): void
{
$row = InventoryItem::query()
->where('character_id', $character->id)
->where('item', $item)
->first();
if ($row) {
$row->qty = (int) $row->qty + $qty;
$row->save();
return;
}
InventoryItem::create([
'character_id' => $character->id,
'item' => $item,
'qty' => $qty,
]);
}
/**
* Consume inventory quantity when available.
*/
private function consume(Character $character, string $item, int $qty): bool
{
$row = InventoryItem::query()
->where('character_id', $character->id)
->where('item', $item)
->first();
if ($row === null || (int) $row->qty < $qty) {
return false;
}
$row->qty = (int) $row->qty - $qty;
$row->save();
return true;
}
/**
* Standard character JSON payload for action responses.
*/
private function characterPayload(Character $character): JsonResponse
{
return response()->json([
'message' => __('city.action_ok'),
'character' => $this->characterData($character),
]);
}
/**
* Character attributes exposed to the client.
*
* @return array<string, mixed>
*/
private function characterData(Character $character): array
{
$character->loadMissing('inventory', 'job');
return [
'id' => $character->id,
'name' => $character->name,
'state' => $character->state,
'money' => (float) $character->money,
'energy' => (int) $character->energy,
'hunger' => (int) $character->hunger,
'thirst' => (int) $character->thirst,
'education' => (int) $character->education,
'job_id' => $character->job_id,
'job_title' => $character->job?->title,
'house_id' => $character->house_id,
'pos_x' => (float) $character->pos_x,
'pos_z' => (float) $character->pos_z,
'hospitalized_until' => $character->hospitalized_until?->toIso8601String(),
'inventory' => $character->inventory->mapWithKeys(fn ($i) => [$i->item => (int) $i->qty])->all(),
];
}
}