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:
@@ -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\Character;
|
||||
use App\Support\MissionTracker;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
@@ -102,6 +103,12 @@ class CityTick extends Command
|
||||
return;
|
||||
}
|
||||
|
||||
// Temporary bus/train rides expire after ride_until.
|
||||
if ($character->ride_kind !== null && $character->ride_until !== null
|
||||
&& $character->ride_until->isPast()) {
|
||||
$character->forceFill(['ride_kind' => null, 'ride_until' => null])->save();
|
||||
}
|
||||
|
||||
$elapsedMin = 1.0;
|
||||
if ($character->last_tick_at !== null) {
|
||||
$elapsedMin = max(0.5, $character->last_tick_at->diffInMinutes($now));
|
||||
@@ -124,7 +131,9 @@ class CityTick extends Command
|
||||
case Character::STATE_WORKING:
|
||||
if ($character->job_id !== null) {
|
||||
$salary = (float) ($character->job?->salary ?? 0);
|
||||
$character->money = round((float) $character->money + ($salary / 30) * $elapsedMin, 2);
|
||||
// Education boosts take-home pay up to +50% at 100% education.
|
||||
$bonus = 1 + (min(100, (int) $character->education) / 200);
|
||||
$character->money = round((float) $character->money + ($salary * $bonus / 30) * $elapsedMin, 2);
|
||||
}
|
||||
$character->energy = max(0, (int) $character->energy - (int) round(1.2 * $elapsedMin));
|
||||
break;
|
||||
@@ -132,6 +141,7 @@ class CityTick extends Command
|
||||
case Character::STATE_STUDYING:
|
||||
$character->education = min(100, (int) $character->education + (int) round(0.5 * $elapsedMin));
|
||||
$character->energy = max(0, (int) $character->energy - (int) round(0.8 * $elapsedMin));
|
||||
MissionTracker::sync($character, 'education');
|
||||
break;
|
||||
|
||||
case Character::STATE_EXERCISING:
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Building;
|
||||
use App\Models\Character;
|
||||
use App\Models\CharacterMessage;
|
||||
use App\Models\City;
|
||||
use App\Models\Job;
|
||||
use App\Models\MarketListing;
|
||||
use App\Models\Mission;
|
||||
use App\Models\Store;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use App\Models\UserConsent;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Administrator world dashboard and metrics.
|
||||
*
|
||||
* Aggregates live city health: citizens, economy, constructions, missions
|
||||
* and marketplace activity so operators can steer the persistent world.
|
||||
*/
|
||||
class AdminWorldController extends Controller
|
||||
{
|
||||
/**
|
||||
* HTML dashboard for the admin area.
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
abort_unless($request->user()?->isAdmin(), 403);
|
||||
|
||||
return view('admin.world.index', [
|
||||
'metrics' => $this->metrics(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON metrics (also used by the report command).
|
||||
*/
|
||||
public function metrics(): array
|
||||
{
|
||||
return [
|
||||
'users' => [
|
||||
'total' => User::query()->count(),
|
||||
'pending' => User::query()->where('status', User::STATUS_PENDING)->count(),
|
||||
'active' => User::query()->where('status', User::STATUS_ACTIVE)->count(),
|
||||
'banned' => User::query()->where('status', User::STATUS_BANNED)->count(),
|
||||
'consents' => UserConsent::query()->count(),
|
||||
],
|
||||
'cities' => City::query()->count(),
|
||||
'characters' => [
|
||||
'total' => Character::query()->count(),
|
||||
'online' => Character::query()->where('is_online', true)->count(),
|
||||
'hospitalized' => Character::query()->where('state', Character::STATE_HOSPITAL)->count(),
|
||||
'avg_health' => (int) round(Character::query()->avg('health') ?? 0),
|
||||
'avg_education' => (int) round(Character::query()->avg('education') ?? 0),
|
||||
'total_money' => round((float) Character::query()->sum('money'), 2),
|
||||
],
|
||||
'world' => [
|
||||
'stores' => Store::query()->count(),
|
||||
'buildings' => Building::query()->count(),
|
||||
'houses_complete' => Building::query()->where('type', 'house')->where('is_complete', true)->count(),
|
||||
'jobs' => Job::query()->count(),
|
||||
'messages' => CharacterMessage::query()->count(),
|
||||
'transactions' => Transaction::query()->count(),
|
||||
'transaction_volume' => round((float) Transaction::query()->sum('amount'), 2),
|
||||
],
|
||||
'market' => [
|
||||
'listings' => MarketListing::query()->where('is_active', true)->count(),
|
||||
],
|
||||
'missions' => [
|
||||
'active' => Mission::query()->where('is_active', true)->count(),
|
||||
'completed' => DB::table('character_missions')->where('is_completed', true)->count(),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Character;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Character appearance customization.
|
||||
*/
|
||||
class CharacterCustomizeController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show current appearance slots.
|
||||
*/
|
||||
public function show(Request $request): JsonResponse
|
||||
{
|
||||
$me = Character::ensureForUser($request->user());
|
||||
|
||||
return response()->json([
|
||||
'name' => $me->name,
|
||||
'skin' => (int) $me->skin,
|
||||
'hair' => (int) $me->hair,
|
||||
'shirt' => (int) $me->shirt,
|
||||
'face' => (int) $me->face,
|
||||
'beard' => (int) $me->beard,
|
||||
'accessory' => (int) $me->accessory,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist appearance choices (1-based slots within safe ranges).
|
||||
*/
|
||||
public function update(Request $request): JsonResponse
|
||||
{
|
||||
$me = Character::ensureForUser($request->user());
|
||||
$data = $request->validate([
|
||||
'skin' => ['required', 'integer', 'min:1', 'max:4'],
|
||||
'hair' => ['required', 'integer', 'min:1', 'max:4'],
|
||||
'shirt' => ['required', 'integer', 'min:1', 'max:5'],
|
||||
'face' => ['required', 'integer', 'min:1', 'max:3'],
|
||||
'beard' => ['required', 'integer', 'min:0', 'max:2'],
|
||||
'accessory' => ['required', 'integer', 'min:0', 'max:2'],
|
||||
]);
|
||||
|
||||
$me->forceFill($data)->save();
|
||||
|
||||
return response()->json([
|
||||
'message' => __('city.customize_saved'),
|
||||
'appearance' => $data,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use App\Models\InventoryItem;
|
||||
use App\Models\Job;
|
||||
use App\Models\Transaction;
|
||||
use App\Support\CityPlazas;
|
||||
use App\Support\MissionTracker;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -100,10 +101,69 @@ class CityActionController extends Controller
|
||||
'apply-job' => $this->applyJob($request, $character),
|
||||
'exercise' => $this->exercise($request, $character),
|
||||
'stop-exercise' => $this->stopExercise($character),
|
||||
'ride' => $this->ride($request, $character),
|
||||
default => response()->json(['message' => __('city.action_unknown')], 422),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Board a nearby bus stop or train for a short ride.
|
||||
*/
|
||||
private function ride(Request $request, Character $character): JsonResponse
|
||||
{
|
||||
if ($character->isHospitalized()) {
|
||||
return response()->json(['message' => __('city.hospital_blocked')], 422);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'kind' => ['required', 'in:bus,train'],
|
||||
'pos_x' => ['nullable', 'numeric'],
|
||||
'pos_z' => ['nullable', 'numeric'],
|
||||
]);
|
||||
|
||||
$x = array_key_exists('pos_x', $data) ? (float) $data['pos_x'] : (float) $character->pos_x;
|
||||
$z = array_key_exists('pos_z', $data) ? (float) $data['pos_z'] : (float) $character->pos_z;
|
||||
|
||||
// Must be near a road (bus) or the train corridor (train).
|
||||
$city = $character->city;
|
||||
if ($data['kind'] === 'bus') {
|
||||
$nearRoad = false;
|
||||
for ($vx = ceil(((float) $city->bounds_min_x) / 40) * 40; $vx <= $city->bounds_max_x; $vx += 40) {
|
||||
if (abs($x - $vx) < 8) {
|
||||
$nearRoad = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (! $nearRoad) {
|
||||
for ($vz = ceil(((float) $city->bounds_min_z) / 40) * 40; $vz <= $city->bounds_max_z; $vz += 40) {
|
||||
if (abs($z - $vz) < 8) {
|
||||
$nearRoad = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (! $nearRoad) {
|
||||
return response()->json(['message' => __('city.ride_need_stop')], 422);
|
||||
}
|
||||
} elseif (abs($z - ((float) $city->bounds_max_z + 14)) > 20) {
|
||||
return response()->json(['message' => __('city.ride_need_stop')], 422);
|
||||
}
|
||||
|
||||
$character->forceFill([
|
||||
'ride_kind' => $data['kind'],
|
||||
'ride_until' => now()->addSeconds(45),
|
||||
'state' => Character::STATE_AWAKE,
|
||||
'pos_x' => $x,
|
||||
'pos_z' => $z,
|
||||
'is_online' => true,
|
||||
])->save();
|
||||
|
||||
return response()->json([
|
||||
'message' => $data['kind'] === 'train' ? __('city.ride_train_ok') : __('city.ride_bus_ok'),
|
||||
'character' => $this->characterData($character),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* One exercise rep on nearby plaza gym equipment: +health, −energy.
|
||||
*
|
||||
@@ -155,9 +215,12 @@ class CityActionController extends Controller
|
||||
'pos_z' => $z,
|
||||
])->save();
|
||||
|
||||
$missionDones = MissionTracker::sync($character, 'exercise_count');
|
||||
|
||||
return response()->json([
|
||||
'message' => __('city.exercise_ok'),
|
||||
'equipment' => $nearby,
|
||||
'missions_completed' => $missionDones,
|
||||
'character' => $this->characterData($character),
|
||||
]);
|
||||
}
|
||||
@@ -476,6 +539,8 @@ class CityActionController extends Controller
|
||||
'build_progress' => 0,
|
||||
]);
|
||||
|
||||
MissionTracker::sync($character->fresh(), 'buildings');
|
||||
|
||||
return response()->json([
|
||||
'message' => __('city.build_started'),
|
||||
'building' => [
|
||||
@@ -569,6 +634,14 @@ class CityActionController extends Controller
|
||||
'pos_x' => (float) $character->pos_x,
|
||||
'pos_z' => (float) $character->pos_z,
|
||||
'hospitalized_until' => $character->hospitalized_until?->toIso8601String(),
|
||||
'face' => (int) $character->face,
|
||||
'beard' => (int) $character->beard,
|
||||
'accessory' => (int) $character->accessory,
|
||||
'skin' => (int) $character->skin,
|
||||
'hair' => (int) $character->hair,
|
||||
'shirt' => (int) $character->shirt,
|
||||
'ride_kind' => $character->ride_kind,
|
||||
'ride_until' => $character->ride_until?->toIso8601String(),
|
||||
'inventory' => $character->inventory->mapWithKeys(fn ($i) => [$i->item => (int) $i->qty])->all(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Character;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\MarketListing;
|
||||
use App\Models\Mission;
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Marketplace between citizens and mission journal endpoints.
|
||||
*/
|
||||
class CityMarketController extends Controller
|
||||
{
|
||||
/**
|
||||
* Active listings in the caller's city plus own listings.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$me = Character::ensureForUser($request->user());
|
||||
|
||||
$listings = MarketListing::query()
|
||||
->where('city_id', $me->city_id)
|
||||
->where('is_active', true)
|
||||
->with('seller:id,name')
|
||||
->orderByDesc('id')
|
||||
->limit(50)
|
||||
->get()
|
||||
->map(fn (MarketListing $l): array => [
|
||||
'id' => $l->id,
|
||||
'item' => $l->item,
|
||||
'qty' => (int) $l->qty,
|
||||
'unit_price' => (float) $l->unit_price,
|
||||
'seller_id' => $l->seller_character_id,
|
||||
'seller_name' => $l->seller?->name,
|
||||
'is_mine' => (int) $l->seller_character_id === (int) $me->id,
|
||||
]);
|
||||
|
||||
$missions = Mission::query()
|
||||
->where('city_id', $me->city_id)
|
||||
->where('is_active', true)
|
||||
->get()
|
||||
->map(function (Mission $m) use ($me) {
|
||||
$pivot = DB::table('character_missions')
|
||||
->where('character_id', $me->id)
|
||||
->where('mission_id', $m->id)
|
||||
->first();
|
||||
|
||||
return [
|
||||
'id' => $m->id,
|
||||
'code' => $m->code,
|
||||
'title' => $m->title,
|
||||
'description' => $m->description,
|
||||
'target' => (int) $m->target,
|
||||
'reward' => (float) $m->reward,
|
||||
'progress' => (int) ($pivot->progress ?? 0),
|
||||
'is_completed' => (bool) ($pivot->is_completed ?? false),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'listings' => $listings,
|
||||
'missions' => $missions,
|
||||
'money' => (float) $me->money,
|
||||
'inventory' => $me->inventory()->get()->mapWithKeys(fn ($i) => [$i->item => (int) $i->qty])->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish inventory for sale.
|
||||
*/
|
||||
public function sell(Request $request): JsonResponse
|
||||
{
|
||||
$me = Character::ensureForUser($request->user());
|
||||
$data = $request->validate([
|
||||
'item' => ['required', 'in:food,water,materials,product'],
|
||||
'qty' => ['required', 'integer', 'min:1', 'max:100'],
|
||||
'unit_price' => ['required', 'numeric', 'min:0.5', 'max:1000'],
|
||||
]);
|
||||
|
||||
$row = InventoryItem::query()
|
||||
->where('character_id', $me->id)
|
||||
->where('item', $data['item'])
|
||||
->first();
|
||||
|
||||
if ($row === null || (int) $row->qty < (int) $data['qty']) {
|
||||
return response()->json(['message' => __('city.no_inventory')], 422);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($me, $data, $row): void {
|
||||
$row->qty = (int) $row->qty - (int) $data['qty'];
|
||||
$row->save();
|
||||
MarketListing::create([
|
||||
'seller_character_id' => $me->id,
|
||||
'city_id' => $me->city_id,
|
||||
'item' => $data['item'],
|
||||
'qty' => (int) $data['qty'],
|
||||
'unit_price' => (float) $data['unit_price'],
|
||||
'is_active' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
return response()->json(['message' => __('city.market_listed')]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Purchase a listing from another citizen.
|
||||
*/
|
||||
public function buy(Request $request): JsonResponse
|
||||
{
|
||||
$me = Character::ensureForUser($request->user());
|
||||
$data = $request->validate([
|
||||
'listing_id' => ['required', 'integer', 'exists:market_listings,id'],
|
||||
'qty' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
]);
|
||||
|
||||
$listing = MarketListing::query()->findOrFail($data['listing_id']);
|
||||
|
||||
if (! $listing->is_active || (int) $listing->seller_character_id === (int) $me->id) {
|
||||
return response()->json(['message' => __('city.market_unavailable')], 422);
|
||||
}
|
||||
|
||||
$qty = min((int) ($data['qty'] ?? $listing->qty), (int) $listing->qty);
|
||||
$total = round($listing->unit_price * $qty, 2);
|
||||
|
||||
if ((float) $me->money < $total) {
|
||||
return response()->json(['message' => __('city.no_money')], 422);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($me, $listing, $qty, $total): void {
|
||||
$seller = Character::query()->lockForUpdate()->findOrFail($listing->seller_character_id);
|
||||
$me->money = round((float) $me->money - $total, 2);
|
||||
$me->save();
|
||||
$seller->money = round((float) $seller->money + $total, 2);
|
||||
$seller->save();
|
||||
|
||||
$row = InventoryItem::query()
|
||||
->where('character_id', $me->id)
|
||||
->where('item', $listing->item)
|
||||
->first();
|
||||
if ($row) {
|
||||
$row->qty = (int) $row->qty + $qty;
|
||||
$row->save();
|
||||
} else {
|
||||
InventoryItem::create([
|
||||
'character_id' => $me->id,
|
||||
'item' => $listing->item,
|
||||
'qty' => $qty,
|
||||
]);
|
||||
}
|
||||
|
||||
$listing->qty = (int) $listing->qty - $qty;
|
||||
if ($listing->qty <= 0) {
|
||||
$listing->is_active = false;
|
||||
}
|
||||
$listing->save();
|
||||
|
||||
Transaction::create([
|
||||
'from_character_id' => $me->id,
|
||||
'to_character_id' => $seller->id,
|
||||
'item' => $listing->item,
|
||||
'qty' => $qty,
|
||||
'amount' => $total,
|
||||
]);
|
||||
});
|
||||
|
||||
$me->refresh();
|
||||
|
||||
return response()->json([
|
||||
'message' => __('city.market_bought'),
|
||||
'money' => (float) $me->money,
|
||||
'inventory' => $me->inventory()->get()->mapWithKeys(fn ($i) => [$i->item => (int) $i->qty])->all(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Character;
|
||||
use App\Models\City;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Lightweight multiplayer presence feed.
|
||||
*
|
||||
* Returns the positions and states of characters active in a city so the
|
||||
* viewer can render other citizens in near-real-time without WebSockets.
|
||||
*/
|
||||
class CityPresenceController extends Controller
|
||||
{
|
||||
/**
|
||||
* Snapshot of citizens in the requested (or default) city.
|
||||
*/
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
abort_unless($user !== null, 401);
|
||||
|
||||
$me = Character::ensureForUser($user);
|
||||
$slug = $request->query('city');
|
||||
$city = $me->city;
|
||||
if (is_string($slug) && $slug !== '') {
|
||||
$city = City::query()
|
||||
->where('slug', $slug)
|
||||
->orWhere('id', ctype_digit($slug) ? (int) $slug : 0)
|
||||
->first() ?? $city;
|
||||
}
|
||||
|
||||
$cacheKey = 'presence.'.$city->id.'.'.now()->timestamp;
|
||||
$citizens = Cache::remember($cacheKey, 1, function () use ($city, $me) {
|
||||
return Character::query()
|
||||
->where('city_id', $city->id)
|
||||
->where(function ($q): void {
|
||||
$q->where('is_online', true)
|
||||
->orWhere('updated_at', '>=', now()->subMinutes(10));
|
||||
})
|
||||
->with('user:id,name')
|
||||
->get()
|
||||
->map(fn (Character $c): array => [
|
||||
'id' => $c->id,
|
||||
'name' => $c->name,
|
||||
'type' => $c->user_id === null ? 'child' : 'citizen',
|
||||
'status' => $c->state,
|
||||
'position' => [(float) $c->pos_x, (float) $c->pos_y, (float) $c->pos_z],
|
||||
'is_online' => (bool) $c->is_online,
|
||||
'is_me' => (int) $c->id === (int) $me->id,
|
||||
'owner_name' => $c->user?->name,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'city_id' => $city->id,
|
||||
'server_time' => now()->toIso8601String(),
|
||||
'citizens' => $citizens,
|
||||
])->header('Cache-Control', 'no-store');
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
@@ -19,7 +20,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
'user_id', 'city_id', 'name', 'skin', 'hair', 'shirt',
|
||||
'pos_x', 'pos_y', 'pos_z', 'money', 'energy', 'hunger', 'thirst', 'health',
|
||||
'state', 'job_id', 'house_id', 'education', 'exercise_count', 'is_online', 'last_tick_at',
|
||||
'hospitalized_until',
|
||||
'hospitalized_until', 'face', 'beard', 'accessory', 'ride_kind', 'ride_until',
|
||||
])]
|
||||
class Character extends Model
|
||||
{
|
||||
@@ -116,6 +117,28 @@ class Character extends Model
|
||||
return $this->hasMany(Building::class, 'owner_character_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Marketplace listings published by this character.
|
||||
*
|
||||
* @return HasMany<MarketListing, $this>
|
||||
*/
|
||||
public function listings(): HasMany
|
||||
{
|
||||
return $this->hasMany(MarketListing::class, 'seller_character_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Missions this character is tracking.
|
||||
*
|
||||
* @return BelongsToMany<Mission, $this>
|
||||
*/
|
||||
public function missions(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Mission::class, 'character_missions')
|
||||
->withPivot(['progress', 'is_completed', 'completed_at'])
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inventory stacks owned by this character.
|
||||
*
|
||||
@@ -168,6 +191,9 @@ class Character extends Model
|
||||
'education' => 0,
|
||||
'exercise_count' => 0,
|
||||
'is_online' => false,
|
||||
'face' => random_int(1, 3),
|
||||
'beard' => random_int(0, 2),
|
||||
'accessory' => random_int(0, 2),
|
||||
]);
|
||||
|
||||
// Starter kit so new citizens can eat, drink and build immediately.
|
||||
@@ -207,6 +233,10 @@ class Character extends Model
|
||||
'is_online' => 'boolean',
|
||||
'last_tick_at' => 'datetime',
|
||||
'hospitalized_until' => 'datetime',
|
||||
'face' => 'integer',
|
||||
'beard' => 'integer',
|
||||
'accessory' => 'integer',
|
||||
'ride_until' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user