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
Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Console\Commands;
use App\Http\Controllers\Web\Admin\AdminWorldController;
use Illuminate\Console\Command;
/**
* Prints a one-shot world metrics report for operators and CI logs.
*/
class CityReport extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'city:report';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Print world metrics (citizens, economy, market, missions)';
/**
* Execute the console command.
*/
public function handle(): int
{
$metrics = app(AdminWorldController::class)->metrics();
$this->info('City10 world report');
$this->line('Users: '.$metrics['users']['total']
.' (pending '.$metrics['users']['pending']
.', active '.$metrics['users']['active']
.', banned '.$metrics['users']['banned'].')');
$this->line('Cities: '.$metrics['cities']);
$this->line('Characters: '.$metrics['characters']['total']
.' (online '.$metrics['characters']['online']
.', hospital '.$metrics['characters']['hospitalized']
.', avg health '.$metrics['characters']['avg_health']
.', money '.$metrics['characters']['total_money'].')');
$this->line('World: '.$metrics['world']['stores'].' stores, '
.$metrics['world']['houses_complete'].' houses, '
.$metrics['world']['jobs'].' jobs, '
.$metrics['world']['messages'].' messages, '
.$metrics['world']['transaction_volume'].' coins traded');
$this->line('Market: '.$metrics['market']['listings'].' active listings');
$this->line('Missions: '.$metrics['missions']['active'].' active, '
.$metrics['missions']['completed'].' completed');
return self::SUCCESS;
}
}
+11 -1
View File
@@ -4,6 +4,7 @@ namespace App\Console\Commands;
use App\Models\Building;
use App\Models\Character;
use App\Support\MissionTracker;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
@@ -102,6 +103,12 @@ class CityTick extends Command
return;
}
// Temporary bus/train rides expire after ride_until.
if ($character->ride_kind !== null && $character->ride_until !== null
&& $character->ride_until->isPast()) {
$character->forceFill(['ride_kind' => null, 'ride_until' => null])->save();
}
$elapsedMin = 1.0;
if ($character->last_tick_at !== null) {
$elapsedMin = max(0.5, $character->last_tick_at->diffInMinutes($now));
@@ -124,7 +131,9 @@ class CityTick extends Command
case Character::STATE_WORKING:
if ($character->job_id !== null) {
$salary = (float) ($character->job?->salary ?? 0);
$character->money = round((float) $character->money + ($salary / 30) * $elapsedMin, 2);
// Education boosts take-home pay up to +50% at 100% education.
$bonus = 1 + (min(100, (int) $character->education) / 200);
$character->money = round((float) $character->money + ($salary * $bonus / 30) * $elapsedMin, 2);
}
$character->energy = max(0, (int) $character->energy - (int) round(1.2 * $elapsedMin));
break;
@@ -132,6 +141,7 @@ class CityTick extends Command
case Character::STATE_STUDYING:
$character->education = min(100, (int) $character->education + (int) round(0.5 * $elapsedMin));
$character->energy = max(0, (int) $character->energy - (int) round(0.8 * $elapsedMin));
MissionTracker::sync($character, 'education');
break;
case Character::STATE_EXERCISING:
@@ -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');
}
}
+31 -1
View File
@@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
@@ -19,7 +20,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'user_id', 'city_id', 'name', 'skin', 'hair', 'shirt',
'pos_x', 'pos_y', 'pos_z', 'money', 'energy', 'hunger', 'thirst', 'health',
'state', 'job_id', 'house_id', 'education', 'exercise_count', 'is_online', 'last_tick_at',
'hospitalized_until',
'hospitalized_until', 'face', 'beard', 'accessory', 'ride_kind', 'ride_until',
])]
class Character extends Model
{
@@ -116,6 +117,28 @@ class Character extends Model
return $this->hasMany(Building::class, 'owner_character_id');
}
/**
* Marketplace listings published by this character.
*
* @return HasMany<MarketListing, $this>
*/
public function listings(): HasMany
{
return $this->hasMany(MarketListing::class, 'seller_character_id');
}
/**
* Missions this character is tracking.
*
* @return BelongsToMany<Mission, $this>
*/
public function missions(): BelongsToMany
{
return $this->belongsToMany(Mission::class, 'character_missions')
->withPivot(['progress', 'is_completed', 'completed_at'])
->withTimestamps();
}
/**
* Inventory stacks owned by this character.
*
@@ -168,6 +191,9 @@ class Character extends Model
'education' => 0,
'exercise_count' => 0,
'is_online' => false,
'face' => random_int(1, 3),
'beard' => random_int(0, 2),
'accessory' => random_int(0, 2),
]);
// Starter kit so new citizens can eat, drink and build immediately.
@@ -207,6 +233,10 @@ class Character extends Model
'is_online' => 'boolean',
'last_tick_at' => 'datetime',
'hospitalized_until' => 'datetime',
'face' => 'integer',
'beard' => 'integer',
'accessory' => 'integer',
'ride_until' => 'datetime',
];
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Player-to-player marketplace listing.
*/
#[Fillable(['seller_character_id', 'city_id', 'item', 'qty', 'unit_price', 'is_active'])]
class MarketListing extends Model
{
/**
* Listing seller.
*
* @return BelongsTo<Character, $this>
*/
public function seller(): BelongsTo
{
return $this->belongsTo(Character::class, 'seller_character_id');
}
/**
* City where the listing is visible.
*
* @return BelongsTo<City, $this>
*/
public function city(): BelongsTo
{
return $this->belongsTo(City::class);
}
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'unit_price' => 'float',
'qty' => 'integer',
'is_active' => 'boolean',
];
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
/**
* Catalog mission that citizens can complete for a reward.
*/
#[Fillable(['city_id', 'code', 'title', 'description', 'metric', 'target', 'reward', 'is_active'])]
class Mission extends Model
{
/**
* Characters tracking this mission.
*
* @return BelongsToMany<Character, $this>
*/
public function characters(): BelongsToMany
{
return $this->belongsToMany(Character::class, 'character_missions')
->withPivot(['progress', 'is_completed', 'completed_at'])
->withTimestamps();
}
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'reward' => 'float',
'target' => 'integer',
'is_active' => 'boolean',
];
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
namespace App\Support;
use App\Models\Character;
use App\Models\Mission;
use Illuminate\Support\Facades\DB;
/**
* Tracks citizen mission progress and pays rewards on completion.
*/
class MissionTracker
{
/**
* Metric keys readable from a character snapshot.
*
* @var list<string>
*/
public const METRICS = ['exercise_count', 'buildings', 'transactions', 'education'];
/**
* Ensure the character has an active row for every city mission.
*/
public static function ensureTracking(Character $character): void
{
$missions = Mission::query()
->where('city_id', $character->city_id)
->where('is_active', true)
->get();
foreach ($missions as $mission) {
$exists = DB::table('character_missions')
->where('character_id', $character->id)
->where('mission_id', $mission->id)
->exists();
if (! $exists) {
DB::table('character_missions')->insert([
'character_id' => $character->id,
'mission_id' => $mission->id,
'progress' => 0,
'is_completed' => false,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
}
/**
* Recompute progress for a metric and complete missions that hit target.
*
* @return list<array{code: string, title: string, reward: float}>
*/
public static function sync(Character $character, string $metric): array
{
if (! in_array($metric, self::METRICS, true)) {
return [];
}
self::ensureTracking($character);
$character->refresh();
$value = (int) match ($metric) {
'exercise_count' => $character->exercise_count,
'education' => $character->education,
'buildings' => $character->buildings()->where('type', 'house')->count(),
'transactions' => DB::table('transactions')
->where('from_character_id', $character->id)
->orWhere('to_character_id', $character->id)
->count(),
default => 0,
};
$completed = [];
$rows = DB::table('character_missions')
->where('character_id', $character->id)
->where('is_completed', false)
->get();
foreach ($rows as $row) {
$mission = Mission::query()->find($row->mission_id);
if ($mission === null || $mission->metric !== $metric) {
continue;
}
$progress = min($value, (int) $mission->target);
DB::table('character_missions')->where('id', $row->id)->update([
'progress' => $progress,
'is_completed' => $progress >= (int) $mission->target,
'completed_at' => $progress >= (int) $mission->target ? now() : null,
'updated_at' => now(),
]);
if ($progress >= (int) $mission->target) {
$character->money = round((float) $character->money + (float) $mission->reward, 2);
$completed[] = [
'code' => $mission->code,
'title' => $mission->title,
'reward' => (float) $mission->reward,
];
}
}
if ($completed !== []) {
$character->save();
}
return $completed;
}
}
@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Player-to-player marketplace listings.
*
* A character lists inventory items for a fixed price; buyers purchase
* through the city market endpoint with an atomic money transfer.
*/
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('market_listings', function (Blueprint $table): void {
$table->id();
$table->foreignId('seller_character_id')->constrained('characters')->cascadeOnDelete();
$table->foreignId('city_id')->constrained('cities')->cascadeOnDelete();
$table->string('item', 40);
$table->unsignedInteger('qty')->default(1);
$table->decimal('unit_price', 10, 2);
$table->boolean('is_active')->default(true)->index();
$table->timestamps();
$table->index(['city_id', 'is_active']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('market_listings');
}
};
@@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Lightweight citizen missions (daily journal goals).
*
* Tracks progress per character and pays a money reward on completion.
*/
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('missions', function (Blueprint $table): void {
$table->id();
$table->foreignId('city_id')->constrained('cities')->cascadeOnDelete();
$table->string('code', 60)->unique();
$table->string('title', 160);
$table->string('description', 400);
$table->string('metric', 40); // exercise_count|buildings|transactions|education
$table->unsignedInteger('target')->default(1);
$table->decimal('reward', 10, 2)->default(20);
$table->boolean('is_active')->default(true)->index();
$table->timestamps();
});
Schema::create('character_missions', function (Blueprint $table): void {
$table->id();
$table->foreignId('character_id')->constrained('characters')->cascadeOnDelete();
$table->foreignId('mission_id')->constrained('missions')->cascadeOnDelete();
$table->unsignedInteger('progress')->default(0);
$table->boolean('is_completed')->default(false)->index();
$table->timestamp('completed_at')->nullable();
$table->timestamps();
$table->unique(['character_id', 'mission_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('character_missions');
Schema::dropIfExists('missions');
}
};
@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Adds cosmetic slots and ride state to characters.
*
* face/beard/accessory drive the 3D look; ride_kind/ride_until power
* temporary bus/train boarding.
*/
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('characters', function (Blueprint $table): void {
$table->unsignedTinyInteger('face')->default(1)->after('shirt');
$table->unsignedTinyInteger('beard')->default(0)->after('face');
$table->unsignedTinyInteger('accessory')->default(0)->after('beard');
$table->string('ride_kind', 20)->nullable()->after('state');
$table->timestamp('ride_until')->nullable()->after('ride_kind');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('characters', function (Blueprint $table): void {
$table->dropColumn(['face', 'beard', 'accessory', 'ride_kind', 'ride_until']);
});
}
};
+68
View File
@@ -6,6 +6,7 @@ use App\Models\Building;
use App\Models\Character;
use App\Models\City;
use App\Models\Job;
use App\Models\Mission;
use App\Models\Store;
use App\Models\User;
use Illuminate\Database\Seeder;
@@ -75,6 +76,73 @@ class CityWorldSeeder extends Seeder
],
);
// Civic buildings: school and city hall.
Building::updateOrCreate(
['city_id' => $city->id, 'type' => 'school'],
[
'owner_character_id' => null,
'name' => 'Escola Municipal',
'pos_x' => -20,
'pos_z' => 60,
'width' => 16,
'depth' => 12,
'height' => 7,
'is_complete' => true,
'build_progress' => 100,
],
);
Building::updateOrCreate(
['city_id' => $city->id, 'type' => 'city_hall'],
[
'owner_character_id' => null,
'name' => 'Prefeitura',
'pos_x' => 20,
'pos_z' => 60,
'width' => 18,
'depth' => 14,
'height' => 10,
'is_complete' => true,
'build_progress' => 100,
],
);
Mission::updateOrCreate(
['code' => 'exercise-5'],
[
'city_id' => $city->id,
'title' => 'Shape em dia',
'description' => 'Complete 5 séries de ginástica nas praças.',
'metric' => 'exercise_count',
'target' => 5,
'reward' => 25,
'is_active' => true,
],
);
Mission::updateOrCreate(
['code' => 'build-home'],
[
'city_id' => $city->id,
'title' => 'Lar doce lar',
'description' => 'Construa a sua primeira casa.',
'metric' => 'buildings',
'target' => 1,
'reward' => 50,
'is_active' => true,
],
);
Mission::updateOrCreate(
['code' => 'learn-20'],
[
'city_id' => $city->id,
'title' => 'Estudioso',
'description' => 'Alcance 20% de educação.',
'metric' => 'education',
'target' => 20,
'reward' => 30,
'is_active' => true,
],
);
User::query()->where('is_active', true)->where('status', '!=', User::STATUS_ACTIVE)
->update(['status' => User::STATUS_ACTIVE]);
User::query()->where('is_active', false)->where('status', User::STATUS_PENDING)
+28 -1
View File
@@ -217,5 +217,32 @@
"city.exercise_max_health": "Health is already at maximum!",
"city.exerciseStart": "Starting exercise…",
"city.exerciseStop": "Exercise stopped.",
"city.ped_red": "Red pedestrian light — wait for the walk signal."
"city.ped_red": "Red pedestrian light — wait for the walk signal.",
"nav.world": "World",
"admin.world.title": "World dashboard",
"admin.world.hint": "Live platform metrics for the persistent world.",
"admin.world.users": "Users",
"admin.world.characters": "Characters",
"admin.world.economy": "Economy",
"admin.world.trade_volume": "Trade volume",
"admin.world.world": "Finished houses",
"admin.world.market": "Market",
"admin.world.missions": "Missions",
"admin.world.health": "Avg health",
"admin.world.education": "Avg education",
"city.customize_saved": "Appearance updated.",
"city.market_listed": "Listing published on the market.",
"city.market_bought": "Purchase complete!",
"city.market_unavailable": "Listing unavailable.",
"city.ride_need_stop": "Get near a road (bus) or the rails (train).",
"city.ride_bus_ok": "You boarded the bus!",
"city.ride_train_ok": "You boarded the train!",
"city.missions_done": "Mission complete",
"city.act_market": "Market",
"city.act_customize": "Appearance",
"city.act_ride_bus": "Bus",
"city.act_ride_train": "Train",
"city.hud_minimap": "Map",
"city.online_now": "online",
"city.marketBuy": "Buy"
}
+28 -1
View File
@@ -217,5 +217,32 @@
"city.exercise_max_health": "¡La salud ya está en el máximo!",
"city.exerciseStart": "Empezando a ejercitarse…",
"city.exerciseStop": "Ejercicio terminado.",
"city.ped_red": "Semáforo de peatón en rojo — espere la señal verde."
"city.ped_red": "Semáforo de peatón en rojo — espere la señal verde.",
"nav.world": "Mundo",
"admin.world.title": "Panel del mundo",
"admin.world.hint": "Métricas en vivo de la plataforma persistente.",
"admin.world.users": "Usuarios",
"admin.world.characters": "Personajes",
"admin.world.economy": "Economía",
"admin.world.trade_volume": "Volumen de trueques",
"admin.world.world": "Casas terminadas",
"admin.world.market": "Mercado",
"admin.world.missions": "Misiones",
"admin.world.health": "Salud media",
"admin.world.education": "Educación media",
"city.customize_saved": "Apariencia actualizada.",
"city.market_listed": "Anuncio publicado en el mercado.",
"city.market_bought": "¡Compra realizada!",
"city.market_unavailable": "Anuncio no disponible.",
"city.ride_need_stop": "Acérquese a una vía (autobús) o a los raíles (tren).",
"city.ride_bus_ok": "¡Has subido al autobús!",
"city.ride_train_ok": "¡Has subido al tren!",
"city.missions_done": "Misión completada",
"city.act_market": "Mercado",
"city.act_customize": "Apariencia",
"city.act_ride_bus": "Autobús",
"city.act_ride_train": "Tren",
"city.hud_minimap": "Mapa",
"city.online_now": "en línea",
"city.marketBuy": "Comprar"
}
+28 -1
View File
@@ -217,5 +217,32 @@
"city.exercise_max_health": "Saúde já está no máximo!",
"city.exerciseStart": "Começando a se exercitar…",
"city.exerciseStop": "Exercício encerrado.",
"city.ped_red": "Semáforo de pedestre vermelho — aguarde o sinal verde."
"city.ped_red": "Semáforo de pedestre vermelho — aguarde o sinal verde.",
"nav.world": "Mundo",
"admin.world.title": "Painel do mundo",
"admin.world.hint": "Métricas ao vivo da plataforma persistente.",
"admin.world.users": "Usuários",
"admin.world.characters": "Personagens",
"admin.world.economy": "Economia",
"admin.world.trade_volume": "Volume de trocas",
"admin.world.world": "Casas prontas",
"admin.world.market": "Mercado",
"admin.world.missions": "Missões",
"admin.world.health": "Saúde média",
"admin.world.education": "Educação média",
"city.customize_saved": "Aparência atualizada.",
"city.market_listed": "Anúncio publicado no mercado.",
"city.market_bought": "Compra realizada!",
"city.market_unavailable": "Anúncio indisponível.",
"city.ride_need_stop": "Aproxime-se de uma via (ônibus) ou dos trilhos (trem).",
"city.ride_bus_ok": "Você embarcou no ônibus!",
"city.ride_train_ok": "Você embarcou no trem!",
"city.missions_done": "Missão concluída",
"city.act_market": "Mercado",
"city.act_customize": "Aparência",
"city.act_ride_bus": "Ônibus",
"city.act_ride_train": "Trem",
"city.hud_minimap": "Mapa",
"city.online_now": "online",
"city.marketBuy": "Comprar"
}
+429
View File
@@ -203,6 +203,12 @@ let exercise = {
animPhase: 0,
nearId: null,
};
let remoteCitizens = new Map(); // characterId -> mesh
let presenceTimer = null;
let vehicleHeadlights = [];
let mobileInput = { x: 0, y: 0, active: false };
let minimapCtx = null;
let lastPresenceOk = 0;
function clearGroup(g) {
while (g.children.length) {
@@ -210,6 +216,8 @@ function clearGroup(g) {
c.traverse?.(o => { o.geometry?.dispose?.(); o.material?.dispose?.(); });
}
citizenMeshes = [];
remoteCitizens = new Map();
vehicleHeadlights = [];
}
function addCollider(cx, cy, cz, w, h, d) {
@@ -2849,6 +2857,11 @@ function updatePlayer(dt) {
if (keys['KeyS'] || keys['ArrowDown']) wish.sub(forward);
if (keys['KeyA'] || keys['ArrowLeft']) wish.sub(right);
if (keys['KeyD'] || keys['ArrowRight']) wish.add(right);
if (mobileInput.active) {
// Joystick: +y on screen is forward for the player.
wish.addScaledVector(forward, -mobileInput.y);
wish.addScaledVector(right, mobileInput.x);
}
if (wish.lengthSq() > 0) {
wish.normalize();
@@ -3387,7 +3400,9 @@ function animate() {
updateExercise(dt);
updateCameraFromPlayer();
updateHUD();
updateVehicleHeadlights();
syncPositionThrottled();
drawMinimap();
// Footsteps while the local player is actually walking.
const walking = locked && (keys['KeyW'] || keys['KeyS'] || keys['KeyA'] || keys['KeyD']
@@ -3404,6 +3419,411 @@ function animate() {
}
animate();
// ------------------------------------------------------------------
// Multiplayer presence (other citizens in near-real-time)
// ------------------------------------------------------------------
function upsertRemoteCitizen(c) {
if (c.is_me) return;
let mesh = remoteCitizens.get(c.id);
if (!mesh) {
mesh = buildHumanoid({
name: c.name,
type: c.type === 'child' ? 'crowd' : 'pedestrian',
status: c.status || 'active',
id: c.id,
}, true);
const [cx, , cz] = c.position;
mesh.position.set(cx, 0, cz);
worldGroup.add(mesh);
crowdMeshes.push(mesh);
remoteCitizens.set(c.id, mesh);
} else {
const [cx, , cz] = c.position;
const dx = cx - mesh.position.x;
const dz = cz - mesh.position.z;
mesh.position.x = cx;
mesh.position.z = cz;
if (Math.hypot(dx, dz) > 0.05) {
mesh.rotation.y = Math.atan2(dx, dz);
animateHumanoid(mesh, 0.016, Math.min(2, Math.hypot(dx, dz) * 4));
}
}
}
async function pollPresence() {
if (!window.__city10?.ready || hospitalBusy) return;
try {
const qs = cfg.citySlug ? `?city=${encodeURIComponent(cfg.citySlug)}` : '';
const res = await fetch(`/city/presence${qs}`, {
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
cache: 'no-store',
});
if (!res.ok) return;
const data = await res.json();
const seen = new Set();
for (const c of data.citizens || []) {
if (c.is_me) continue;
seen.add(c.id);
upsertRemoteCitizen(c);
}
for (const [id, mesh] of remoteCitizens) {
if (!seen.has(id)) {
worldGroup.remove(mesh);
remoteCitizens.delete(id);
}
}
lastPresenceOk = performance.now();
} catch (_) { /* offline-safe */ }
}
function startPresenceLoop() {
if (presenceTimer) clearInterval(presenceTimer);
presenceTimer = setInterval(pollPresence, 2500);
pollPresence();
}
// ------------------------------------------------------------------
// Minimap (canvas overlay)
// ------------------------------------------------------------------
function initMinimap() {
const canvas = document.getElementById('minimap');
if (!canvas) return;
minimapCtx = canvas.getContext('2d');
}
function drawMinimap() {
if (!minimapCtx || !cityBounds) return;
const canvas = minimapCtx.canvas;
const w = canvas.width;
const h = canvas.height;
const minX = cityBounds.min_x;
const maxX = cityBounds.max_x;
const minZ = cityBounds.min_z;
const maxZ = cityBounds.max_z;
const spanX = Math.max(1, maxX - minX);
const spanZ = Math.max(1, maxZ - minZ);
const toMap = (x, z) => [
((x - minX) / spanX) * (w - 8) + 4,
((z - minZ) / spanZ) * (h - 8) + 4,
];
minimapCtx.clearRect(0, 0, w, h);
minimapCtx.fillStyle = 'rgba(11,18,32,.85)';
minimapCtx.fillRect(0, 0, w, h);
// Roads
if (grid) {
minimapCtx.strokeStyle = 'rgba(148,163,184,.45)';
minimapCtx.lineWidth = 2;
for (const vx of grid.xs) {
const [mx] = toMap(vx, minZ);
minimapCtx.beginPath();
minimapCtx.moveTo(mx, 4);
minimapCtx.lineTo(mx, h - 4);
minimapCtx.stroke();
}
for (const vz of grid.zs) {
const [, mz] = toMap(minX, vz);
minimapCtx.beginPath();
minimapCtx.moveTo(4, mz);
minimapCtx.lineTo(w - 4, mz);
minimapCtx.stroke();
}
}
// Plazas (static parks — green)
for (const p of plazas) {
const [mx, mz] = toMap(p.position[0], p.position[1]);
minimapCtx.fillStyle = 'rgba(34,197,94,.55)';
minimapCtx.beginPath();
minimapCtx.arc(mx, mz, 5, 0, Math.PI * 2);
minimapCtx.fill();
}
// Stores (static shops — orange)
for (const s of cityData?.stores || []) {
const [mx, mz] = toMap(s.position[0], s.position[2]);
minimapCtx.fillStyle = '#f59e0b';
minimapCtx.fillRect(mx - 2.5, mz - 2.5, 5, 5);
}
// NPC pedestrians (local crowd — yellow, they walk)
if (pedestrianSystem) {
minimapCtx.fillStyle = '#facc15';
for (const p of pedestrianSystem.positions()) {
const [mx, mz] = toMap(p.x, p.z);
minimapCtx.beginPath();
minimapCtx.arc(mx, mz, 1.8, 0, Math.PI * 2);
minimapCtx.fill();
}
}
// Vehicles (moving — colored by kind)
if (vehicleSystem?.vehicles) {
for (const v of vehicleSystem.vehicles) {
const p = { x: v.mesh?.position?.x, z: v.mesh?.position?.z };
if (p.x == null || p.z == null) continue;
const kind = v.kind || v.mesh?.userData?.kind || 'car';
minimapCtx.fillStyle = kind === 'moto' ? '#a78bfa'
: kind === 'bike' ? '#4ade80'
: kind === 'bus' ? '#60a5fa'
: '#f472b6';
// Player is the bright pink; other cars use a softer white-blue.
if (Math.hypot(p.x - player.pos.x, p.z - player.pos.z) < 0.5) continue;
minimapCtx.fillStyle = kind === 'moto' ? '#a78bfa'
: kind === 'bike' ? '#4ade80'
: kind === 'bus' ? '#60a5fa'
: '#94a3b8';
const [mx, mz] = toMap(p.x, p.z);
minimapCtx.fillRect(mx - 1.2, mz - 1.2, 2.4, 2.4);
}
}
// Remote citizens (online players — cyan)
minimapCtx.fillStyle = '#38bdf8';
for (const mesh of remoteCitizens.values()) {
const [mx, mz] = toMap(mesh.position.x, mesh.position.z);
minimapCtx.beginPath();
minimapCtx.arc(mx, mz, 2.8, 0, Math.PI * 2);
minimapCtx.fill();
}
// Player (you — pink with white ring)
const [px, pz] = toMap(player.pos.x, player.pos.z);
minimapCtx.fillStyle = '#f472b6';
minimapCtx.beginPath();
minimapCtx.arc(px, pz, 4, 0, Math.PI * 2);
minimapCtx.fill();
minimapCtx.strokeStyle = '#fff';
minimapCtx.lineWidth = 1.5;
minimapCtx.stroke();
}
// ------------------------------------------------------------------
// Vehicle headlights at night
// ------------------------------------------------------------------
function initVehicleHeadlights() {
for (const v of vehicleSystem?.vehicles || []) {
if (!v.mesh || v.mesh.userData.headlights) continue;
if (v.mesh.userData.kind === 'bike') continue;
const lights = [];
const halfL = v.mesh.userData.halfL ?? 2;
for (const side of [-1, 1]) {
const lamp = new THREE.Mesh(
new THREE.SphereGeometry(0.12, 8, 8),
new THREE.MeshStandardMaterial({
color: 0xfff6c8,
emissive: 0xfff2b0,
emissiveIntensity: 0,
})
);
lamp.position.set(halfL + 0.05, 0.55, side * 0.55);
v.mesh.add(lamp);
lights.push(lamp.material);
}
v.mesh.userData.headlights = lights;
vehicleHeadlights.push(...lights);
}
}
function updateVehicleHeadlights() {
const night = sim.nightFactor;
const on = night > 0.4 ? (night - 0.4) / 0.6 * 2.2 : 0.05;
for (const m of vehicleHeadlights) {
m.emissiveIntensity = on;
}
}
// ------------------------------------------------------------------
// Mobile virtual joystick
// ------------------------------------------------------------------
function bindMobileJoystick() {
const base = document.getElementById('joystick-base');
const knob = document.getElementById('joystick-knob');
if (!base || !knob) return;
let activeId = null;
const radius = 42;
const setKnob = (dx, dy) => {
const len = Math.hypot(dx, dy) || 1;
const clamped = Math.min(len, radius);
const nx = (dx / len) * clamped;
const ny = (dy / len) * clamped;
knob.style.transform = `translate(${nx}px, ${ny}px)`;
mobileInput.x = nx / radius;
mobileInput.y = ny / radius;
mobileInput.active = Math.hypot(mobileInput.x, mobileInput.y) > 0.15;
};
const center = () => {
activeId = null;
mobileInput = { x: 0, y: 0, active: false };
knob.style.transform = 'translate(0px, 0px)';
};
base.addEventListener('pointerdown', (e) => {
activeId = e.pointerId;
base.setPointerCapture(e.pointerId);
const rect = base.getBoundingClientRect();
setKnob(e.clientX - (rect.left + rect.width / 2), e.clientY - (rect.top + rect.height / 2));
e.preventDefault();
});
base.addEventListener('pointermove', (e) => {
if (activeId !== e.pointerId) return;
const rect = base.getBoundingClientRect();
setKnob(e.clientX - (rect.left + rect.width / 2), e.clientY - (rect.top + rect.height / 2));
});
base.addEventListener('pointerup', center);
base.addEventListener('pointercancel', center);
}
// ------------------------------------------------------------------
// Market / missions / customize / ride UI
// ------------------------------------------------------------------
async function refreshMarketPanel() {
const listEl = document.getElementById('market-list');
const missionEl = document.getElementById('mission-list');
if (!listEl && !missionEl) return;
try {
const res = await fetch('/city/market', { headers: { Accept: 'application/json' }, cache: 'no-store' });
if (!res.ok) return;
const data = await res.json();
if (listEl) {
listEl.innerHTML = (data.listings || []).length
? data.listings.map(l => `
<div style="display:flex; justify-content:space-between; gap:.4rem; align-items:center; margin:.25rem 0;">
<span>${l.qty}× ${l.item} · $${Number(l.unit_price).toFixed(2)} <span style="opacity:.7">(${l.seller_name})</span></span>
${l.is_mine ? '' : `<button type="button" class="btn market-buy" data-id="${l.id}" style="padding:.15rem .4rem; font-size:.7rem;">${i18n.marketBuy || 'Buy'}</button>`}
</div>`).join('')
: `<div style="opacity:.6;">—</div>`;
listEl.querySelectorAll('.market-buy').forEach(btn => {
btn.addEventListener('click', async () => {
try {
const out = await postJson('/city/market/buy', { listing_id: Number(btn.dataset.id) });
flashCharMsg(out.message || i18n.actionOk);
cityAudio.play('money');
if (out.inventory && meCharacter) {
meCharacter.money = out.money;
meCharacter.inventory = out.inventory;
updateCharacterHud(meCharacter);
}
refreshMarketPanel();
} catch (e) {
flashCharMsg(e.message, true);
}
});
});
}
if (missionEl) {
missionEl.innerHTML = (data.missions || []).map(m => `
<div style="margin:.3rem 0; ${m.is_completed ? 'opacity:.55;' : ''}">
<strong style="color:#fff;">${m.title}</strong>
<div style="font-size:.75rem;">${m.description}</div>
<div style="font-size:.75rem;">${m.progress}/${m.target} · $${Number(m.reward).toFixed(2)}
${m.is_completed ? '✓' : ''}</div>
</div>`).join('') || '<div style="opacity:.6;">—</div>';
}
} catch (_) { /* ignore */ }
}
function bindWorldUI() {
document.getElementById('btn-market')?.addEventListener('click', () => {
const panel = document.getElementById('market-panel');
if (!panel) return;
const show = panel.style.display === 'none' || panel.style.display === '';
panel.style.display = show ? 'block' : 'none';
if (show) refreshMarketPanel();
});
document.getElementById('market-sell')?.addEventListener('click', async () => {
const item = document.getElementById('market-item')?.value || 'product';
const qty = Number(document.getElementById('market-qty')?.value || 1);
const price = Number(document.getElementById('market-price')?.value || 5);
try {
const out = await postJson('/city/market/sell', { item, qty, unit_price: price });
flashCharMsg(out.message || i18n.actionOk);
cityAudio.play('money');
refreshMarketPanel();
} catch (e) {
flashCharMsg(e.message, true);
}
});
document.getElementById('btn-customize')?.addEventListener('click', async () => {
const panel = document.getElementById('customize-panel');
if (!panel) return;
const show = panel.style.display === 'none' || panel.style.display === '';
panel.style.display = show ? 'block' : 'none';
if (!show) return;
try {
const res = await fetch('/city/customize', { headers: { Accept: 'application/json' } });
if (!res.ok) return;
const data = await res.json();
['skin', 'hair', 'shirt', 'face', 'beard', 'accessory'].forEach(k => {
const el = document.getElementById(`cz-${k}`);
if (el) el.value = String(data[k]);
});
} catch (_) { /* ignore */ }
});
document.getElementById('customize-save')?.addEventListener('click', async () => {
const body = {};
for (const k of ['skin', 'hair', 'shirt', 'face', 'beard', 'accessory']) {
body[k] = Number(document.getElementById(`cz-${k}`)?.value || 1);
}
try {
const res = await fetch('/city/customize', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': csrf,
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(body),
});
const out = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(out.message || 'error');
flashCharMsg(out.message || i18n.actionOk);
cityAudio.play('ok');
loadCity();
} catch (e) {
flashCharMsg(e.message, true);
}
});
document.getElementById('btn-ride-bus')?.addEventListener('click', async () => {
try {
const out = await postJson('/city/actions/ride', {
kind: 'bus',
pos_x: player.pos.x,
pos_z: player.pos.z,
});
if (out.character) updateCharacterHud(out.character);
flashCharMsg(out.message || i18n.actionOk);
cityAudio.play('ok');
} catch (e) {
flashCharMsg(e.message, true);
}
});
document.getElementById('btn-ride-train')?.addEventListener('click', async () => {
try {
const out = await postJson('/city/actions/ride', {
kind: 'train',
pos_x: player.pos.x,
pos_z: cityBounds ? cityBounds.max_z + 14 : player.pos.z,
});
if (out.character) updateCharacterHud(out.character);
flashCharMsg(out.message || i18n.actionOk);
cityAudio.play('ok');
} catch (e) {
flashCharMsg(e.message, true);
}
});
}
// ------------------------------------------------------------------
// Test/debug API
// ------------------------------------------------------------------
@@ -3517,6 +3937,9 @@ window.__city10 = {
}
},
action: runAction,
presence: { poll: pollPresence, remotes: () => remoteCitizens.size, lastOk: () => lastPresenceOk },
minimap: () => !!minimapCtx,
joystickBound: () => !!document.getElementById('joystick-base'),
agentPositions() {
return agentMeshes.map(m => ({
id: m.userData?.agent?.id ?? null,
@@ -3544,6 +3967,9 @@ async function loadCity() {
updateDayNight();
loadingEl.style.display = 'none';
window.__city10.ready = true;
initVehicleHeadlights();
startPresenceLoop();
refreshMarketPanel();
} catch (err) {
console.error(err);
loadingEl.style.display = 'none';
@@ -3558,6 +3984,9 @@ reloadBtn.addEventListener('click', () => {
});
bindCharacterUI();
bindAudioUI();
bindWorldUI();
initMinimap();
bindMobileJoystick();
// Unlock audio on the first gesture anywhere (autoplay policy).
const unlockAudio = () => {
@@ -0,0 +1,43 @@
@extends('layouts.app')
@section('title', __('admin.world.title'))
@section('content')
<div style="max-width: 1000px; margin: 0 auto;">
<div class="card" style="margin-bottom:1.25rem;">
<h1 style="margin-top:0; color:#fff;">🌍 {{ __('admin.world.title') }}</h1>
<p style="color:var(--muted);">{{ __('admin.world.hint') }}</p>
</div>
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:1rem;">
@foreach ([
['title' => __('admin.world.users'), 'value' => $metrics['users']['total'],
'sub' => $metrics['users']['pending'].' pending · '.$metrics['users']['active'].' active · '.$metrics['users']['banned'].' banned'],
['title' => __('admin.world.characters'), 'value' => $metrics['characters']['total'],
'sub' => $metrics['characters']['online'].' online · '.$metrics['characters']['hospitalized'].' hospital'],
['title' => __('admin.world.economy'), 'value' => $metrics['characters']['total_money'],
'sub' => __('admin.world.trade_volume').': '.$metrics['world']['transaction_volume']],
['title' => __('admin.world.world'), 'value' => $metrics['world']['houses_complete'],
'sub' => $metrics['world']['stores'].' stores · '.$metrics['world']['jobs'].' jobs · '.$metrics['world']['messages'].' msgs'],
['title' => __('admin.world.market'), 'value' => $metrics['market']['listings'],
'sub' => __('admin.world.missions').': '.$metrics['missions']['completed'].' done / '.$metrics['missions']['active']],
['title' => __('admin.world.health'), 'value' => $metrics['characters']['avg_health'],
'sub' => __('admin.world.education').': '.$metrics['characters']['avg_education'].'%'],
] as $card)
<div class="card" style="margin:0;">
<div style="color:var(--muted); font-size:.85rem;">{{ $card['title'] }}</div>
<div style="color:#fff; font-size:1.8rem; font-weight:700;">{{ $card['value'] }}</div>
<div style="color:var(--muted); font-size:.8rem; margin-top:.25rem;">{{ $card['sub'] }}</div>
</div>
@endforeach
</div>
<div class="card" style="margin-top:1.25rem;">
<p style="color:var(--muted); margin:0;">
<code>php artisan city:report</code> ·
<a href="{{ route('admin.citizens.index') }}" style="color:#7dd3fc;">{{ __('nav.citizens') }}</a> ·
<a href="{{ route('admin.cities.index') }}" style="color:#7dd3fc;">{{ __('nav.cities') }}</a>
</p>
</div>
</div>
@endsection
+78
View File
@@ -27,6 +27,43 @@
{{ __('city.error') }}
</div>
{{-- Minimap --}}
<div style="position:absolute; right:.7rem; bottom:4.6rem; z-index:12;">
<canvas id="minimap" width="160" height="160"
style="display:block; border-radius:.6rem; border:1px solid var(--border);
box-shadow:0 8px 20px rgba(0,0,0,.4);"
aria-label="{{ __('city.hud_minimap') }}"></canvas>
<div style="margin-top:.3rem; font-size:.62rem; color:#cbd5e1; background:rgba(15,23,42,.8);
border:1px solid var(--border); border-radius:.4rem; padding:.25rem .4rem; line-height:1.45;">
<span style="color:#f472b6;">●</span> você
<span style="color:#38bdf8;">●</span> jogadores
<span style="color:#facc15;">●</span> pedestres<br>
<span style="color:#94a3b8;">■</span> carros
<span style="color:#a78bfa;">■</span> motos
<span style="color:#4ade80;">■</span> bikes
<span style="color:#60a5fa;">■</span> ônibus<br>
<span style="color:#f59e0b;">■</span> lojas
<span style="color:#22c55e;">●</span> praças
</div>
</div>
{{-- Virtual joystick (mobile) --}}
<div id="joystick-base"
style="display:none; position:absolute; left:1.1rem; bottom:1.4rem; z-index:14;
width:110px; height:110px; border-radius:50%;
background:rgba(15,23,42,.55); border:2px solid rgba(148,163,184,.35);
touch-action:none;">
<div id="joystick-knob"
style="position:absolute; left:50%; top:50%; width:44px; height:44px; margin:-22px 0 0 -22px;
border-radius:50%; background:rgba(56,189,248,.75); border:2px solid #e0f2fe;"></div>
</div>
<style>
@media (max-width: 820px), (pointer: coarse) {
#joystick-base { display: block !important; }
#minimap { width:120px !important; height:120px !important; }
}
</style>
{{-- Pedestrian red-light wait banner --}}
<div id="ped-wait"
style="display:none; position:absolute; bottom:4.2rem; left:50%; transform:translateX(-50%);
@@ -77,6 +114,47 @@
<button type="button" class="btn char-act" data-act="buy" style="padding:.25rem .45rem; font-size:.75rem;">{{ __('city.act_buy') }}</button>
<button type="button" class="btn char-act" data-act="sell" style="padding:.25rem .45rem; font-size:.75rem;">{{ __('city.act_sell_v') }}</button>
<button type="button" class="btn char-act" data-act="build" style="padding:.25rem .45rem; font-size:.75rem;">{{ __('city.act_build') }}</button>
<button type="button" id="btn-market" class="btn" style="padding:.25rem .45rem; font-size:.75rem;">{{ __('city.act_market') }}</button>
<button type="button" id="btn-customize" class="btn" style="padding:.25rem .45rem; font-size:.75rem;">{{ __('city.act_customize') }}</button>
<button type="button" id="btn-ride-bus" class="btn" style="padding:.25rem .45rem; font-size:.75rem;">{{ __('city.act_ride_bus') }}</button>
<button type="button" id="btn-ride-train" class="btn" style="padding:.25rem .45rem; font-size:.75rem;">{{ __('city.act_ride_train') }}</button>
</div>
{{-- Marketplace --}}
<div id="market-panel" style="display:none; margin-top:.55rem; border-top:1px solid var(--border); padding-top:.45rem;">
<div style="color:#fff; font-weight:600;">🛒 {{ __('city.act_market') }}</div>
<div id="market-list" style="max-height:120px; overflow:auto; font-size:.75rem;"></div>
<div style="display:flex; gap:.25rem; margin-top:.3rem; flex-wrap:wrap;">
<select id="market-item" style="flex:1; min-width:70px; padding:.2rem; background:#0b1220; color:var(--text); border:1px solid var(--border); border-radius:.3rem; font-size:.7rem;">
<option value="product">product</option>
<option value="food">food</option>
<option value="water">water</option>
<option value="materials">materials</option>
</select>
<input id="market-qty" type="number" min="1" max="100" value="1" style="width:48px; padding:.2rem; background:#0b1220; color:var(--text); border:1px solid var(--border); border-radius:.3rem; font-size:.7rem;">
<input id="market-price" type="number" min="0.5" step="0.5" value="5" style="width:52px; padding:.2rem; background:#0b1220; color:var(--text); border:1px solid var(--border); border-radius:.3rem; font-size:.7rem;">
<button type="button" id="market-sell" class="btn" style="padding:.2rem .4rem; font-size:.7rem;">OK</button>
</div>
</div>
{{-- Appearance customization --}}
<div id="customize-panel" style="display:none; margin-top:.55rem; border-top:1px solid var(--border); padding-top:.45rem;">
<div style="color:#fff; font-weight:600;">🎨 {{ __('city.act_customize') }}</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:.25rem; font-size:.72rem; margin-top:.3rem;">
<label>skin <select id="cz-skin"><option>1</option><option>2</option><option>3</option><option>4</option></select></label>
<label>hair <select id="cz-hair"><option>1</option><option>2</option><option>3</option><option>4</option></select></label>
<label>shirt <select id="cz-shirt"><option>1</option><option>2</option><option>3</option><option>4</option><option>5</option></select></label>
<label>face <select id="cz-face"><option>1</option><option>2</option><option>3</option></select></label>
<label>beard <select id="cz-beard"><option>0</option><option>1</option><option>2</option></select></label>
<label>acc <select id="cz-accessory"><option>0</option><option>1</option><option>2</option></select></label>
</div>
<button type="button" id="customize-save" class="btn" style="margin-top:.35rem; width:100%; padding:.3rem; font-size:.75rem;">💾</button>
</div>
{{-- Missions journal --}}
<div style="margin-top:.55rem; border-top:1px solid var(--border); padding-top:.45rem;">
<div style="color:#fff; font-weight:600;">📜 {{ __('admin.world.missions') }}</div>
<div id="mission-list" style="max-height:100px; overflow:auto; font-size:.72rem; margin-top:.25rem;"></div>
</div>
<div style="margin-top:.6rem; color:#fff; font-weight:600;">{{ __('city.hud_jobs') }}</div>
+1
View File
@@ -111,6 +111,7 @@
<a href="{{ route('admin.stores.index') }}" class="badge badge-admin" style="text-decoration:none;">{{ __('nav.admin') }}</a>
<a href="{{ route('admin.citizens.index') }}">{{ __('nav.citizens') }}</a>
<a href="{{ route('admin.cities.index') }}">{{ __('nav.cities') }}</a>
<a href="{{ route('admin.world.index') }}">{{ __('nav.world') }}</a>
@endif
<form method="POST" action="{{ route('logout') }}">
@csrf
+37 -5
View File
@@ -1,13 +1,17 @@
<?php
use App\Http\Controllers\Web\AccountController;
use App\Http\Controllers\Web\Admin\AdminWorldController;
use App\Http\Controllers\Web\Admin\CitizenAdminController;
use App\Http\Controllers\Web\Admin\CityAdminController;
use App\Http\Controllers\Web\Admin\StoreAdminController;
use App\Http\Controllers\Web\AuthController;
use App\Http\Controllers\Web\CharacterChatController;
use App\Http\Controllers\Web\CharacterCustomizeController;
use App\Http\Controllers\Web\ChatController;
use App\Http\Controllers\Web\CityActionController;
use App\Http\Controllers\Web\CityMarketController;
use App\Http\Controllers\Web\CityPresenceController;
use App\Http\Controllers\Web\CitySocialController;
use App\Http\Controllers\Web\CityViewerController;
use App\Http\Controllers\Web\DashboardController;
@@ -78,17 +82,43 @@ Route::middleware('auth')->group(function (): void {
Route::get('/city/data', [CityViewerController::class, 'data'])->name('city.data');
// Citizen lifecycle actions inside the world.
Route::post('/city/actions/position', [CityActionController::class, 'position'])->name('city.actions.position');
Route::post('/city/actions/{action}', [CityActionController::class, 'handle'])->name('city.actions.handle');
Route::post('/city/actions/position', [CityActionController::class, 'position'])
->middleware('throttle:60,1')
->name('city.actions.position');
Route::post('/city/actions/{action}', [CityActionController::class, 'handle'])
->middleware('throttle:90,1')
->name('city.actions.handle');
// Character social graph and chat.
Route::post('/city/social/{verb}', [CitySocialController::class, 'handle'])->name('city.social.handle');
Route::post('/city/social/{verb}', [CitySocialController::class, 'handle'])
->middleware('throttle:30,1')
->name('city.social.handle');
Route::get('/city/social/inbox', [CitySocialController::class, 'inbox'])->name('city.social.inbox');
Route::post('/city/messages/{character}', [CharacterChatController::class, 'send'])->name('city.messages.send');
Route::post('/city/messages/{character}', [CharacterChatController::class, 'send'])
->middleware('throttle:60,1')
->name('city.messages.send');
Route::get('/city/messages/{character}', [CharacterChatController::class, 'index'])->name('city.messages.index');
// Multiplayer presence + marketplace + journal + cosmetics.
Route::get('/city/presence', CityPresenceController::class)
->middleware('throttle:60,1')
->name('city.presence');
Route::get('/city/market', [CityMarketController::class, 'index'])->name('city.market.index');
Route::post('/city/market/sell', [CityMarketController::class, 'sell'])
->middleware('throttle:30,1')
->name('city.market.sell');
Route::post('/city/market/buy', [CityMarketController::class, 'buy'])
->middleware('throttle:30,1')
->name('city.market.buy');
Route::get('/city/customize', [CharacterCustomizeController::class, 'show'])->name('city.customize.show');
Route::put('/city/customize', [CharacterCustomizeController::class, 'update'])
->middleware('throttle:20,1')
->name('city.customize.update');
// NPC chat (SSE stream + message POST).
Route::post('/city/chat/{agent}', [ChatController::class, 'send'])->name('city.chat.send');
Route::post('/city/chat/{agent}', [ChatController::class, 'send'])
->middleware('throttle:30,1')
->name('city.chat.send');
Route::get('/city/chat/{agent}/stream', [ChatController::class, 'stream'])->name('city.chat.stream');
});
});
@@ -115,4 +145,6 @@ Route::middleware(['auth', EnsureTermsAccepted::class, EnsureUserIsAdmin::class]
Route::get('/cities', [CityAdminController::class, 'index'])->name('cities.index');
Route::post('/cities', [CityAdminController::class, 'store'])->name('cities.store');
Route::post('/cities/{city}/expand', [CityAdminController::class, 'expand'])->name('cities.expand');
Route::get('/world', [AdminWorldController::class, 'index'])->name('world.index');
});
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# City10 database backup — run on the cloud host (cron recommended daily).
set -euo pipefail
cd "$(dirname "$0")/.."
STAMP="$(date +%Y%m%d-%H%M%S)"
mkdir -p storage/app/backups
mysqldump --single-transaction --no-tablespaces \
-h"${DB_HOST:-127.0.0.1}" -P"${DB_PORT:-3306}" \
-u"${DB_USERNAME}" -p"${DB_PASSWORD}" \
"${DB_DATABASE}" | gzip > "storage/app/backups/city10-${STAMP}.sql.gz"
echo "Backup written to storage/app/backups/city10-${STAMP}.sql.gz"
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# City10 cloud deploy checklist (run on the server after git pull).
set -euo pipefail
cd "$(dirname "$0")/.."
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
echo "Deploy checks complete. Ensure cron: * * * * * php artisan schedule:run"
View File
View File
+177
View File
@@ -0,0 +1,177 @@
<?php
namespace Tests\Feature;
use App\Models\Character;
use App\Models\InventoryItem;
use App\Models\LegalDocument;
use App\Models\MarketListing;
use App\Models\Mission;
use App\Models\Transaction;
use App\Models\User;
use App\Models\UserConsent;
use App\Support\CityPlazas;
use Database\Seeders\CityWorldSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Presence, marketplace, missions, customize and ride endpoints.
*/
class WorldFeaturesTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Character $character;
/**
* Boot world fixtures.
*/
protected function setUp(): void
{
parent::setUp();
$this->seed(CityWorldSeeder::class);
$this->user = User::factory()->create(['status' => User::STATUS_ACTIVE, 'is_active' => true]);
$this->character = Character::ensureForUser($this->user);
$this->actingAs($this->user);
}
/**
* Presence lists citizens in the city.
*/
public function test_presence_returns_citizens(): void
{
$this->character->forceFill(['is_online' => true, 'pos_x' => 5, 'pos_z' => 5])->save();
$this->getJson('/city/presence')
->assertOk()
->assertJsonStructure(['city_id', 'server_time', 'citizens' => [['id', 'name', 'position', 'is_me']]]);
}
/**
* Market sell lists inventory and buy transfers money.
*/
public function test_market_sell_and_buy(): void
{
InventoryItem::updateOrCreate(
['character_id' => $this->character->id, 'item' => 'product'],
['qty' => 3],
);
$this->postJson('/city/market/sell', [
'item' => 'product',
'qty' => 2,
'unit_price' => 5,
])->assertOk();
$listing = MarketListing::firstOrFail();
$this->assertSame(1, (int) InventoryItem::where('character_id', $this->character->id)->where('item', 'product')->value('qty'));
$buyerUser = User::factory()->create(['status' => User::STATUS_ACTIVE, 'is_active' => true]);
$buyer = Character::ensureForUser($buyerUser);
$buyer->forceFill(['money' => 50])->save();
$buy = $this->actingAs($buyerUser)
->postJson('/city/market/buy', ['listing_id' => $listing->id, 'qty' => 2]);
$buy->assertOk();
$this->assertEqualsWithDelta(40.0, (float) $buy->json('money'), 0.01);
$this->assertFalse($listing->fresh()->is_active);
$this->assertSame(1, Transaction::count());
}
/**
* Completing exercise metric finishes the exercise mission with reward.
*/
public function test_mission_completes_on_exercise(): void
{
$mission = Mission::where('code', 'exercise-5')->firstOrFail();
$eq = CityPlazas::forCity($this->character->city)[0]['equipment'][0];
$this->character->forceFill([
'pos_x' => $eq['position'][0],
'pos_z' => $eq['position'][1],
'health' => 50,
'energy' => 90,
'exercise_count' => 4,
'money' => 10,
])->save();
$this->postJson('/city/actions/exercise', [
'pos_x' => $eq['position'][0],
'pos_z' => $eq['position'][1],
])->assertOk()
->assertJsonPath('character.exercise_count', 5);
$pivot = DB::table('character_missions')
->where('character_id', $this->character->id)
->where('mission_id', $mission->id)
->first();
$this->assertNotNull($pivot);
$this->assertTrue((bool) $pivot->is_completed);
$this->assertSame(10.0 + 25.0, round((float) $this->character->fresh()->money, 2));
}
/**
* Appearance customization persists safe slots.
*/
public function test_customize_updates_appearance(): void
{
$this->putJson('/city/customize', [
'skin' => 2,
'hair' => 3,
'shirt' => 4,
'face' => 1,
'beard' => 0,
'accessory' => 2,
])->assertOk()
->assertJsonPath('appearance.skin', 2);
$this->character->refresh();
$this->assertSame(3, (int) $this->character->hair);
}
/**
* Boarding a bus requires being near a road.
*/
public function test_ride_bus_requires_road(): void
{
$this->postJson('/city/actions/ride', [
'kind' => 'bus',
'pos_x' => 10,
'pos_z' => 10,
])->assertStatus(422);
$this->postJson('/city/actions/ride', [
'kind' => 'bus',
'pos_x' => 1,
'pos_z' => 0,
])->assertOk()
->assertJsonPath('character.ride_kind', 'bus');
}
/**
* Admin world dashboard metrics endpoint.
*/
public function test_admin_world_metrics(): void
{
$admin = User::factory()->admin()->create();
foreach (LegalDocument::where('is_current', true)->pluck('id') as $docId) {
UserConsent::create([
'user_id' => $admin->id,
'legal_document_id' => $docId,
'accepted_at' => now(),
'ip_address' => '127.0.0.1',
'user_agent' => 'phpunit',
'source' => 'web_form',
]);
}
$this->actingAs($admin)
->get('/admin/world')
->assertOk()
->assertSee(__('admin.world.title'), false);
}
}