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 */ 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(), ]; } }