diff --git a/.playwright-mcp/city10-minimap-moving.png b/.playwright-mcp/city10-minimap-moving.png new file mode 100644 index 0000000..f47aa55 Binary files /dev/null and b/.playwright-mcp/city10-minimap-moving.png differ diff --git a/.playwright-mcp/city10-world-features.png b/.playwright-mcp/city10-world-features.png new file mode 100644 index 0000000..3b5ed7b Binary files /dev/null and b/.playwright-mcp/city10-world-features.png differ diff --git a/app/Console/Commands/CityReport.php b/app/Console/Commands/CityReport.php new file mode 100644 index 0000000..a85ac68 --- /dev/null +++ b/app/Console/Commands/CityReport.php @@ -0,0 +1,56 @@ +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; + } +} diff --git a/app/Console/Commands/CityTick.php b/app/Console/Commands/CityTick.php index 792d064..cb24759 100755 --- a/app/Console/Commands/CityTick.php +++ b/app/Console/Commands/CityTick.php @@ -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: diff --git a/app/Http/Controllers/Web/Admin/AdminWorldController.php b/app/Http/Controllers/Web/Admin/AdminWorldController.php new file mode 100644 index 0000000..0ecce5c --- /dev/null +++ b/app/Http/Controllers/Web/Admin/AdminWorldController.php @@ -0,0 +1,81 @@ +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(), + ], + ]; + } +} diff --git a/app/Http/Controllers/Web/CharacterCustomizeController.php b/app/Http/Controllers/Web/CharacterCustomizeController.php new file mode 100644 index 0000000..c88398d --- /dev/null +++ b/app/Http/Controllers/Web/CharacterCustomizeController.php @@ -0,0 +1,55 @@ +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, + ]); + } +} diff --git a/app/Http/Controllers/Web/CityActionController.php b/app/Http/Controllers/Web/CityActionController.php index cccb711..10ffc04 100755 --- a/app/Http/Controllers/Web/CityActionController.php +++ b/app/Http/Controllers/Web/CityActionController.php @@ -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(), ]; } diff --git a/app/Http/Controllers/Web/CityMarketController.php b/app/Http/Controllers/Web/CityMarketController.php new file mode 100644 index 0000000..6f3ff82 --- /dev/null +++ b/app/Http/Controllers/Web/CityMarketController.php @@ -0,0 +1,180 @@ +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(), + ]); + } +} diff --git a/app/Http/Controllers/Web/CityPresenceController.php b/app/Http/Controllers/Web/CityPresenceController.php new file mode 100644 index 0000000..5b6e563 --- /dev/null +++ b/app/Http/Controllers/Web/CityPresenceController.php @@ -0,0 +1,68 @@ +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'); + } +} diff --git a/app/Models/Character.php b/app/Models/Character.php index a667f8b..2feb2a0 100755 --- a/app/Models/Character.php +++ b/app/Models/Character.php @@ -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 + */ + public function listings(): HasMany + { + return $this->hasMany(MarketListing::class, 'seller_character_id'); + } + + /** + * Missions this character is tracking. + * + * @return BelongsToMany + */ + 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', ]; } diff --git a/app/Models/MarketListing.php b/app/Models/MarketListing.php new file mode 100644 index 0000000..7222e80 --- /dev/null +++ b/app/Models/MarketListing.php @@ -0,0 +1,48 @@ + + */ + public function seller(): BelongsTo + { + return $this->belongsTo(Character::class, 'seller_character_id'); + } + + /** + * City where the listing is visible. + * + * @return BelongsTo + */ + public function city(): BelongsTo + { + return $this->belongsTo(City::class); + } + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'unit_price' => 'float', + 'qty' => 'integer', + 'is_active' => 'boolean', + ]; + } +} diff --git a/app/Models/Mission.php b/app/Models/Mission.php new file mode 100644 index 0000000..5dae167 --- /dev/null +++ b/app/Models/Mission.php @@ -0,0 +1,40 @@ + + */ + 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 + */ + protected function casts(): array + { + return [ + 'reward' => 'float', + 'target' => 'integer', + 'is_active' => 'boolean', + ]; + } +} diff --git a/app/Support/MissionTracker.php b/app/Support/MissionTracker.php new file mode 100644 index 0000000..4401f45 --- /dev/null +++ b/app/Support/MissionTracker.php @@ -0,0 +1,110 @@ + + */ + 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 + */ + 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; + } +} diff --git a/database/migrations/2026_10_18_003000_create_market_listings_table.php b/database/migrations/2026_10_18_003000_create_market_listings_table.php new file mode 100644 index 0000000..20628b2 --- /dev/null +++ b/database/migrations/2026_10_18_003000_create_market_listings_table.php @@ -0,0 +1,41 @@ +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'); + } +}; diff --git a/database/migrations/2026_10_18_003100_create_missions_table.php b/database/migrations/2026_10_18_003100_create_missions_table.php new file mode 100644 index 0000000..88d11a8 --- /dev/null +++ b/database/migrations/2026_10_18_003100_create_missions_table.php @@ -0,0 +1,53 @@ +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'); + } +}; diff --git a/database/migrations/2026_10_18_003200_add_cosmetics_and_ride_to_characters_table.php b/database/migrations/2026_10_18_003200_add_cosmetics_and_ride_to_characters_table.php new file mode 100644 index 0000000..14c3157 --- /dev/null +++ b/database/migrations/2026_10_18_003200_add_cosmetics_and_ride_to_characters_table.php @@ -0,0 +1,38 @@ +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']); + }); + } +}; diff --git a/database/seeders/CityWorldSeeder.php b/database/seeders/CityWorldSeeder.php index f218436..5febd11 100755 --- a/database/seeders/CityWorldSeeder.php +++ b/database/seeders/CityWorldSeeder.php @@ -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) diff --git a/lang/en.json b/lang/en.json index c51cdcf..671cc61 100755 --- a/lang/en.json +++ b/lang/en.json @@ -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" } diff --git a/lang/es.json b/lang/es.json index baf748d..0edbff0 100755 --- a/lang/es.json +++ b/lang/es.json @@ -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" } diff --git a/lang/pt_BR.json b/lang/pt_BR.json index 9218b96..e46b1e4 100755 --- a/lang/pt_BR.json +++ b/lang/pt_BR.json @@ -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" } diff --git a/public/js/city/app.js b/public/js/city/app.js index 5f80aa9..10fa9f9 100755 --- a/public/js/city/app.js +++ b/public/js/city/app.js @@ -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 => ` +
+ ${l.qty}× ${l.item} · $${Number(l.unit_price).toFixed(2)} (${l.seller_name}) + ${l.is_mine ? '' : ``} +
`).join('') + : `
—
`; + 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 => ` +
+ ${m.title} +
${m.description}
+
${m.progress}/${m.target} · $${Number(m.reward).toFixed(2)} + ${m.is_completed ? '✓' : ''}
+
`).join('') || '
—
'; + } + } 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 = () => { diff --git a/resources/views/admin/world/index.blade.php b/resources/views/admin/world/index.blade.php new file mode 100644 index 0000000..30d09df --- /dev/null +++ b/resources/views/admin/world/index.blade.php @@ -0,0 +1,43 @@ +@extends('layouts.app') + +@section('title', __('admin.world.title')) + +@section('content') +
+
+

🌍 {{ __('admin.world.title') }}

+

{{ __('admin.world.hint') }}

+
+ +
+ @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) +
+
{{ $card['title'] }}
+
{{ $card['value'] }}
+
{{ $card['sub'] }}
+
+ @endforeach +
+ +
+

+ php artisan city:report · + {{ __('nav.citizens') }} · + {{ __('nav.cities') }} +

+
+
+@endsection \ No newline at end of file diff --git a/resources/views/city/viewer.blade.php b/resources/views/city/viewer.blade.php index 40bd053..2c83798 100755 --- a/resources/views/city/viewer.blade.php +++ b/resources/views/city/viewer.blade.php @@ -27,6 +27,43 @@ {{ __('city.error') }} + {{-- Minimap --}} +
+ +
+ ● você + ● jogadores + ● pedestres
+ ■ carros + ■ motos + ■ bikes + ■ ônibus
+ ■ lojas + ● praças +
+
+ + {{-- Virtual joystick (mobile) --}} + + + {{-- Pedestrian red-light wait banner --}} + + {{-- Marketplace --}} + + + {{-- Appearance customization --}} + + + {{-- Missions journal --}} +
+
📜 {{ __('admin.world.missions') }}
+
{{ __('city.hud_jobs') }}
diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index bc69b1d..aefcb33 100755 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -111,6 +111,7 @@ {{ __('nav.admin') }} {{ __('nav.citizens') }} {{ __('nav.cities') }} + {{ __('nav.world') }} @endif
@csrf diff --git a/routes/web.php b/routes/web.php index 9fb662f..d23093f 100755 --- a/routes/web.php +++ b/routes/web.php @@ -1,13 +1,17 @@ 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'); }); diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100755 index 0000000..190f183 --- /dev/null +++ b/scripts/backup.sh @@ -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" diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..5927ab0 --- /dev/null +++ b/scripts/deploy.sh @@ -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" diff --git a/tests/Feature/HospitalAdmissionTest.php b/tests/Feature/HospitalAdmissionTest.php old mode 100644 new mode 100755 diff --git a/tests/Feature/PlazaExerciseTest.php b/tests/Feature/PlazaExerciseTest.php old mode 100644 new mode 100755 diff --git a/tests/Feature/WorldFeaturesTest.php b/tests/Feature/WorldFeaturesTest.php new file mode 100644 index 0000000..2b45c1e --- /dev/null +++ b/tests/Feature/WorldFeaturesTest.php @@ -0,0 +1,177 @@ +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); + } +}