Files
City10/app/Http/Controllers/Web/CityActionController.php
T
marcocosta10000 090b530481 feat(city): add plaza exercise system with health tracking
- Adds CityPlazas support class for deterministic public plaza generation with outdoor gym equipment
- Implements exercise and stop-exercise actions with proximity validation to equipment
- Introduces health (0-100) and exercise_count attributes to Character model
- Reduces hospital stay duration from two game days to one game hour
- Adds 3D gym equipment rendering (bench, bars, bike, treadmill) in city viewer
- Includes exercise state machine handling in world ticker with energy drain
- Adds comprehensive PlazaExerciseTest and updates HospitalAdmissionTest
- Provides localized UI strings for health HUD and exercise prompts
2026-10-17 22:10:50 -03:00

576 lines
19 KiB
PHP
Executable File
Raw 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 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),
default => response()->json(['message' => __('city.action_unknown')], 422),
};
}
/**
* 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();
return response()->json([
'message' => __('city.exercise_ok'),
'equipment' => $nearby,
'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,
]);
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(),
'inventory' => $character->inventory->mapWithKeys(fn ($i) => [$i->item => (int) $i->qty])->all(),
];
}
}