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:
Binary file not shown.
|
After Width: | Height: | Size: 277 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 202 KiB |
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace App\Console\Commands;
|
|||||||
|
|
||||||
use App\Models\Building;
|
use App\Models\Building;
|
||||||
use App\Models\Character;
|
use App\Models\Character;
|
||||||
|
use App\Support\MissionTracker;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
@@ -102,6 +103,12 @@ class CityTick extends Command
|
|||||||
return;
|
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;
|
$elapsedMin = 1.0;
|
||||||
if ($character->last_tick_at !== null) {
|
if ($character->last_tick_at !== null) {
|
||||||
$elapsedMin = max(0.5, $character->last_tick_at->diffInMinutes($now));
|
$elapsedMin = max(0.5, $character->last_tick_at->diffInMinutes($now));
|
||||||
@@ -124,7 +131,9 @@ class CityTick extends Command
|
|||||||
case Character::STATE_WORKING:
|
case Character::STATE_WORKING:
|
||||||
if ($character->job_id !== null) {
|
if ($character->job_id !== null) {
|
||||||
$salary = (float) ($character->job?->salary ?? 0);
|
$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));
|
$character->energy = max(0, (int) $character->energy - (int) round(1.2 * $elapsedMin));
|
||||||
break;
|
break;
|
||||||
@@ -132,6 +141,7 @@ class CityTick extends Command
|
|||||||
case Character::STATE_STUDYING:
|
case Character::STATE_STUDYING:
|
||||||
$character->education = min(100, (int) $character->education + (int) round(0.5 * $elapsedMin));
|
$character->education = min(100, (int) $character->education + (int) round(0.5 * $elapsedMin));
|
||||||
$character->energy = max(0, (int) $character->energy - (int) round(0.8 * $elapsedMin));
|
$character->energy = max(0, (int) $character->energy - (int) round(0.8 * $elapsedMin));
|
||||||
|
MissionTracker::sync($character, 'education');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Character::STATE_EXERCISING:
|
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\Job;
|
||||||
use App\Models\Transaction;
|
use App\Models\Transaction;
|
||||||
use App\Support\CityPlazas;
|
use App\Support\CityPlazas;
|
||||||
|
use App\Support\MissionTracker;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -100,10 +101,69 @@ class CityActionController extends Controller
|
|||||||
'apply-job' => $this->applyJob($request, $character),
|
'apply-job' => $this->applyJob($request, $character),
|
||||||
'exercise' => $this->exercise($request, $character),
|
'exercise' => $this->exercise($request, $character),
|
||||||
'stop-exercise' => $this->stopExercise($character),
|
'stop-exercise' => $this->stopExercise($character),
|
||||||
|
'ride' => $this->ride($request, $character),
|
||||||
default => response()->json(['message' => __('city.action_unknown')], 422),
|
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.
|
* One exercise rep on nearby plaza gym equipment: +health, −energy.
|
||||||
*
|
*
|
||||||
@@ -155,9 +215,12 @@ class CityActionController extends Controller
|
|||||||
'pos_z' => $z,
|
'pos_z' => $z,
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
|
$missionDones = MissionTracker::sync($character, 'exercise_count');
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => __('city.exercise_ok'),
|
'message' => __('city.exercise_ok'),
|
||||||
'equipment' => $nearby,
|
'equipment' => $nearby,
|
||||||
|
'missions_completed' => $missionDones,
|
||||||
'character' => $this->characterData($character),
|
'character' => $this->characterData($character),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -476,6 +539,8 @@ class CityActionController extends Controller
|
|||||||
'build_progress' => 0,
|
'build_progress' => 0,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
MissionTracker::sync($character->fresh(), 'buildings');
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => __('city.build_started'),
|
'message' => __('city.build_started'),
|
||||||
'building' => [
|
'building' => [
|
||||||
@@ -569,6 +634,14 @@ class CityActionController extends Controller
|
|||||||
'pos_x' => (float) $character->pos_x,
|
'pos_x' => (float) $character->pos_x,
|
||||||
'pos_z' => (float) $character->pos_z,
|
'pos_z' => (float) $character->pos_z,
|
||||||
'hospitalized_until' => $character->hospitalized_until?->toIso8601String(),
|
'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(),
|
'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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,7 +20,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
'user_id', 'city_id', 'name', 'skin', 'hair', 'shirt',
|
'user_id', 'city_id', 'name', 'skin', 'hair', 'shirt',
|
||||||
'pos_x', 'pos_y', 'pos_z', 'money', 'energy', 'hunger', 'thirst', 'health',
|
'pos_x', 'pos_y', 'pos_z', 'money', 'energy', 'hunger', 'thirst', 'health',
|
||||||
'state', 'job_id', 'house_id', 'education', 'exercise_count', 'is_online', 'last_tick_at',
|
'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
|
class Character extends Model
|
||||||
{
|
{
|
||||||
@@ -116,6 +117,28 @@ class Character extends Model
|
|||||||
return $this->hasMany(Building::class, 'owner_character_id');
|
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.
|
* Inventory stacks owned by this character.
|
||||||
*
|
*
|
||||||
@@ -168,6 +191,9 @@ class Character extends Model
|
|||||||
'education' => 0,
|
'education' => 0,
|
||||||
'exercise_count' => 0,
|
'exercise_count' => 0,
|
||||||
'is_online' => false,
|
'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.
|
// Starter kit so new citizens can eat, drink and build immediately.
|
||||||
@@ -207,6 +233,10 @@ class Character extends Model
|
|||||||
'is_online' => 'boolean',
|
'is_online' => 'boolean',
|
||||||
'last_tick_at' => 'datetime',
|
'last_tick_at' => 'datetime',
|
||||||
'hospitalized_until' => 'datetime',
|
'hospitalized_until' => 'datetime',
|
||||||
|
'face' => 'integer',
|
||||||
|
'beard' => 'integer',
|
||||||
|
'accessory' => 'integer',
|
||||||
|
'ride_until' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ use App\Models\Building;
|
|||||||
use App\Models\Character;
|
use App\Models\Character;
|
||||||
use App\Models\City;
|
use App\Models\City;
|
||||||
use App\Models\Job;
|
use App\Models\Job;
|
||||||
|
use App\Models\Mission;
|
||||||
use App\Models\Store;
|
use App\Models\Store;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Database\Seeder;
|
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)
|
User::query()->where('is_active', true)->where('status', '!=', User::STATUS_ACTIVE)
|
||||||
->update(['status' => User::STATUS_ACTIVE]);
|
->update(['status' => User::STATUS_ACTIVE]);
|
||||||
User::query()->where('is_active', false)->where('status', User::STATUS_PENDING)
|
User::query()->where('is_active', false)->where('status', User::STATUS_PENDING)
|
||||||
|
|||||||
+28
-1
@@ -217,5 +217,32 @@
|
|||||||
"city.exercise_max_health": "Health is already at maximum!",
|
"city.exercise_max_health": "Health is already at maximum!",
|
||||||
"city.exerciseStart": "Starting exercise…",
|
"city.exerciseStart": "Starting exercise…",
|
||||||
"city.exerciseStop": "Exercise stopped.",
|
"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
@@ -217,5 +217,32 @@
|
|||||||
"city.exercise_max_health": "¡La salud ya está en el máximo!",
|
"city.exercise_max_health": "¡La salud ya está en el máximo!",
|
||||||
"city.exerciseStart": "Empezando a ejercitarse…",
|
"city.exerciseStart": "Empezando a ejercitarse…",
|
||||||
"city.exerciseStop": "Ejercicio terminado.",
|
"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
@@ -217,5 +217,32 @@
|
|||||||
"city.exercise_max_health": "Saúde já está no máximo!",
|
"city.exercise_max_health": "Saúde já está no máximo!",
|
||||||
"city.exerciseStart": "Começando a se exercitar…",
|
"city.exerciseStart": "Começando a se exercitar…",
|
||||||
"city.exerciseStop": "Exercício encerrado.",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,6 +203,12 @@ let exercise = {
|
|||||||
animPhase: 0,
|
animPhase: 0,
|
||||||
nearId: null,
|
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) {
|
function clearGroup(g) {
|
||||||
while (g.children.length) {
|
while (g.children.length) {
|
||||||
@@ -210,6 +216,8 @@ function clearGroup(g) {
|
|||||||
c.traverse?.(o => { o.geometry?.dispose?.(); o.material?.dispose?.(); });
|
c.traverse?.(o => { o.geometry?.dispose?.(); o.material?.dispose?.(); });
|
||||||
}
|
}
|
||||||
citizenMeshes = [];
|
citizenMeshes = [];
|
||||||
|
remoteCitizens = new Map();
|
||||||
|
vehicleHeadlights = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function addCollider(cx, cy, cz, w, h, d) {
|
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['KeyS'] || keys['ArrowDown']) wish.sub(forward);
|
||||||
if (keys['KeyA'] || keys['ArrowLeft']) wish.sub(right);
|
if (keys['KeyA'] || keys['ArrowLeft']) wish.sub(right);
|
||||||
if (keys['KeyD'] || keys['ArrowRight']) wish.add(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) {
|
if (wish.lengthSq() > 0) {
|
||||||
wish.normalize();
|
wish.normalize();
|
||||||
@@ -3387,7 +3400,9 @@ function animate() {
|
|||||||
updateExercise(dt);
|
updateExercise(dt);
|
||||||
updateCameraFromPlayer();
|
updateCameraFromPlayer();
|
||||||
updateHUD();
|
updateHUD();
|
||||||
|
updateVehicleHeadlights();
|
||||||
syncPositionThrottled();
|
syncPositionThrottled();
|
||||||
|
drawMinimap();
|
||||||
|
|
||||||
// Footsteps while the local player is actually walking.
|
// Footsteps while the local player is actually walking.
|
||||||
const walking = locked && (keys['KeyW'] || keys['KeyS'] || keys['KeyA'] || keys['KeyD']
|
const walking = locked && (keys['KeyW'] || keys['KeyS'] || keys['KeyA'] || keys['KeyD']
|
||||||
@@ -3404,6 +3419,411 @@ function animate() {
|
|||||||
}
|
}
|
||||||
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
|
// Test/debug API
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
@@ -3517,6 +3937,9 @@ window.__city10 = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
action: runAction,
|
action: runAction,
|
||||||
|
presence: { poll: pollPresence, remotes: () => remoteCitizens.size, lastOk: () => lastPresenceOk },
|
||||||
|
minimap: () => !!minimapCtx,
|
||||||
|
joystickBound: () => !!document.getElementById('joystick-base'),
|
||||||
agentPositions() {
|
agentPositions() {
|
||||||
return agentMeshes.map(m => ({
|
return agentMeshes.map(m => ({
|
||||||
id: m.userData?.agent?.id ?? null,
|
id: m.userData?.agent?.id ?? null,
|
||||||
@@ -3544,6 +3967,9 @@ async function loadCity() {
|
|||||||
updateDayNight();
|
updateDayNight();
|
||||||
loadingEl.style.display = 'none';
|
loadingEl.style.display = 'none';
|
||||||
window.__city10.ready = true;
|
window.__city10.ready = true;
|
||||||
|
initVehicleHeadlights();
|
||||||
|
startPresenceLoop();
|
||||||
|
refreshMarketPanel();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
loadingEl.style.display = 'none';
|
loadingEl.style.display = 'none';
|
||||||
@@ -3558,6 +3984,9 @@ reloadBtn.addEventListener('click', () => {
|
|||||||
});
|
});
|
||||||
bindCharacterUI();
|
bindCharacterUI();
|
||||||
bindAudioUI();
|
bindAudioUI();
|
||||||
|
bindWorldUI();
|
||||||
|
initMinimap();
|
||||||
|
bindMobileJoystick();
|
||||||
|
|
||||||
// Unlock audio on the first gesture anywhere (autoplay policy).
|
// Unlock audio on the first gesture anywhere (autoplay policy).
|
||||||
const unlockAudio = () => {
|
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
|
||||||
@@ -27,6 +27,43 @@
|
|||||||
{{ __('city.error') }}
|
{{ __('city.error') }}
|
||||||
</div>
|
</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 --}}
|
{{-- Pedestrian red-light wait banner --}}
|
||||||
<div id="ped-wait"
|
<div id="ped-wait"
|
||||||
style="display:none; position:absolute; bottom:4.2rem; left:50%; transform:translateX(-50%);
|
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="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="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" 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>
|
||||||
|
|
||||||
<div style="margin-top:.6rem; color:#fff; font-weight:600;">{{ __('city.hud_jobs') }}</div>
|
<div style="margin-top:.6rem; color:#fff; font-weight:600;">{{ __('city.hud_jobs') }}</div>
|
||||||
|
|||||||
@@ -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.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.citizens.index') }}">{{ __('nav.citizens') }}</a>
|
||||||
<a href="{{ route('admin.cities.index') }}">{{ __('nav.cities') }}</a>
|
<a href="{{ route('admin.cities.index') }}">{{ __('nav.cities') }}</a>
|
||||||
|
<a href="{{ route('admin.world.index') }}">{{ __('nav.world') }}</a>
|
||||||
@endif
|
@endif
|
||||||
<form method="POST" action="{{ route('logout') }}">
|
<form method="POST" action="{{ route('logout') }}">
|
||||||
@csrf
|
@csrf
|
||||||
|
|||||||
+37
-5
@@ -1,13 +1,17 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\Web\AccountController;
|
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\CitizenAdminController;
|
||||||
use App\Http\Controllers\Web\Admin\CityAdminController;
|
use App\Http\Controllers\Web\Admin\CityAdminController;
|
||||||
use App\Http\Controllers\Web\Admin\StoreAdminController;
|
use App\Http\Controllers\Web\Admin\StoreAdminController;
|
||||||
use App\Http\Controllers\Web\AuthController;
|
use App\Http\Controllers\Web\AuthController;
|
||||||
use App\Http\Controllers\Web\CharacterChatController;
|
use App\Http\Controllers\Web\CharacterChatController;
|
||||||
|
use App\Http\Controllers\Web\CharacterCustomizeController;
|
||||||
use App\Http\Controllers\Web\ChatController;
|
use App\Http\Controllers\Web\ChatController;
|
||||||
use App\Http\Controllers\Web\CityActionController;
|
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\CitySocialController;
|
||||||
use App\Http\Controllers\Web\CityViewerController;
|
use App\Http\Controllers\Web\CityViewerController;
|
||||||
use App\Http\Controllers\Web\DashboardController;
|
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');
|
Route::get('/city/data', [CityViewerController::class, 'data'])->name('city.data');
|
||||||
|
|
||||||
// Citizen lifecycle actions inside the world.
|
// Citizen lifecycle actions inside the world.
|
||||||
Route::post('/city/actions/position', [CityActionController::class, 'position'])->name('city.actions.position');
|
Route::post('/city/actions/position', [CityActionController::class, 'position'])
|
||||||
Route::post('/city/actions/{action}', [CityActionController::class, 'handle'])->name('city.actions.handle');
|
->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.
|
// 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::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');
|
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).
|
// 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');
|
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::get('/cities', [CityAdminController::class, 'index'])->name('cities.index');
|
||||||
Route::post('/cities', [CityAdminController::class, 'store'])->name('cities.store');
|
Route::post('/cities', [CityAdminController::class, 'store'])->name('cities.store');
|
||||||
Route::post('/cities/{city}/expand', [CityAdminController::class, 'expand'])->name('cities.expand');
|
Route::post('/cities/{city}/expand', [CityAdminController::class, 'expand'])->name('cities.expand');
|
||||||
|
|
||||||
|
Route::get('/world', [AdminWorldController::class, 'index'])->name('world.index');
|
||||||
});
|
});
|
||||||
|
|||||||
Executable
+11
@@ -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"
|
||||||
Executable
+9
@@ -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"
|
||||||
Regular → Executable
Regular → Executable
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user