Files
City10/app/Http/Controllers/Web/CityActionController.php
marcocosta10000 a620f83dd8 feat(city): add player marketplace, mission journal, character customization and presence
- Player-to-player marketplace with listings, buying and selling
- Mission system with progress tracking and money rewards
- Character appearance customization (skin, hair, face, beard, accessory)
- Multiplayer presence feed showing nearby citizens in near-real-time
- Admin world dashboard with live platform metrics
- Bus and train transportation with temporary ride state
- Mobile joystick controls and minimap HUD
- Education bonus applied to work salary
2026-10-17 22:51:20 -03:00

649 lines
22 KiB
PHP
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 App\Support\CityPlazas;
use App\Support\MissionTracker;
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),
'exercise' => $this->exercise($request, $character),
'stop-exercise' => $this->stopExercise($character),
'ride' => $this->ride($request, $character),
default => response()->json(['message' => __('city.action_unknown')], 422),
};
}
/**
* Board a nearby bus stop or train for a short ride.
*/
private function ride(Request $request, Character $character): JsonResponse
{
if ($character->isHospitalized()) {
return response()->json(['message' => __('city.hospital_blocked')], 422);
}
$data = $request->validate([
'kind' => ['required', 'in:bus,train'],
'pos_x' => ['nullable', 'numeric'],
'pos_z' => ['nullable', 'numeric'],
]);
$x = array_key_exists('pos_x', $data) ? (float) $data['pos_x'] : (float) $character->pos_x;
$z = array_key_exists('pos_z', $data) ? (float) $data['pos_z'] : (float) $character->pos_z;
// Must be near a road (bus) or the train corridor (train).
$city = $character->city;
if ($data['kind'] === 'bus') {
$nearRoad = false;
for ($vx = ceil(((float) $city->bounds_min_x) / 40) * 40; $vx <= $city->bounds_max_x; $vx += 40) {
if (abs($x - $vx) < 8) {
$nearRoad = true;
break;
}
}
if (! $nearRoad) {
for ($vz = ceil(((float) $city->bounds_min_z) / 40) * 40; $vz <= $city->bounds_max_z; $vz += 40) {
if (abs($z - $vz) < 8) {
$nearRoad = true;
break;
}
}
}
if (! $nearRoad) {
return response()->json(['message' => __('city.ride_need_stop')], 422);
}
} elseif (abs($z - ((float) $city->bounds_max_z + 14)) > 20) {
return response()->json(['message' => __('city.ride_need_stop')], 422);
}
$character->forceFill([
'ride_kind' => $data['kind'],
'ride_until' => now()->addSeconds(45),
'state' => Character::STATE_AWAKE,
'pos_x' => $x,
'pos_z' => $z,
'is_online' => true,
])->save();
return response()->json([
'message' => $data['kind'] === 'train' ? __('city.ride_train_ok') : __('city.ride_bus_ok'),
'character' => $this->characterData($character),
]);
}
/**
* One exercise rep on nearby plaza gym equipment: +health, −energy.
*
* Optional pos_x/pos_z let the client report its live position so the
* proximity check does not wait for the throttled position sync.
*/
private function exercise(Request $request, Character $character): JsonResponse
{
if ($character->state === Character::STATE_SLEEPING) {
return response()->json(['message' => __('city.exercise_need_awake')], 422);
}
$data = $request->validate([
'pos_x' => ['nullable', 'numeric'],
'pos_z' => ['nullable', 'numeric'],
]);
$x = array_key_exists('pos_x', $data) ? (float) $data['pos_x'] : (float) $character->pos_x;
$z = array_key_exists('pos_z', $data) ? (float) $data['pos_z'] : (float) $character->pos_z;
$nearby = CityPlazas::nearestEquipment(
$character->city,
$x,
$z,
4.0,
);
if ($nearby === null) {
return response()->json(['message' => __('city.exercise_need_equipment')], 422);
}
if ((int) $character->energy < 5) {
return response()->json(['message' => __('city.exercise_tired')], 422);
}
if ((int) $character->health >= 100) {
return response()->json([
'message' => __('city.exercise_max_health'),
'character' => $this->characterData($character),
]);
}
$character->forceFill([
'state' => Character::STATE_EXERCISING,
'health' => min(100, (int) $character->health + 2),
'energy' => max(0, (int) $character->energy - 2),
'exercise_count' => (int) $character->exercise_count + 1,
'pos_x' => $x,
'pos_z' => $z,
])->save();
$missionDones = MissionTracker::sync($character, 'exercise_count');
return response()->json([
'message' => __('city.exercise_ok'),
'equipment' => $nearby,
'missions_completed' => $missionDones,
'character' => $this->characterData($character),
]);
}
/**
* Leave the exercising state (back to awake).
*/
private function stopExercise(Character $character): JsonResponse
{
if ($character->state === Character::STATE_EXERCISING) {
$character->state = Character::STATE_AWAKE;
$character->save();
}
return response()->json([
'message' => __('city.action_ok'),
'character' => $this->characterData($character),
]);
}
/**
* Admit the character to the hospital after a vehicle collision.
*
* Stays one game hour (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,
]);
MissionTracker::sync($character->fresh(), 'buildings');
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,
'health' => (int) $character->health,
'education' => (int) $character->education,
'exercise_count' => (int) $character->exercise_count,
'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(),
'face' => (int) $character->face,
'beard' => (int) $character->beard,
'accessory' => (int) $character->accessory,
'skin' => (int) $character->skin,
'hair' => (int) $character->hair,
'shirt' => (int) $character->shirt,
'ride_kind' => $character->ride_kind,
'ride_until' => $character->ride_until?->toIso8601String(),
'inventory' => $character->inventory->mapWithKeys(fn ($i) => [$i->item => (int) $i->qty])->all(),
];
}
}