- 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
649 lines
22 KiB
PHP
Executable File
649 lines
22 KiB
PHP
Executable File
<?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(),
|
||
];
|
||
}
|
||
}
|