Files
City10/app/Http/Controllers/Web/CityActionController.php
T
marcocosta10000 b8c31e7be4 feat(city): add east shore beach leisure activity
Characters can now relax on the beach sand to recover energy and
health. The server clamps position to the shoreline, exposes beach
geometry in the city payload, and the client renders animated waves,
sand, stalls, chairs, and ambient NPCs.

Includes Beach support class, new beach action endpoint, localization
strings, and feature tests.
2026-10-31 20:35:22 -03:00

704 lines
24 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\Beach;
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;
// Players may persist onto the east beach sand (past the road grid)
// but never beyond the shoreline.
$maxX = Beach::sandTo($city) + 2;
$x = max((float) $city->bounds_min_x, min($maxX, (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),
'beach' => $this->beach($request, $character),
'stop-exercise' => $this->stopExercise($character),
'ride' => $this->ride($request, $character),
default => response()->json(['message' => __('city.action_unknown')], 422),
};
}
/**
* Relax on the east beach while standing on the sand.
*
* The client posts this on a slow timer during a relax session (the
* same pattern as exercise): each tick recovers a bit of energy and
* health, capped at 100, and only inside the beach zone.
*/
private function beach(Request $request, Character $character): JsonResponse
{
if ($character->state === Character::STATE_SLEEPING) {
return response()->json(['message' => __('city.exercise_need_awake')], 422);
}
if ($character->state === Character::STATE_EXERCISING) {
return response()->json(['message' => __('city.beach_busy')], 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;
if (! Beach::isOnBeach($character->city, $x, $z)) {
return response()->json(['message' => __('city.beach_need')], 422);
}
if ((int) $character->energy >= 100 && (int) $character->health >= 100) {
return response()->json([
'message' => __('city.beach_full'),
'full' => true,
'character' => $this->characterData($character),
]);
}
$character->forceFill([
'health' => min(100, (int) $character->health + 1),
'energy' => min(100, (int) $character->energy + 1),
'pos_x' => $x,
'pos_z' => $z,
])->save();
return response()->json([
'message' => __('city.beach_ok'),
'character' => $this->characterData($character),
]);
}
/**
* 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(),
];
}
}