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'])); // Airport grounds (north/south strips) are walkable beyond the // city bounds inside their X band. $minZ = (float) $city->bounds_min_z; $maxZ = (float) $city->bounds_max_z; if (abs($x) <= Airports::VISIT_HALF_WIDTH) { $minZ = Airports::VISIT_MIN_Z; $maxZ = Airports::VISIT_MAX_Z; } // Port strip (north-east coast): walking east of the bounds opens // the Z range down to the port grounds/quay. if ($x >= Port::VISIT_MIN_X) { $minZ = Port::VISIT_MIN_Z; } $z = max($minZ, min($maxZ, (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 */ 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(), ]; } }