feat(city): add player marketplace, mission journal, character customization and presence

- 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
This commit is contained in:
2026-10-17 22:51:20 -03:00
parent 18778b3d26
commit a620f83dd8
30 changed files with 1822 additions and 10 deletions
@@ -10,6 +10,7 @@ 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;
@@ -100,10 +101,69 @@ class CityActionController extends Controller
'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.
*
@@ -155,9 +215,12 @@ class CityActionController extends Controller
'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),
]);
}
@@ -476,6 +539,8 @@ class CityActionController extends Controller
'build_progress' => 0,
]);
MissionTracker::sync($character->fresh(), 'buildings');
return response()->json([
'message' => __('city.build_started'),
'building' => [
@@ -569,6 +634,14 @@ class CityActionController extends Controller
'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(),
];
}