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
@@ -0,0 +1,81 @@
<?php
namespace App\Http\Controllers\Web\Admin;
use App\Http\Controllers\Controller;
use App\Models\Building;
use App\Models\Character;
use App\Models\CharacterMessage;
use App\Models\City;
use App\Models\Job;
use App\Models\MarketListing;
use App\Models\Mission;
use App\Models\Store;
use App\Models\Transaction;
use App\Models\User;
use App\Models\UserConsent;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
/**
* Administrator world dashboard and metrics.
*
* Aggregates live city health: citizens, economy, constructions, missions
* and marketplace activity so operators can steer the persistent world.
*/
class AdminWorldController extends Controller
{
/**
* HTML dashboard for the admin area.
*/
public function index(Request $request): View
{
abort_unless($request->user()?->isAdmin(), 403);
return view('admin.world.index', [
'metrics' => $this->metrics(),
]);
}
/**
* JSON metrics (also used by the report command).
*/
public function metrics(): array
{
return [
'users' => [
'total' => User::query()->count(),
'pending' => User::query()->where('status', User::STATUS_PENDING)->count(),
'active' => User::query()->where('status', User::STATUS_ACTIVE)->count(),
'banned' => User::query()->where('status', User::STATUS_BANNED)->count(),
'consents' => UserConsent::query()->count(),
],
'cities' => City::query()->count(),
'characters' => [
'total' => Character::query()->count(),
'online' => Character::query()->where('is_online', true)->count(),
'hospitalized' => Character::query()->where('state', Character::STATE_HOSPITAL)->count(),
'avg_health' => (int) round(Character::query()->avg('health') ?? 0),
'avg_education' => (int) round(Character::query()->avg('education') ?? 0),
'total_money' => round((float) Character::query()->sum('money'), 2),
],
'world' => [
'stores' => Store::query()->count(),
'buildings' => Building::query()->count(),
'houses_complete' => Building::query()->where('type', 'house')->where('is_complete', true)->count(),
'jobs' => Job::query()->count(),
'messages' => CharacterMessage::query()->count(),
'transactions' => Transaction::query()->count(),
'transaction_volume' => round((float) Transaction::query()->sum('amount'), 2),
],
'market' => [
'listings' => MarketListing::query()->where('is_active', true)->count(),
],
'missions' => [
'active' => Mission::query()->where('is_active', true)->count(),
'completed' => DB::table('character_missions')->where('is_completed', true)->count(),
],
];
}
}
@@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Character;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Character appearance customization.
*/
class CharacterCustomizeController extends Controller
{
/**
* Show current appearance slots.
*/
public function show(Request $request): JsonResponse
{
$me = Character::ensureForUser($request->user());
return response()->json([
'name' => $me->name,
'skin' => (int) $me->skin,
'hair' => (int) $me->hair,
'shirt' => (int) $me->shirt,
'face' => (int) $me->face,
'beard' => (int) $me->beard,
'accessory' => (int) $me->accessory,
]);
}
/**
* Persist appearance choices (1-based slots within safe ranges).
*/
public function update(Request $request): JsonResponse
{
$me = Character::ensureForUser($request->user());
$data = $request->validate([
'skin' => ['required', 'integer', 'min:1', 'max:4'],
'hair' => ['required', 'integer', 'min:1', 'max:4'],
'shirt' => ['required', 'integer', 'min:1', 'max:5'],
'face' => ['required', 'integer', 'min:1', 'max:3'],
'beard' => ['required', 'integer', 'min:0', 'max:2'],
'accessory' => ['required', 'integer', 'min:0', 'max:2'],
]);
$me->forceFill($data)->save();
return response()->json([
'message' => __('city.customize_saved'),
'appearance' => $data,
]);
}
}
@@ -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(),
];
}
@@ -0,0 +1,180 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Character;
use App\Models\InventoryItem;
use App\Models\MarketListing;
use App\Models\Mission;
use App\Models\Transaction;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Marketplace between citizens and mission journal endpoints.
*/
class CityMarketController extends Controller
{
/**
* Active listings in the caller's city plus own listings.
*/
public function index(Request $request): JsonResponse
{
$me = Character::ensureForUser($request->user());
$listings = MarketListing::query()
->where('city_id', $me->city_id)
->where('is_active', true)
->with('seller:id,name')
->orderByDesc('id')
->limit(50)
->get()
->map(fn (MarketListing $l): array => [
'id' => $l->id,
'item' => $l->item,
'qty' => (int) $l->qty,
'unit_price' => (float) $l->unit_price,
'seller_id' => $l->seller_character_id,
'seller_name' => $l->seller?->name,
'is_mine' => (int) $l->seller_character_id === (int) $me->id,
]);
$missions = Mission::query()
->where('city_id', $me->city_id)
->where('is_active', true)
->get()
->map(function (Mission $m) use ($me) {
$pivot = DB::table('character_missions')
->where('character_id', $me->id)
->where('mission_id', $m->id)
->first();
return [
'id' => $m->id,
'code' => $m->code,
'title' => $m->title,
'description' => $m->description,
'target' => (int) $m->target,
'reward' => (float) $m->reward,
'progress' => (int) ($pivot->progress ?? 0),
'is_completed' => (bool) ($pivot->is_completed ?? false),
];
});
return response()->json([
'listings' => $listings,
'missions' => $missions,
'money' => (float) $me->money,
'inventory' => $me->inventory()->get()->mapWithKeys(fn ($i) => [$i->item => (int) $i->qty])->all(),
]);
}
/**
* Publish inventory for sale.
*/
public function sell(Request $request): JsonResponse
{
$me = Character::ensureForUser($request->user());
$data = $request->validate([
'item' => ['required', 'in:food,water,materials,product'],
'qty' => ['required', 'integer', 'min:1', 'max:100'],
'unit_price' => ['required', 'numeric', 'min:0.5', 'max:1000'],
]);
$row = InventoryItem::query()
->where('character_id', $me->id)
->where('item', $data['item'])
->first();
if ($row === null || (int) $row->qty < (int) $data['qty']) {
return response()->json(['message' => __('city.no_inventory')], 422);
}
DB::transaction(function () use ($me, $data, $row): void {
$row->qty = (int) $row->qty - (int) $data['qty'];
$row->save();
MarketListing::create([
'seller_character_id' => $me->id,
'city_id' => $me->city_id,
'item' => $data['item'],
'qty' => (int) $data['qty'],
'unit_price' => (float) $data['unit_price'],
'is_active' => true,
]);
});
return response()->json(['message' => __('city.market_listed')]);
}
/**
* Purchase a listing from another citizen.
*/
public function buy(Request $request): JsonResponse
{
$me = Character::ensureForUser($request->user());
$data = $request->validate([
'listing_id' => ['required', 'integer', 'exists:market_listings,id'],
'qty' => ['nullable', 'integer', 'min:1', 'max:100'],
]);
$listing = MarketListing::query()->findOrFail($data['listing_id']);
if (! $listing->is_active || (int) $listing->seller_character_id === (int) $me->id) {
return response()->json(['message' => __('city.market_unavailable')], 422);
}
$qty = min((int) ($data['qty'] ?? $listing->qty), (int) $listing->qty);
$total = round($listing->unit_price * $qty, 2);
if ((float) $me->money < $total) {
return response()->json(['message' => __('city.no_money')], 422);
}
DB::transaction(function () use ($me, $listing, $qty, $total): void {
$seller = Character::query()->lockForUpdate()->findOrFail($listing->seller_character_id);
$me->money = round((float) $me->money - $total, 2);
$me->save();
$seller->money = round((float) $seller->money + $total, 2);
$seller->save();
$row = InventoryItem::query()
->where('character_id', $me->id)
->where('item', $listing->item)
->first();
if ($row) {
$row->qty = (int) $row->qty + $qty;
$row->save();
} else {
InventoryItem::create([
'character_id' => $me->id,
'item' => $listing->item,
'qty' => $qty,
]);
}
$listing->qty = (int) $listing->qty - $qty;
if ($listing->qty <= 0) {
$listing->is_active = false;
}
$listing->save();
Transaction::create([
'from_character_id' => $me->id,
'to_character_id' => $seller->id,
'item' => $listing->item,
'qty' => $qty,
'amount' => $total,
]);
});
$me->refresh();
return response()->json([
'message' => __('city.market_bought'),
'money' => (float) $me->money,
'inventory' => $me->inventory()->get()->mapWithKeys(fn ($i) => [$i->item => (int) $i->qty])->all(),
]);
}
}
@@ -0,0 +1,68 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Character;
use App\Models\City;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
/**
* Lightweight multiplayer presence feed.
*
* Returns the positions and states of characters active in a city so the
* viewer can render other citizens in near-real-time without WebSockets.
*/
class CityPresenceController extends Controller
{
/**
* Snapshot of citizens in the requested (or default) city.
*/
public function __invoke(Request $request): JsonResponse
{
$user = $request->user();
abort_unless($user !== null, 401);
$me = Character::ensureForUser($user);
$slug = $request->query('city');
$city = $me->city;
if (is_string($slug) && $slug !== '') {
$city = City::query()
->where('slug', $slug)
->orWhere('id', ctype_digit($slug) ? (int) $slug : 0)
->first() ?? $city;
}
$cacheKey = 'presence.'.$city->id.'.'.now()->timestamp;
$citizens = Cache::remember($cacheKey, 1, function () use ($city, $me) {
return Character::query()
->where('city_id', $city->id)
->where(function ($q): void {
$q->where('is_online', true)
->orWhere('updated_at', '>=', now()->subMinutes(10));
})
->with('user:id,name')
->get()
->map(fn (Character $c): array => [
'id' => $c->id,
'name' => $c->name,
'type' => $c->user_id === null ? 'child' : 'citizen',
'status' => $c->state,
'position' => [(float) $c->pos_x, (float) $c->pos_y, (float) $c->pos_z],
'is_online' => (bool) $c->is_online,
'is_me' => (int) $c->id === (int) $me->id,
'owner_name' => $c->user?->name,
])
->values()
->all();
});
return response()->json([
'city_id' => $city->id,
'server_time' => now()->toIso8601String(),
'citizens' => $citizens,
])->header('Cache-Control', 'no-store');
}
}