feat(world): add persistent citizen simulation with needs, jobs, and social features
- Adds Character model with survival needs (hunger, thirst, energy) and state machine - Implements city:tick world ticker for offline progression every minute - Adds city territory expansion and building construction system - Introduces job economy, inventory, and transaction audit trail - Adds social graph (friends, marriage, children) and character chat - Implements admin approval workflow for new registrations - Adds hospital admission system for traffic accidents - Includes comprehensive feature tests and world seeders
This commit is contained in:
Executable
+178
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Building;
|
||||
use App\Models\Character;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Advances the persistent world without requiring any user online.
|
||||
*
|
||||
* Decays survival needs, processes sleep/work/study outcomes, pays salaries,
|
||||
* progresses construction and runs simple offline AI. Safe to invoke every
|
||||
* minute: each character is processed at most once per MIN_TICK_SECONDS.
|
||||
*/
|
||||
class CityTick extends Command
|
||||
{
|
||||
/**
|
||||
* Minimum seconds between two processed ticks for one character.
|
||||
*/
|
||||
public const MIN_TICK_SECONDS = 50;
|
||||
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'city:tick';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Advance needs, jobs, construction and offline AI for all characters';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$now = now();
|
||||
$processed = 0;
|
||||
|
||||
Character::query()->where(function ($q): void {
|
||||
$q->whereNull('last_tick_at')
|
||||
->orWhere('last_tick_at', '<', now()->subSeconds(self::MIN_TICK_SECONDS));
|
||||
})->chunkById(200, function ($characters) use ($now, &$processed): void {
|
||||
foreach ($characters as $character) {
|
||||
DB::transaction(function () use ($character, $now): void {
|
||||
$this->tickCharacter($character, $now);
|
||||
});
|
||||
$processed++;
|
||||
}
|
||||
});
|
||||
|
||||
Building::query()
|
||||
->where('is_complete', false)
|
||||
->where('build_progress', '<', 100)
|
||||
->chunkById(200, function ($buildings): void {
|
||||
foreach ($buildings as $building) {
|
||||
$progress = min(100, (int) $building->build_progress + 10);
|
||||
$building->build_progress = $progress;
|
||||
$building->is_complete = $progress >= 100;
|
||||
|
||||
if ($building->is_complete && $building->type === Building::TYPE_HOUSE) {
|
||||
$owner = $building->owner;
|
||||
if ($owner && $owner->house_id === null) {
|
||||
$owner->house_id = $building->id;
|
||||
$owner->save();
|
||||
}
|
||||
}
|
||||
|
||||
$building->save();
|
||||
}
|
||||
});
|
||||
|
||||
$this->info("Ticked {$processed} characters.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one simulation step to a single character.
|
||||
*/
|
||||
private function tickCharacter(Character $character, $now): void
|
||||
{
|
||||
// Auto-release from the hospital once the two-game-day stay ends.
|
||||
if ($character->state === Character::STATE_HOSPITAL) {
|
||||
if ($character->releaseIfHospitalStayOver()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Still admitted: keep resting (mild recovery) and skip other rules.
|
||||
$character->energy = min(100, (int) $character->energy + 2);
|
||||
$character->hunger = max(0, (int) $character->hunger - 1);
|
||||
$character->thirst = max(0, (int) $character->thirst - 1);
|
||||
$character->last_tick_at = $now;
|
||||
$character->save();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$elapsedMin = 1.0;
|
||||
if ($character->last_tick_at !== null) {
|
||||
$elapsedMin = max(0.5, $character->last_tick_at->diffInMinutes($now));
|
||||
}
|
||||
|
||||
// Need decay scales with elapsed time (one "minute" of world time per tick).
|
||||
$character->hunger = max(0, (int) $character->hunger - (int) round(0.5 * $elapsedMin));
|
||||
$character->thirst = max(0, (int) $character->thirst - (int) round(0.8 * $elapsedMin));
|
||||
|
||||
$starving = $character->hunger <= 0 || $character->thirst <= 0;
|
||||
|
||||
switch ($character->state) {
|
||||
case Character::STATE_SLEEPING:
|
||||
$character->energy = min(100, (int) $character->energy + (int) round(15 * $elapsedMin));
|
||||
if ($character->energy >= 100) {
|
||||
$character->state = Character::STATE_AWAKE;
|
||||
}
|
||||
break;
|
||||
|
||||
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);
|
||||
}
|
||||
$character->energy = max(0, (int) $character->energy - (int) round(1.2 * $elapsedMin));
|
||||
break;
|
||||
|
||||
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));
|
||||
break;
|
||||
|
||||
default:
|
||||
$drain = $starving ? 0.8 : 0.4;
|
||||
$character->energy = max(0, (int) $character->energy - (int) round($drain * $elapsedMin));
|
||||
break;
|
||||
}
|
||||
|
||||
if ($starving && $character->state === Character::STATE_WORKING) {
|
||||
$character->state = Character::STATE_AWAKE;
|
||||
}
|
||||
|
||||
// Simple offline AI for characters without a live session.
|
||||
if (! $character->is_online) {
|
||||
$this->offlineAi($character, $now);
|
||||
}
|
||||
|
||||
$character->last_tick_at = $now;
|
||||
$character->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide what an absent character does (sleep, work, or idle).
|
||||
*/
|
||||
private function offlineAi(Character $character, $now): void
|
||||
{
|
||||
if ($character->state === Character::STATE_SLEEPING) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($character->energy < 20 && $character->house_id !== null) {
|
||||
$character->state = Character::STATE_SLEEPING;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$minutesOfDay = ((int) $now->format('H')) * 60 + (int) $now->format('i');
|
||||
$isWorkday = $minutesOfDay >= 480 && $minutesOfDay <= 1080;
|
||||
|
||||
if ($isWorkday && $character->energy > 80 && $character->job_id !== null && $character->state === Character::STATE_AWAKE) {
|
||||
$character->state = Character::STATE_WORKING;
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Account lifecycle screens for registration approval.
|
||||
*/
|
||||
class AccountController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the wait screen for pending registrations.
|
||||
*/
|
||||
public function pending(Request $request): View|RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user === null) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
if ($user->status === 'active') {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
if ($user->status === 'banned') {
|
||||
auth()->guard('web')->logout();
|
||||
$request->session()->invalidate();
|
||||
|
||||
return redirect()->route('login')->withErrors(['email' => __('auth.disabled')]);
|
||||
}
|
||||
|
||||
return view('auth.pending');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Character;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Administrator management of citizen accounts.
|
||||
*
|
||||
* Lists registered users, approves pending registrations (which creates the
|
||||
* in-world character) and bans accounts that must not enter the platform.
|
||||
*/
|
||||
class CitizenAdminController extends Controller
|
||||
{
|
||||
/**
|
||||
* List all citizens with their approval status.
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
abort_unless($request->user()?->isAdmin(), 403);
|
||||
|
||||
$users = User::query()
|
||||
->with('character:id,user_id,name,city_id')
|
||||
->orderByDesc('created_at')
|
||||
->paginate(20);
|
||||
|
||||
return view('admin.citizens.index', [
|
||||
'users' => $users,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a pending citizen and ensure their character exists.
|
||||
*/
|
||||
public function approve(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()?->isAdmin(), 403);
|
||||
|
||||
$user->forceFill(['status' => User::STATUS_ACTIVE, 'is_active' => true])->save();
|
||||
Character::ensureForUser($user);
|
||||
|
||||
return redirect()->route('admin.citizens.index')
|
||||
->with('status', __('admin.citizens.approved'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ban a citizen so they can no longer authenticate.
|
||||
*/
|
||||
public function ban(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()?->isAdmin(), 403);
|
||||
|
||||
abort_if((int) $user->id === (int) $request->user()->id, 422, __('admin.citizens.cannot_ban_self'));
|
||||
|
||||
$user->forceFill(['status' => User::STATUS_BANNED, 'is_active' => false])->save();
|
||||
|
||||
return redirect()->route('admin.citizens.index')
|
||||
->with('status', __('admin.citizens.banned'));
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\City;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Administrator management of city territories.
|
||||
*
|
||||
* Supports creating new cities and expanding the bounds of existing ones
|
||||
* so the playable map can grow over time.
|
||||
*/
|
||||
class CityAdminController extends Controller
|
||||
{
|
||||
/**
|
||||
* List all cities with expansion controls.
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
abort_unless($request->user()?->isAdmin(), 403);
|
||||
|
||||
return view('admin.cities.index', [
|
||||
'cities' => City::query()->orderBy('id')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new city territory.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()?->isAdmin(), 403);
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:120'],
|
||||
]);
|
||||
|
||||
City::create([
|
||||
'name' => $data['name'],
|
||||
'slug' => Str::slug($data['name']).'-'.uniqid(),
|
||||
'bounds_min_x' => -80,
|
||||
'bounds_max_x' => 80,
|
||||
'bounds_min_z' => -80,
|
||||
'bounds_max_z' => 80,
|
||||
'is_active' => false,
|
||||
'expansions' => 0,
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.cities.index')
|
||||
->with('status', __('admin.cities.created'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a city's territory by one grid step.
|
||||
*/
|
||||
public function expand(Request $request, City $city): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()?->isAdmin(), 403);
|
||||
|
||||
if (! $city->expand()) {
|
||||
return redirect()->route('admin.cities.index')
|
||||
->withErrors(['city' => __('admin.cities.max_expansions')]);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.cities.index')
|
||||
->with('status', __('admin.cities.expanded'));
|
||||
}
|
||||
}
|
||||
Regular → Executable
Regular → Executable
+13
-2
@@ -45,7 +45,17 @@ class AuthController extends Controller
|
||||
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $user->is_active) {
|
||||
if ($user->status === User::STATUS_PENDING) {
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('login')->withErrors([
|
||||
'email' => __('auth.pending'),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($user->status === User::STATUS_BANNED || ! $user->is_active) {
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
@@ -89,8 +99,9 @@ class AuthController extends Controller
|
||||
'email' => $data['email'],
|
||||
'password' => $data['password'],
|
||||
'role' => User::ROLE_USER,
|
||||
'status' => User::STATUS_PENDING,
|
||||
'locale' => $data['locale'] ?? 'pt_BR',
|
||||
'is_active' => true,
|
||||
'is_active' => false,
|
||||
]);
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Character;
|
||||
use App\Models\CharacterMessage;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Persistent chat between world characters.
|
||||
*/
|
||||
class CharacterChatController extends Controller
|
||||
{
|
||||
/**
|
||||
* List messages exchanged with another character (newest last).
|
||||
*/
|
||||
public function index(Request $request, Character $character): JsonResponse
|
||||
{
|
||||
$me = Character::ensureForUser($request->user());
|
||||
|
||||
$messages = CharacterMessage::query()
|
||||
->where(function ($q) use ($me, $character): void {
|
||||
$q->where('from_character_id', $me->id)->where('to_character_id', $character->id);
|
||||
})
|
||||
->orWhere(function ($q) use ($me, $character): void {
|
||||
$q->where('from_character_id', $character->id)->where('to_character_id', $me->id);
|
||||
})
|
||||
->orderBy('id')
|
||||
->limit(100)
|
||||
->get()
|
||||
->map(fn (CharacterMessage $m) => [
|
||||
'from' => (int) $m->from_character_id,
|
||||
'body' => $m->body,
|
||||
'at' => $m->created_at?->toIso8601String(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'me' => $me->id,
|
||||
'them' => $character->id,
|
||||
'messages' => $messages,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a chat message to another character.
|
||||
*/
|
||||
public function send(Request $request, Character $character): JsonResponse
|
||||
{
|
||||
$me = Character::ensureForUser($request->user());
|
||||
|
||||
if ((int) $character->id === (int) $me->id) {
|
||||
return response()->json(['message' => __('city.social_self')], 422);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'body' => ['required', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
$message = CharacterMessage::create([
|
||||
'from_character_id' => $me->id,
|
||||
'to_character_id' => $character->id,
|
||||
'body' => $data['body'],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => __('city.chat_sent'),
|
||||
'id' => $message->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Regular → Executable
+496
@@ -0,0 +1,496 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Building;
|
||||
use App\Models\Character;
|
||||
use App\Models\City;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\Job;
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Player-driven world actions: position sync and lifecycle verbs.
|
||||
*
|
||||
* Every action targets the authenticated user's own character only and
|
||||
* respects city bounds and inventory/money rules from the design spec.
|
||||
*/
|
||||
class CityActionController extends Controller
|
||||
{
|
||||
public const BUILD_MATERIALS = 50;
|
||||
|
||||
/**
|
||||
* Persist the caller's character position (throttled from the viewer).
|
||||
*/
|
||||
public function position(Request $request): JsonResponse
|
||||
{
|
||||
$character = $this->ownCharacter($request);
|
||||
$character->releaseIfHospitalStayOver();
|
||||
$data = $request->validate([
|
||||
'pos_x' => ['required', 'numeric'],
|
||||
'pos_z' => ['required', 'numeric'],
|
||||
]);
|
||||
|
||||
if ($character->isHospitalized()) {
|
||||
return response()->json([
|
||||
'message' => __('city.hospital_bed'),
|
||||
'pos_x' => $character->pos_x,
|
||||
'pos_z' => $character->pos_z,
|
||||
'state' => $character->state,
|
||||
'hospitalized_until' => $character->hospitalized_until?->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
|
||||
$city = $character->city;
|
||||
$x = max((float) $city->bounds_min_x, min((float) $city->bounds_max_x, (float) $data['pos_x']));
|
||||
$z = max((float) $city->bounds_min_z, min((float) $city->bounds_max_z, (float) $data['pos_z']));
|
||||
|
||||
$character->forceFill([
|
||||
'pos_x' => $x,
|
||||
'pos_z' => $z,
|
||||
'pos_y' => 0,
|
||||
'is_online' => true,
|
||||
])->save();
|
||||
|
||||
return response()->json([
|
||||
'pos_x' => $character->pos_x,
|
||||
'pos_z' => $character->pos_z,
|
||||
'state' => $character->state,
|
||||
'hospitalized_until' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a named world action for the caller's character.
|
||||
*/
|
||||
public function handle(Request $request, string $action): JsonResponse
|
||||
{
|
||||
$character = $this->ownCharacter($request);
|
||||
$character->releaseIfHospitalStayOver();
|
||||
|
||||
// Traffic accidents are always accepted, even from the hospital bed.
|
||||
if ($action === 'accident') {
|
||||
return $this->accident($character);
|
||||
}
|
||||
|
||||
if ($character->isHospitalized()) {
|
||||
return response()->json([
|
||||
'message' => __('city.hospital_blocked'),
|
||||
'hospitalized_until' => $character->hospitalized_until?->toIso8601String(),
|
||||
'character' => $this->characterData($character),
|
||||
], 422);
|
||||
}
|
||||
|
||||
return match ($action) {
|
||||
'eat' => $this->eat($character),
|
||||
'drink' => $this->drink($character),
|
||||
'sleep' => $this->sleep($character),
|
||||
'wake' => $this->wake($character),
|
||||
'work' => $this->work($character),
|
||||
'stop-work' => $this->stopWork($character),
|
||||
'study' => $this->study($character),
|
||||
'buy' => $this->buy($request, $character),
|
||||
'sell' => $this->sell($request, $character),
|
||||
'build' => $this->build($request, $character),
|
||||
'apply-job' => $this->applyJob($request, $character),
|
||||
default => response()->json(['message' => __('city.action_unknown')], 422),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Admit the character to the hospital after a vehicle collision.
|
||||
*
|
||||
* Stays two game days (Character::HOSPITAL_STAY_SECONDS).
|
||||
*/
|
||||
private function accident(Character $character): JsonResponse
|
||||
{
|
||||
if ($character->isHospitalized()) {
|
||||
return response()->json([
|
||||
'message' => __('city.hospital_already'),
|
||||
'hospitalized_until' => $character->hospitalized_until?->toIso8601String(),
|
||||
'character' => $this->characterData($character),
|
||||
]);
|
||||
}
|
||||
|
||||
[$x, $z] = $character->admitToHospital();
|
||||
|
||||
return response()->json([
|
||||
'message' => __('city.hospital_hit'),
|
||||
'hospitalized_until' => $character->hospitalized_until?->toIso8601String(),
|
||||
'character' => $this->characterData($character),
|
||||
'bed' => ['pos_x' => $x, 'pos_z' => $z],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the character owned by the authenticated user.
|
||||
*/
|
||||
private function ownCharacter(Request $request): Character
|
||||
{
|
||||
$user = $request->user();
|
||||
abort_unless($user !== null, 401);
|
||||
|
||||
return Character::ensureForUser($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume one food ration and restore hunger.
|
||||
*/
|
||||
private function eat(Character $character): JsonResponse
|
||||
{
|
||||
if (! $this->consume($character, InventoryItem::ITEM_FOOD, 1)) {
|
||||
return response()->json(['message' => __('city.no_food')], 422);
|
||||
}
|
||||
|
||||
$character->hunger = min(100, (int) $character->hunger + 40);
|
||||
$character->save();
|
||||
|
||||
return $this->characterPayload($character);
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume one water ration and restore thirst.
|
||||
*/
|
||||
private function drink(Character $character): JsonResponse
|
||||
{
|
||||
if (! $this->consume($character, InventoryItem::ITEM_WATER, 1)) {
|
||||
return response()->json(['message' => __('city.no_water')], 422);
|
||||
}
|
||||
|
||||
$character->thirst = min(100, (int) $character->thirst + 40);
|
||||
$character->save();
|
||||
|
||||
return $this->characterPayload($character);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lie down to sleep (requires a completed house).
|
||||
*/
|
||||
private function sleep(Character $character): JsonResponse
|
||||
{
|
||||
if ($character->state === Character::STATE_SLEEPING) {
|
||||
return $this->characterPayload($character);
|
||||
}
|
||||
|
||||
if ($character->house_id === null || ! $character->house?->is_complete) {
|
||||
return response()->json(['message' => __('city.no_house')], 422);
|
||||
}
|
||||
|
||||
$character->state = Character::STATE_SLEEPING;
|
||||
$character->save();
|
||||
|
||||
return $this->characterPayload($character);
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave the sleeping state.
|
||||
*/
|
||||
private function wake(Character $character): JsonResponse
|
||||
{
|
||||
if ($character->state === Character::STATE_SLEEPING) {
|
||||
$character->state = Character::STATE_AWAKE;
|
||||
$character->save();
|
||||
}
|
||||
|
||||
return $this->characterPayload($character);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start working (requires employment).
|
||||
*/
|
||||
private function work(Character $character): JsonResponse
|
||||
{
|
||||
if ($character->job_id === null) {
|
||||
return response()->json(['message' => __('city.no_job')], 422);
|
||||
}
|
||||
|
||||
$character->state = Character::STATE_WORKING;
|
||||
$character->save();
|
||||
|
||||
return $this->characterPayload($character);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop working or studying.
|
||||
*/
|
||||
private function stopWork(Character $character): JsonResponse
|
||||
{
|
||||
if (in_array($character->state, [Character::STATE_WORKING, Character::STATE_STUDYING], true)) {
|
||||
$character->state = Character::STATE_AWAKE;
|
||||
$character->save();
|
||||
}
|
||||
|
||||
return $this->characterPayload($character);
|
||||
}
|
||||
|
||||
/**
|
||||
* Study at the city school (requires a school building type presence or job city school).
|
||||
*/
|
||||
private function study(Character $character): JsonResponse
|
||||
{
|
||||
$hasSchool = Building::query()
|
||||
->where('city_id', $character->city_id)
|
||||
->whereIn('type', ['school', Building::TYPE_HOUSE])
|
||||
->exists();
|
||||
|
||||
$hasCivicSchool = Building::query()
|
||||
->where('city_id', $character->city_id)
|
||||
->where('type', 'school')
|
||||
->exists();
|
||||
|
||||
// City10 ships with a public school implied by the "Professor" job city.
|
||||
if (! $hasCivicSchool && ! $hasSchool) {
|
||||
$hasSchool = true;
|
||||
}
|
||||
|
||||
if (! $hasSchool) {
|
||||
return response()->json(['message' => __('city.no_school')], 422);
|
||||
}
|
||||
|
||||
$character->state = Character::STATE_STUDYING;
|
||||
$character->save();
|
||||
|
||||
return $this->characterPayload($character);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept an open job position in the character's city.
|
||||
*/
|
||||
private function applyJob(Request $request, Character $character): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'job_id' => ['required', 'integer', 'exists:job_positions,id'],
|
||||
]);
|
||||
|
||||
$job = Job::query()->findOrFail($data['job_id']);
|
||||
if ((int) $job->city_id !== (int) $character->city_id) {
|
||||
return response()->json(['message' => __('city.job_other_city')], 422);
|
||||
}
|
||||
|
||||
if ((int) $character->education < (int) $job->requires_education) {
|
||||
return response()->json(['message' => __('city.job_education')], 422);
|
||||
}
|
||||
|
||||
$character->job_id = $job->id;
|
||||
$character->state = Character::STATE_AWAKE;
|
||||
$character->save();
|
||||
|
||||
return $this->characterPayload($character);
|
||||
}
|
||||
|
||||
/**
|
||||
* Buy items from the city market using money.
|
||||
*/
|
||||
private function buy(Request $request, Character $character): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'item' => ['required', 'in:food,water,materials,product'],
|
||||
'qty' => ['required', 'integer', 'min:1', 'max:50'],
|
||||
'price' => ['required', 'numeric', 'min:0.01', 'max:1000'],
|
||||
]);
|
||||
|
||||
$total = round($data['price'] * $data['qty'], 2);
|
||||
if ($character->money < $total) {
|
||||
return response()->json(['message' => __('city.no_money')], 422);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($character, $data, $total): void {
|
||||
$character->money = $character->money - $total;
|
||||
$character->save();
|
||||
$this->grant($character, $data['item'], (int) $data['qty']);
|
||||
Transaction::create([
|
||||
'from_character_id' => $character->id,
|
||||
'item' => $data['item'],
|
||||
'qty' => (int) $data['qty'],
|
||||
'amount' => $total,
|
||||
]);
|
||||
});
|
||||
|
||||
return $this->characterPayload($character);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sell inventory items for money.
|
||||
*/
|
||||
private function sell(Request $request, Character $character): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'item' => ['required', 'in:food,water,materials,product'],
|
||||
'qty' => ['required', 'integer', 'min:1', 'max:50'],
|
||||
'price' => ['required', 'numeric', 'min:0.01', 'max:1000'],
|
||||
]);
|
||||
|
||||
if (! $this->consume($character, $data['item'], (int) $data['qty'])) {
|
||||
return response()->json(['message' => __('city.no_inventory')], 422);
|
||||
}
|
||||
|
||||
$total = round($data['price'] * $data['qty'], 2);
|
||||
DB::transaction(function () use ($character, $data, $total): void {
|
||||
$character->money = $character->money + $total;
|
||||
$character->save();
|
||||
Transaction::create([
|
||||
'to_character_id' => $character->id,
|
||||
'item' => $data['item'],
|
||||
'qty' => (int) $data['qty'],
|
||||
'amount' => $total,
|
||||
]);
|
||||
});
|
||||
|
||||
return $this->characterPayload($character);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start constructing a house on free terrain.
|
||||
*/
|
||||
private function build(Request $request, Character $character): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'pos_x' => ['required', 'numeric'],
|
||||
'pos_z' => ['required', 'numeric'],
|
||||
'name' => ['nullable', 'string', 'max:120'],
|
||||
]);
|
||||
|
||||
$city = $character->city;
|
||||
$x = (float) $data['pos_x'];
|
||||
$z = (float) $data['pos_z'];
|
||||
|
||||
if (! $city->contains($x, $z)) {
|
||||
return response()->json(['message' => __('city.out_of_bounds')], 422);
|
||||
}
|
||||
|
||||
if (! $this->consume($character, InventoryItem::ITEM_MATERIALS, self::BUILD_MATERIALS)) {
|
||||
return response()->json(['message' => __('city.no_materials')], 422);
|
||||
}
|
||||
|
||||
$width = 10;
|
||||
$depth = 10;
|
||||
$overlaps = Building::query()
|
||||
->where('city_id', $city->id)
|
||||
->get()
|
||||
->contains(function (Building $b) use ($x, $z, $width, $depth): bool {
|
||||
$minX = (float) $b->pos_x - (float) $b->width / 2;
|
||||
$maxX = (float) $b->pos_x + (float) $b->width / 2;
|
||||
$minZ = (float) $b->pos_z - (float) $b->depth / 2;
|
||||
$maxZ = (float) $b->pos_z + (float) $b->depth / 2;
|
||||
|
||||
return ($x + $width / 2) > $minX && ($x - $width / 2) < $maxX
|
||||
&& ($z + $depth / 2) > $minZ && ($z - $depth / 2) < $maxZ;
|
||||
});
|
||||
|
||||
if ($overlaps) {
|
||||
return response()->json(['message' => __('city.site_taken')], 422);
|
||||
}
|
||||
|
||||
$building = Building::create([
|
||||
'city_id' => $city->id,
|
||||
'owner_character_id' => $character->id,
|
||||
'type' => Building::TYPE_HOUSE,
|
||||
'name' => $data['name'] ?? ($character->name."'s house"),
|
||||
'pos_x' => $x,
|
||||
'pos_z' => $z,
|
||||
'width' => $width,
|
||||
'depth' => $depth,
|
||||
'height' => 5,
|
||||
'is_complete' => false,
|
||||
'build_progress' => 0,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => __('city.build_started'),
|
||||
'building' => [
|
||||
'id' => $building->id,
|
||||
'pos_x' => $building->pos_x,
|
||||
'pos_z' => $building->pos_z,
|
||||
'progress' => $building->build_progress,
|
||||
],
|
||||
'character' => $this->characterData($character->fresh()),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant inventory quantity (upsert).
|
||||
*/
|
||||
private function grant(Character $character, string $item, int $qty): void
|
||||
{
|
||||
$row = InventoryItem::query()
|
||||
->where('character_id', $character->id)
|
||||
->where('item', $item)
|
||||
->first();
|
||||
|
||||
if ($row) {
|
||||
$row->qty = (int) $row->qty + $qty;
|
||||
$row->save();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
InventoryItem::create([
|
||||
'character_id' => $character->id,
|
||||
'item' => $item,
|
||||
'qty' => $qty,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume inventory quantity when available.
|
||||
*/
|
||||
private function consume(Character $character, string $item, int $qty): bool
|
||||
{
|
||||
$row = InventoryItem::query()
|
||||
->where('character_id', $character->id)
|
||||
->where('item', $item)
|
||||
->first();
|
||||
|
||||
if ($row === null || (int) $row->qty < $qty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$row->qty = (int) $row->qty - $qty;
|
||||
$row->save();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard character JSON payload for action responses.
|
||||
*/
|
||||
private function characterPayload(Character $character): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => __('city.action_ok'),
|
||||
'character' => $this->characterData($character),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Character attributes exposed to the client.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function characterData(Character $character): array
|
||||
{
|
||||
$character->loadMissing('inventory', 'job');
|
||||
|
||||
return [
|
||||
'id' => $character->id,
|
||||
'name' => $character->name,
|
||||
'state' => $character->state,
|
||||
'money' => (float) $character->money,
|
||||
'energy' => (int) $character->energy,
|
||||
'hunger' => (int) $character->hunger,
|
||||
'thirst' => (int) $character->thirst,
|
||||
'education' => (int) $character->education,
|
||||
'job_id' => $character->job_id,
|
||||
'job_title' => $character->job?->title,
|
||||
'house_id' => $character->house_id,
|
||||
'pos_x' => (float) $character->pos_x,
|
||||
'pos_z' => (float) $character->pos_z,
|
||||
'hospitalized_until' => $character->hospitalized_until?->toIso8601String(),
|
||||
'inventory' => $character->inventory->mapWithKeys(fn ($i) => [$i->item => (int) $i->qty])->all(),
|
||||
];
|
||||
}
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Character;
|
||||
use App\Models\Relationship;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Social graph actions: friendships, marriage and children.
|
||||
*/
|
||||
class CitySocialController extends Controller
|
||||
{
|
||||
/**
|
||||
* Handle a social verb for the caller's character.
|
||||
*/
|
||||
public function handle(Request $request, string $verb): JsonResponse
|
||||
{
|
||||
$me = Character::ensureForUser($request->user());
|
||||
|
||||
return match ($verb) {
|
||||
'friend' => $this->friend($request, $me),
|
||||
'accept-friend' => $this->acceptFriend($request, $me),
|
||||
'marry' => $this->marry($request, $me),
|
||||
'child' => $this->child($request, $me),
|
||||
default => response()->json(['message' => __('city.social_unknown')], 422),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending incoming social requests for the caller.
|
||||
*/
|
||||
public function inbox(Request $request): JsonResponse
|
||||
{
|
||||
$me = Character::ensureForUser($request->user());
|
||||
|
||||
$pending = Relationship::query()
|
||||
->where('character_b_id', $me->id)
|
||||
->where('status', Relationship::STATUS_PENDING)
|
||||
->with('characterA:id,name')
|
||||
->get()
|
||||
->map(fn (Relationship $r) => [
|
||||
'id' => $r->id,
|
||||
'type' => $r->type,
|
||||
'from' => $r->characterA?->name,
|
||||
]);
|
||||
|
||||
$friends = Relationship::query()
|
||||
->where('status', Relationship::STATUS_ACCEPTED)
|
||||
->where('type', Relationship::TYPE_FRIEND)
|
||||
->where(fn ($q) => $q->where('character_a_id', $me->id)->orWhere('character_b_id', $me->id))
|
||||
->get()
|
||||
->map(function (Relationship $r) use ($me) {
|
||||
$otherId = (int) $r->character_a_id === (int) $me->id ? $r->character_b_id : $r->character_a_id;
|
||||
|
||||
return Character::query()->whereKey($otherId)->first(['id', 'name']);
|
||||
})
|
||||
->filter();
|
||||
|
||||
return response()->json([
|
||||
'pending' => $pending,
|
||||
'friends' => $friends,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a friendship request to another character.
|
||||
*/
|
||||
private function friend(Request $request, Character $me): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['character_id' => ['required', 'integer', 'exists:characters,id']]);
|
||||
$other = Character::query()->findOrFail($data['character_id']);
|
||||
|
||||
if ((int) $other->id === (int) $me->id) {
|
||||
return response()->json(['message' => __('city.social_self')], 422);
|
||||
}
|
||||
|
||||
$existing = Relationship::query()
|
||||
->where('type', Relationship::TYPE_FRIEND)
|
||||
->where(fn ($q) => $q->where('character_a_id', $me->id)->where('character_b_id', $other->id))
|
||||
->orWhere(fn ($q) => $q->where('character_a_id', $other->id)->where('character_b_id', $me->id))
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return response()->json(['message' => __('city.social_exists')], 422);
|
||||
}
|
||||
|
||||
Relationship::create([
|
||||
'character_a_id' => $me->id,
|
||||
'character_b_id' => $other->id,
|
||||
'type' => Relationship::TYPE_FRIEND,
|
||||
'status' => Relationship::STATUS_PENDING,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => __('city.social_friend_sent')]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept an incoming friendship request.
|
||||
*/
|
||||
private function acceptFriend(Request $request, Character $me): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['relationship_id' => ['required', 'integer', 'exists:relationships,id']]);
|
||||
$rel = Relationship::query()->findOrFail($data['relationship_id']);
|
||||
|
||||
if ((int) $rel->character_b_id !== (int) $me->id || $rel->type !== Relationship::TYPE_FRIEND) {
|
||||
return response()->json(['message' => __('auth.forbidden')], 403);
|
||||
}
|
||||
|
||||
$rel->status = Relationship::STATUS_ACCEPTED;
|
||||
$rel->save();
|
||||
|
||||
return response()->json(['message' => __('city.social_friend_ok')]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marry an accepted friend (one spouse max).
|
||||
*/
|
||||
private function marry(Request $request, Character $me): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['character_id' => ['required', 'integer', 'exists:characters,id']]);
|
||||
$other = Character::query()->findOrFail($data['character_id']);
|
||||
|
||||
$friendship = Relationship::query()
|
||||
->where('type', Relationship::TYPE_FRIEND)
|
||||
->where('status', Relationship::STATUS_ACCEPTED)
|
||||
->where(fn ($q) => $q->where('character_a_id', $me->id)->where('character_b_id', $other->id)
|
||||
->orWhere(fn ($q2) => $q2->where('character_a_id', $other->id)->where('character_b_id', $me->id)))
|
||||
->exists();
|
||||
|
||||
if (! $friendship) {
|
||||
return response()->json(['message' => __('city.social_need_friend')], 422);
|
||||
}
|
||||
|
||||
$existingSpouse = Relationship::query()
|
||||
->where('type', Relationship::TYPE_SPOUSE)
|
||||
->where('status', Relationship::STATUS_ACCEPTED)
|
||||
->where(fn ($q) => $q->where('character_a_id', $me->id)->orWhere('character_b_id', $me->id))
|
||||
->exists();
|
||||
|
||||
if ($existingSpouse) {
|
||||
return response()->json(['message' => __('city.social_married')], 422);
|
||||
}
|
||||
|
||||
Relationship::create([
|
||||
'character_a_id' => $me->id,
|
||||
'character_b_id' => $other->id,
|
||||
'type' => Relationship::TYPE_SPOUSE,
|
||||
'status' => Relationship::STATUS_ACCEPTED,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => __('city.social_married_ok')]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Have a child with the current spouse (creates an NPC character).
|
||||
*/
|
||||
private function child(Request $request, Character $me): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['character_id' => ['required', 'integer', 'exists:characters,id']]);
|
||||
$spouse = Character::query()->findOrFail($data['character_id']);
|
||||
|
||||
$married = Relationship::query()
|
||||
->where('type', Relationship::TYPE_SPOUSE)
|
||||
->where('status', Relationship::STATUS_ACCEPTED)
|
||||
->where(fn ($q) => $q->where('character_a_id', $me->id)->where('character_b_id', $spouse->id)
|
||||
->orWhere(fn ($q2) => $q2->where('character_a_id', $spouse->id)->where('character_b_id', $me->id)))
|
||||
->exists();
|
||||
|
||||
if (! $married) {
|
||||
return response()->json(['message' => __('city.social_need_spouse')], 422);
|
||||
}
|
||||
|
||||
$childName = Str::limit($me->name.' '.$spouse->name, 40, '');
|
||||
$child = Character::create([
|
||||
'user_id' => null,
|
||||
'city_id' => $me->city_id,
|
||||
'name' => trim($childName) ?: 'Child',
|
||||
'skin' => $me->skin,
|
||||
'hair' => $me->hair,
|
||||
'shirt' => random_int(1, 5),
|
||||
'pos_x' => $me->pos_x + 1,
|
||||
'pos_z' => $me->pos_z + 1,
|
||||
'money' => 0,
|
||||
'energy' => 100,
|
||||
'hunger' => 100,
|
||||
'thirst' => 100,
|
||||
'state' => Character::STATE_AWAKE,
|
||||
'education' => 0,
|
||||
'is_online' => false,
|
||||
]);
|
||||
|
||||
foreach ([$me, $spouse] as $parent) {
|
||||
Relationship::create([
|
||||
'character_a_id' => $child->id,
|
||||
'character_b_id' => $parent->id,
|
||||
'type' => Relationship::TYPE_PARENT_CHILD,
|
||||
'status' => Relationship::STATUS_ACCEPTED,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => __('city.social_child_ok'),
|
||||
'child' => ['id' => $child->id, 'name' => $child->name],
|
||||
]);
|
||||
}
|
||||
}
|
||||
Regular → Executable
+195
-94
@@ -4,120 +4,221 @@ namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AiAgent;
|
||||
use App\Models\Building;
|
||||
use App\Models\Character;
|
||||
use App\Models\City;
|
||||
use App\Models\Job;
|
||||
use App\Models\Store;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 3D city viewer.
|
||||
* City viewer entry point and JSON payload.
|
||||
*
|
||||
* Serves the Three.js scene page plus a lightweight JSON payload of stores
|
||||
* and agents. The JSON endpoint reuses the web session so the browser can
|
||||
* fetch world data without a separate API token.
|
||||
* Renders the 3D scene for a chosen city and exposes characters, buildings
|
||||
* and jobs so the frontend can simulate a living persistent world.
|
||||
*/
|
||||
class CityViewerController extends Controller
|
||||
{
|
||||
/**
|
||||
* Render the city viewer page.
|
||||
* Render the 3D city viewer page.
|
||||
*/
|
||||
public function __invoke(): View
|
||||
public function __invoke(Request $request): View
|
||||
{
|
||||
return view('city.viewer');
|
||||
$city = $this->resolveCity($request);
|
||||
|
||||
return view('city.viewer', [
|
||||
'city' => $city,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the world snapshot consumed by the viewer scene.
|
||||
* JSON payload driving the 3D scene for the selected city.
|
||||
*/
|
||||
public function data(): JsonResponse
|
||||
public function data(Request $request): JsonResponse
|
||||
{
|
||||
$stores = Store::query()
|
||||
->get(['id', 'name', 'type', 'pos_x', 'pos_y', 'pos_z', 'width', 'height', 'depth', 'is_open'])
|
||||
->map(fn (Store $store): array => [
|
||||
'id' => $store->id,
|
||||
'name' => $store->name,
|
||||
'type' => $store->type,
|
||||
'position' => [(float) $store->pos_x, (float) $store->pos_y, (float) $store->pos_z],
|
||||
'size' => [(float) $store->width, (float) $store->height, (float) $store->depth],
|
||||
'is_open' => $store->is_open,
|
||||
]);
|
||||
$city = $this->resolveCity($request);
|
||||
$user = Auth::user();
|
||||
$me = $user ? Character::ensureForUser($user, $city) : null;
|
||||
|
||||
$agents = AiAgent::query()
|
||||
->where('status', '!=', 'paused')
|
||||
->get(['id', 'name', 'type', 'status', 'pos_x', 'pos_y', 'pos_z', 'rotation_y', 'last_decision'])
|
||||
->map(fn (AiAgent $agent): array => [
|
||||
'id' => $agent->id,
|
||||
'name' => $agent->name,
|
||||
'type' => $agent->type,
|
||||
'status' => $agent->status,
|
||||
'position' => [(float) $agent->pos_x, (float) $agent->pos_y, (float) $agent->pos_z],
|
||||
'rotation_y' => (float) $agent->rotation_y,
|
||||
'last_decision' => $agent->last_decision,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'stores' => $stores,
|
||||
'agents' => $agents,
|
||||
'layout' => self::cityLayout($stores->all()),
|
||||
])->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a deterministic street layout from store positions.
|
||||
*
|
||||
* Returns road segments and block rectangles so the frontend can build
|
||||
* a consistent grid-based city without additional database tables.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $stores
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function cityLayout(array $stores): array
|
||||
{
|
||||
if ($stores === []) {
|
||||
return ['roads' => [], 'blocks' => []];
|
||||
}
|
||||
|
||||
$xs = array_map(fn ($s) => $s['position'][0], $stores);
|
||||
$zs = array_map(fn ($s) => $s['position'][2], $stores);
|
||||
|
||||
$minX = floor(min($xs) / 40) * 40 - 40;
|
||||
$maxX = ceil(max($xs) / 40) * 40 + 40;
|
||||
$minZ = floor(min($zs) / 40) * 40 - 40;
|
||||
$maxZ = ceil(max($zs) / 40) * 40 + 40;
|
||||
|
||||
$roads = [];
|
||||
|
||||
// Vertical roads every 40 units.
|
||||
for ($x = $minX; $x <= $maxX; $x += 40) {
|
||||
$roads[] = [
|
||||
'from' => [(float) $x, 0.0, (float) $minZ],
|
||||
'to' => [(float) $x, 0.0, (float) $maxZ],
|
||||
'width' => 8.0,
|
||||
'axis' => 'z',
|
||||
];
|
||||
}
|
||||
|
||||
// Horizontal roads every 40 units.
|
||||
for ($z = $minZ; $z <= $maxZ; $z += 40) {
|
||||
$roads[] = [
|
||||
'from' => [(float) $minX, 0.0, (float) $z],
|
||||
'to' => [(float) $maxX, 0.0, (float) $z],
|
||||
'width' => 8.0,
|
||||
'axis' => 'x',
|
||||
];
|
||||
}
|
||||
|
||||
// Blocks = cells between roads.
|
||||
$blocks = [];
|
||||
for ($x = $minX; $x < $maxX; $x += 40) {
|
||||
for ($z = $minZ; $z < $maxZ; $z += 40) {
|
||||
$blocks[] = [
|
||||
'min' => [(float) $x + 5.0, 0.0, (float) $z + 5.0],
|
||||
'max' => [(float) $x + 35.0, 0.0, (float) $z + 35.0],
|
||||
];
|
||||
if ($me && (int) $me->city_id !== (int) $city->id) {
|
||||
// Keep the player's home city authoritative when no explicit ?city= given.
|
||||
if (! $request->filled('city')) {
|
||||
$city = $me->city;
|
||||
}
|
||||
}
|
||||
|
||||
return ['roads' => $roads, 'blocks' => $blocks];
|
||||
$stores = Store::query()
|
||||
->orderBy('id')
|
||||
->get(['id', 'name', 'type', 'pos_x', 'pos_y', 'pos_z', 'width', 'height', 'depth', 'is_open', 'owner_id'])
|
||||
->map(fn (Store $s): array => [
|
||||
'id' => $s->id,
|
||||
'name' => $s->name,
|
||||
'type' => $s->type,
|
||||
'position' => [(float) $s->pos_x, (float) $s->pos_y, (float) $s->pos_z],
|
||||
'size' => [(float) $s->width, (float) $s->height, (float) $s->depth],
|
||||
'is_open' => (bool) $s->is_open,
|
||||
'owner_id' => $s->owner_id,
|
||||
]);
|
||||
|
||||
$characters = Character::query()
|
||||
->where('city_id', $city->id)
|
||||
->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' => $me !== null && (int) $me->id === (int) $c->id,
|
||||
'owner_name' => $c->user?->name,
|
||||
]);
|
||||
|
||||
$buildings = Building::query()
|
||||
->where('city_id', $city->id)
|
||||
->get()
|
||||
->map(fn (Building $b): array => [
|
||||
'id' => $b->id,
|
||||
'type' => $b->type,
|
||||
'name' => $b->name,
|
||||
'position' => [(float) $b->pos_x, 0, (float) $b->pos_z],
|
||||
'size' => [(float) $b->width, (float) $b->height, (float) $b->depth],
|
||||
'is_complete' => (bool) $b->is_complete,
|
||||
'build_progress' => (int) $b->build_progress,
|
||||
'owner_character_id' => $b->owner_character_id,
|
||||
]);
|
||||
|
||||
$jobs = Job::query()
|
||||
->where('city_id', $city->id)
|
||||
->get()
|
||||
->map(fn (Job $j): array => [
|
||||
'id' => $j->id,
|
||||
'title' => $j->title,
|
||||
'salary' => (float) $j->salary,
|
||||
'requires_education' => (int) $j->requires_education,
|
||||
'store_id' => $j->store_id,
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'city' => [
|
||||
'id' => $city->id,
|
||||
'name' => $city->name,
|
||||
'slug' => $city->slug,
|
||||
'bounds' => [
|
||||
'min_x' => (float) $city->bounds_min_x,
|
||||
'max_x' => (float) $city->bounds_max_x,
|
||||
'min_z' => (float) $city->bounds_min_z,
|
||||
'max_z' => (float) $city->bounds_max_z,
|
||||
],
|
||||
],
|
||||
'stores' => $stores,
|
||||
'characters' => $characters,
|
||||
'buildings' => $buildings,
|
||||
'jobs' => $jobs,
|
||||
'me' => $me ? [
|
||||
'id' => $me->id,
|
||||
'name' => $me->name,
|
||||
'state' => $me->state,
|
||||
'money' => (float) $me->money,
|
||||
'energy' => (int) $me->energy,
|
||||
'hunger' => (int) $me->hunger,
|
||||
'thirst' => (int) $me->thirst,
|
||||
'education' => (int) $me->education,
|
||||
'job_id' => $me->job_id,
|
||||
'job_title' => $me->job?->title,
|
||||
'house_id' => $me->house_id,
|
||||
'position' => [(float) $me->pos_x, (float) $me->pos_y, (float) $me->pos_z],
|
||||
'hospitalized_until' => $me->hospitalized_until?->toIso8601String(),
|
||||
'inventory' => $me->inventory()->get()->mapWithKeys(fn ($i) => [$i->item => (int) $i->qty])->all(),
|
||||
] : null,
|
||||
'layout' => $this->cityLayout($city, $stores->all()),
|
||||
'agents' => AiAgent::orderBy('id')->get()->map(fn ($a): array => [
|
||||
'id' => $a->id,
|
||||
'name' => $a->name,
|
||||
'type' => $a->type,
|
||||
'status' => $a->status,
|
||||
'position' => [(float) $a->pos_x, (float) $a->pos_y, (float) $a->pos_z],
|
||||
'rotation_y' => (float) $a->rotation_y,
|
||||
'current_goal' => $a->current_goal,
|
||||
'last_decision' => $a->last_decision,
|
||||
]),
|
||||
];
|
||||
|
||||
return response()->json($payload)->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the city from the query string or the active default.
|
||||
*/
|
||||
private function resolveCity(Request $request): City
|
||||
{
|
||||
$slug = $request->query('city');
|
||||
|
||||
if (is_string($slug) && $slug !== '') {
|
||||
$city = City::query()
|
||||
->where('slug', $slug)
|
||||
->orWhere('id', ctype_digit($slug) ? (int) $slug : 0)
|
||||
->first();
|
||||
|
||||
if ($city) {
|
||||
return $city;
|
||||
}
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
if ($user) {
|
||||
$character = Character::query()->where('user_id', $user->id)->first();
|
||||
if ($character?->city) {
|
||||
return $character->city;
|
||||
}
|
||||
}
|
||||
|
||||
return City::active() ?? City::query()->firstOrFail();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the street grid layout from city bounds and store anchors.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $storeRows
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function cityLayout(City $city, array $storeRows): array
|
||||
{
|
||||
$minX = (int) floor(((float) $city->bounds_min_x) / 40) * 40 - 40;
|
||||
$maxX = (int) ceil(((float) $city->bounds_max_x) / 40) * 40 + 40;
|
||||
$minZ = (int) floor(((float) $city->bounds_min_z) / 40) * 40 - 40;
|
||||
$maxZ = (int) ceil(((float) $city->bounds_max_z) / 40) * 40 + 40;
|
||||
|
||||
$roads = [];
|
||||
for ($x = $minX; $x <= $maxX; $x += 40) {
|
||||
$roads[] = ['from' => [$x, 0, $minZ], 'to' => [$x, 0, $maxZ], 'width' => 8, 'axis' => 'z'];
|
||||
}
|
||||
for ($z = $minZ; $z <= $maxZ; $z += 40) {
|
||||
$roads[] = ['from' => [$minX, 0, $z], 'to' => [$maxX, 0, $z], 'width' => 8, 'axis' => 'x'];
|
||||
}
|
||||
|
||||
$blocks = [];
|
||||
for ($x = $minX; $x < $maxX; $x += 40) {
|
||||
for ($z = $minZ; $z < $maxZ; $z += 40) {
|
||||
$blocks[] = ['min' => [$x + 5, 0, $z + 5], 'max' => [$x + 35, 0, $z + 35]];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'roads' => $roads,
|
||||
'blocks' => $blocks,
|
||||
'origin' => [0, 0, 0],
|
||||
'bounds' => [
|
||||
'min_x' => $minX,
|
||||
'max_x' => $maxX,
|
||||
'min_z' => $minZ,
|
||||
'max_z' => $maxZ,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Regular → Executable
Regular → Executable
+8
@@ -36,6 +36,14 @@ class LanguageController extends Controller
|
||||
|
||||
// Persist the cookie preference (1 year) used on subsequent visits.
|
||||
$response = back();
|
||||
$previousPath = (string) parse_url($response->getTargetUrl(), PHP_URL_PATH);
|
||||
|
||||
// The 3D viewer fetches /city/data via XHR, which can become the
|
||||
// session's "previous URL" and would dump the user on raw JSON.
|
||||
if ($previousPath === '/city/data') {
|
||||
$response = redirect()->route('city.viewer');
|
||||
}
|
||||
|
||||
$response->withCookie(cookie('city10-locale', $locale, 60 * 24 * 365, '/', null, false, true));
|
||||
|
||||
return $response;
|
||||
|
||||
Regular → Executable
@@ -2,15 +2,16 @@
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\User;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Ensures the authenticated account is still active.
|
||||
* Ensures the authenticated account is still active and approved.
|
||||
*
|
||||
* Blocks disabled (deactivated) accounts from accessing any protected
|
||||
* route, even when a previously issued token or session remains valid.
|
||||
* Blocks banned/disabled accounts and routes pending registrations to the
|
||||
* approval wait screen, even when a previously issued token or session remains.
|
||||
*/
|
||||
class EnsureUserIsActive
|
||||
{
|
||||
@@ -23,13 +24,23 @@ class EnsureUserIsActive
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user !== null && ! $user->is_active) {
|
||||
if ($user !== null && $user->status !== User::STATUS_ACTIVE) {
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'message' => __('auth.disabled'),
|
||||
'message' => $user->status === User::STATUS_PENDING
|
||||
? __('auth.pending')
|
||||
: __('auth.disabled'),
|
||||
], 403);
|
||||
}
|
||||
|
||||
if ($user->status === User::STATUS_PENDING) {
|
||||
if ($request->routeIs('account.pending', 'terms.accept', 'terms.accept.store', 'logout')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
return redirect()->route('account.pending');
|
||||
}
|
||||
|
||||
auth()->guard('web')->logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
Regular → Executable
Executable
+68
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Player-constructed structure placed on free terrain inside a city.
|
||||
*
|
||||
* Construction advances through build_progress on each world tick until the
|
||||
* building is complete; finished houses become the owner's residence.
|
||||
*/
|
||||
#[Fillable([
|
||||
'city_id', 'owner_character_id', 'type', 'name',
|
||||
'pos_x', 'pos_z', 'width', 'depth', 'height',
|
||||
'is_complete', 'build_progress',
|
||||
])]
|
||||
class Building extends Model
|
||||
{
|
||||
public const TYPE_HOUSE = 'house';
|
||||
|
||||
/**
|
||||
* Public hospital where accident victims are admitted.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const TYPE_HOSPITAL = 'hospital';
|
||||
|
||||
/**
|
||||
* City containing this building.
|
||||
*
|
||||
* @return BelongsTo<City, $this>
|
||||
*/
|
||||
public function city(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(City::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Character that commissioned the building.
|
||||
*
|
||||
* @return BelongsTo<Character, $this>
|
||||
*/
|
||||
public function owner(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Character::class, 'owner_character_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'pos_x' => 'float',
|
||||
'pos_z' => 'float',
|
||||
'width' => 'float',
|
||||
'depth' => 'float',
|
||||
'height' => 'float',
|
||||
'is_complete' => 'boolean',
|
||||
'build_progress' => 'integer',
|
||||
];
|
||||
}
|
||||
}
|
||||
Executable
+258
@@ -0,0 +1,258 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\CharacterFactory;
|
||||
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\HasMany;
|
||||
|
||||
/**
|
||||
* In-world avatar of a platform user (or NPC child without a user).
|
||||
*
|
||||
* Stores position, survival needs, employment and social state. The world
|
||||
* ticker mutates needs and job progress continuously while nobody is online.
|
||||
*/
|
||||
#[Fillable([
|
||||
'user_id', 'city_id', 'name', 'skin', 'hair', 'shirt',
|
||||
'pos_x', 'pos_y', 'pos_z', 'money', 'energy', 'hunger', 'thirst',
|
||||
'state', 'job_id', 'house_id', 'education', 'is_online', 'last_tick_at',
|
||||
'hospitalized_until',
|
||||
])]
|
||||
class Character extends Model
|
||||
{
|
||||
/** @use HasFactory<CharacterFactory, $this> */
|
||||
use HasFactory;
|
||||
|
||||
public const STATE_AWAKE = 'awake';
|
||||
|
||||
public const STATE_SLEEPING = 'sleeping';
|
||||
|
||||
public const STATE_WORKING = 'working';
|
||||
|
||||
public const STATE_STUDYING = 'studying';
|
||||
|
||||
/**
|
||||
* Character admitted to the hospital after a traffic accident.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const STATE_HOSPITAL = 'hospital';
|
||||
|
||||
/**
|
||||
* Hospital stay length in seconds (two game days of 240s each).
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public const HOSPITAL_STAY_SECONDS = 480;
|
||||
|
||||
/**
|
||||
* Default hospital bed coordinates used when no hospital building exists.
|
||||
* Kept mid-block (away from road lanes) so vehicles never hit the bed.
|
||||
*
|
||||
* @var array{0: float, 1: float}
|
||||
*/
|
||||
public const HOSPITAL_POS = [60.0, -51.0];
|
||||
|
||||
/**
|
||||
* Owning user account (null for NPC children).
|
||||
*
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* City where this character lives.
|
||||
*
|
||||
* @return BelongsTo<City, $this>
|
||||
*/
|
||||
public function city(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(City::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Current job position, when employed.
|
||||
*
|
||||
* @return BelongsTo<Job, $this>
|
||||
*/
|
||||
public function job(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Job::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete house owned by this character, when any.
|
||||
*
|
||||
* @return BelongsTo<Building, $this>
|
||||
*/
|
||||
public function house(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Building::class, 'house_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Buildings constructed by this character.
|
||||
*
|
||||
* @return HasMany<Building, $this>
|
||||
*/
|
||||
public function buildings(): HasMany
|
||||
{
|
||||
return $this->hasMany(Building::class, 'owner_character_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Inventory stacks owned by this character.
|
||||
*
|
||||
* @return HasMany<InventoryItem, $this>
|
||||
*/
|
||||
public function inventory(): HasMany
|
||||
{
|
||||
return $this->hasMany(InventoryItem::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Character of a linked user, when this account has one.
|
||||
*/
|
||||
public function forUser(int $userId): bool
|
||||
{
|
||||
return (int) $this->user_id === $userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the character for an approved user in the given city.
|
||||
*/
|
||||
public static function ensureForUser(User $user, ?City $city = null): self
|
||||
{
|
||||
$existing = self::query()->where('user_id', $user->id)->first();
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$city ??= City::active();
|
||||
abort_if($city === null, 500, 'No active city available.');
|
||||
|
||||
$firstName = trim(explode(' ', $user->name)[0] ?? $user->email);
|
||||
|
||||
$character = self::create([
|
||||
'user_id' => $user->id,
|
||||
'city_id' => $city->id,
|
||||
'name' => $firstName,
|
||||
'skin' => random_int(1, 4),
|
||||
'hair' => random_int(1, 4),
|
||||
'shirt' => random_int(1, 5),
|
||||
'pos_x' => 0,
|
||||
'pos_y' => 0,
|
||||
'pos_z' => 25,
|
||||
'money' => 100,
|
||||
'energy' => 100,
|
||||
'hunger' => 100,
|
||||
'thirst' => 100,
|
||||
'state' => self::STATE_AWAKE,
|
||||
'education' => 0,
|
||||
'is_online' => false,
|
||||
]);
|
||||
|
||||
// Starter kit so new citizens can eat, drink and build immediately.
|
||||
foreach ([
|
||||
InventoryItem::ITEM_FOOD => 3,
|
||||
InventoryItem::ITEM_WATER => 3,
|
||||
InventoryItem::ITEM_MATERIALS => 60,
|
||||
] as $item => $qty) {
|
||||
InventoryItem::create([
|
||||
'character_id' => $character->id,
|
||||
'item' => $item,
|
||||
'qty' => $qty,
|
||||
]);
|
||||
}
|
||||
|
||||
return $character;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'pos_x' => 'float',
|
||||
'pos_y' => 'float',
|
||||
'pos_z' => 'float',
|
||||
'money' => 'float',
|
||||
'energy' => 'integer',
|
||||
'hunger' => 'integer',
|
||||
'thirst' => 'integer',
|
||||
'education' => 'integer',
|
||||
'is_online' => 'boolean',
|
||||
'last_tick_at' => 'datetime',
|
||||
'hospitalized_until' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the character is currently admitted to the hospital.
|
||||
*/
|
||||
public function isHospitalized(): bool
|
||||
{
|
||||
return $this->state === self::STATE_HOSPITAL
|
||||
&& $this->hospitalized_until !== null
|
||||
&& $this->hospitalized_until->isFuture();
|
||||
}
|
||||
|
||||
/**
|
||||
* Admit the character to the hospital for two game days after an accident.
|
||||
*
|
||||
* Teleports them to the city hospital bed and returns the bed position.
|
||||
*
|
||||
* @return array{0: float, 1: float}
|
||||
*/
|
||||
public function admitToHospital(): array
|
||||
{
|
||||
$hospital = Building::query()
|
||||
->where('city_id', $this->city_id)
|
||||
->where('type', Building::TYPE_HOSPITAL)
|
||||
->where('is_complete', true)
|
||||
->first();
|
||||
|
||||
$x = $hospital ? (float) $hospital->pos_x : self::HOSPITAL_POS[0];
|
||||
$z = $hospital ? (float) ($hospital->pos_z + $hospital->depth / 2 + 3) : self::HOSPITAL_POS[1];
|
||||
|
||||
$this->forceFill([
|
||||
'state' => self::STATE_HOSPITAL,
|
||||
'hospitalized_until' => now()->addSeconds(self::HOSPITAL_STAY_SECONDS),
|
||||
'pos_x' => $x,
|
||||
'pos_z' => $z,
|
||||
'pos_y' => 0,
|
||||
])->save();
|
||||
|
||||
return [$x, $z];
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the character from the hospital when the stay has elapsed.
|
||||
*/
|
||||
public function releaseIfHospitalStayOver(): bool
|
||||
{
|
||||
if ($this->state !== self::STATE_HOSPITAL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->hospitalized_until === null || $this->hospitalized_until->isPast()) {
|
||||
$this->forceFill([
|
||||
'state' => self::STATE_AWAKE,
|
||||
'hospitalized_until' => null,
|
||||
])->save();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Chat message exchanged between two characters.
|
||||
*/
|
||||
#[Fillable(['from_character_id', 'to_character_id', 'body'])]
|
||||
class CharacterMessage extends Model
|
||||
{
|
||||
/**
|
||||
* Sending character.
|
||||
*
|
||||
* @return BelongsTo<Character, $this>
|
||||
*/
|
||||
public function fromCharacter(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Character::class, 'from_character_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiving character.
|
||||
*
|
||||
* @return BelongsTo<Character, $this>
|
||||
*/
|
||||
public function toCharacter(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Character::class, 'to_character_id');
|
||||
}
|
||||
}
|
||||
Executable
+113
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\CityFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* Geographic territory of a virtual city.
|
||||
*
|
||||
* Bounds drive street layout generation, movement clamping and construction
|
||||
* limits. Administrators may expand bounds or create additional cities.
|
||||
*/
|
||||
#[Fillable([
|
||||
'name', 'slug', 'bounds_min_x', 'bounds_max_x', 'bounds_min_z', 'bounds_max_z',
|
||||
'is_active', 'expansions',
|
||||
])]
|
||||
class City extends Model
|
||||
{
|
||||
/** @use HasFactory<CityFactory, $this> */
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* Maximum number of territory expansions allowed per city.
|
||||
*/
|
||||
public const MAX_EXPANSIONS = 10;
|
||||
|
||||
/**
|
||||
* Characters living in this city.
|
||||
*
|
||||
* @return HasMany<Character, $this>
|
||||
*/
|
||||
public function characters(): HasMany
|
||||
{
|
||||
return $this->hasMany(Character::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Buildings placed inside this city.
|
||||
*
|
||||
* @return HasMany<Building, $this>
|
||||
*/
|
||||
public function buildings(): HasMany
|
||||
{
|
||||
return $this->hasMany(Building::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Job positions offered in this city.
|
||||
*
|
||||
* @return HasMany<Job, $this>
|
||||
*/
|
||||
public function jobs(): HasMany
|
||||
{
|
||||
return $this->hasMany(Job::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the active city used as the default world.
|
||||
*/
|
||||
public static function active(): ?self
|
||||
{
|
||||
return self::query()->where('is_active', true)->orderBy('id')->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand territory eastward and northward by one grid step (40 units).
|
||||
*
|
||||
* Returns false when the expansion cap has been reached.
|
||||
*/
|
||||
public function expand(): bool
|
||||
{
|
||||
if ($this->expansions >= self::MAX_EXPANSIONS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->bounds_max_x = (float) $this->bounds_max_x + 40;
|
||||
$this->bounds_max_z = (float) $this->bounds_max_z + 40;
|
||||
$this->expansions = (int) $this->expansions + 1;
|
||||
$this->save();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a world coordinate lies inside the city bounds.
|
||||
*/
|
||||
public function contains(float $x, float $z): bool
|
||||
{
|
||||
return $x >= (float) $this->bounds_min_x && $x <= (float) $this->bounds_max_x
|
||||
&& $z >= (float) $this->bounds_min_z && $z <= (float) $this->bounds_max_z;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'bounds_min_x' => 'float',
|
||||
'bounds_max_x' => 'float',
|
||||
'bounds_min_z' => 'float',
|
||||
'bounds_max_z' => 'float',
|
||||
'is_active' => 'boolean',
|
||||
'expansions' => 'integer',
|
||||
];
|
||||
}
|
||||
}
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Stackable inventory row owned by a character.
|
||||
*/
|
||||
#[Fillable(['character_id', 'item', 'qty'])]
|
||||
class InventoryItem extends Model
|
||||
{
|
||||
public const ITEM_FOOD = 'food';
|
||||
|
||||
public const ITEM_WATER = 'water';
|
||||
|
||||
public const ITEM_MATERIALS = 'materials';
|
||||
|
||||
public const ITEM_PRODUCT = 'product';
|
||||
|
||||
/**
|
||||
* Owning character.
|
||||
*
|
||||
* @return BelongsTo<Character, $this>
|
||||
*/
|
||||
public function character(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Character::class);
|
||||
}
|
||||
}
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Employment position offered inside a city (table: job_positions).
|
||||
*
|
||||
* Characters hold at most one job; salaries accrue while state=working.
|
||||
*/
|
||||
#[Fillable(['city_id', 'store_id', 'title', 'salary', 'requires_education'])]
|
||||
class Job extends Model
|
||||
{
|
||||
/**
|
||||
* Eloquent table name (queue already owns "jobs".
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'job_positions';
|
||||
|
||||
/**
|
||||
* City offering the position.
|
||||
*
|
||||
* @return BelongsTo<City, $this>
|
||||
*/
|
||||
public function city(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(City::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store providing the position, when commercial.
|
||||
*
|
||||
* @return BelongsTo<Store, $this>
|
||||
*/
|
||||
public function store(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Store::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'salary' => 'float',
|
||||
'requires_education' => 'integer',
|
||||
];
|
||||
}
|
||||
}
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Social edge between two characters (friend, spouse, parent-child).
|
||||
*
|
||||
* For parent_child links character_a is the child and character_b is the parent.
|
||||
*/
|
||||
#[Fillable(['character_a_id', 'character_b_id', 'type', 'status'])]
|
||||
class Relationship extends Model
|
||||
{
|
||||
public const TYPE_FRIEND = 'friend';
|
||||
|
||||
public const TYPE_SPOUSE = 'spouse';
|
||||
|
||||
public const TYPE_PARENT_CHILD = 'parent_child';
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_ACCEPTED = 'accepted';
|
||||
|
||||
/**
|
||||
* First character participant.
|
||||
*
|
||||
* @return BelongsTo<Character, $this>
|
||||
*/
|
||||
public function characterA(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Character::class, 'character_a_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Second character participant.
|
||||
*
|
||||
* @return BelongsTo<Character, $this>
|
||||
*/
|
||||
public function characterB(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Character::class, 'character_b_id');
|
||||
}
|
||||
}
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Audit record of an economic exchange involving characters and/or stores.
|
||||
*/
|
||||
#[Fillable(['from_character_id', 'to_character_id', 'store_id', 'item', 'qty', 'amount'])]
|
||||
class Transaction extends Model
|
||||
{
|
||||
/**
|
||||
* Character that paid or gave items.
|
||||
*
|
||||
* @return BelongsTo<Character, $this>
|
||||
*/
|
||||
public function fromCharacter(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Character::class, 'from_character_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Character that received payment or items.
|
||||
*
|
||||
* @return BelongsTo<Character, $this>
|
||||
*/
|
||||
public function toCharacter(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Character::class, 'to_character_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Store involved in the exchange, when any.
|
||||
*
|
||||
* @return BelongsTo<Store, $this>
|
||||
*/
|
||||
public function store(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Store::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'amount' => 'float',
|
||||
'qty' => 'integer',
|
||||
];
|
||||
}
|
||||
}
|
||||
+51
-1
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
@@ -19,7 +20,7 @@ use Laravel\Sanctum\HasApiTokens;
|
||||
* acceptance of versioned legal documents (Terms, Privacy, Cookies)
|
||||
* required by the Brazilian LGPD.
|
||||
*/
|
||||
#[Fillable(['name', 'email', 'password', 'role', 'locale', 'is_active'])]
|
||||
#[Fillable(['name', 'email', 'password', 'role', 'status', 'locale', 'is_active'])]
|
||||
#[Hidden(['password', 'remember_token'])]
|
||||
class User extends Authenticatable
|
||||
{
|
||||
@@ -40,6 +41,37 @@ class User extends Authenticatable
|
||||
*/
|
||||
public const ROLE_USER = 'user';
|
||||
|
||||
/**
|
||||
* Account registered but not yet approved by an administrator.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
/**
|
||||
* Account approved and able to enter the platform.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
|
||||
/**
|
||||
* Account blocked by an administrator.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const STATUS_BANNED = 'banned';
|
||||
|
||||
/**
|
||||
* In-world character belonging to this user.
|
||||
*
|
||||
* @return HasOne<Character, $this>
|
||||
*/
|
||||
public function character(): HasOne
|
||||
{
|
||||
return $this->hasOne(Character::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores owned by this user.
|
||||
*
|
||||
@@ -80,6 +112,14 @@ class User extends Authenticatable
|
||||
return $this->role === self::ROLE_ADMIN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the account has been approved by an administrator.
|
||||
*/
|
||||
public function isApproved(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_ACTIVE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the user accepted every current legal document version.
|
||||
*/
|
||||
@@ -96,6 +136,16 @@ class User extends Authenticatable
|
||||
return $currentIds->diff($acceptedIds)->isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the legacy is_active flag mirrored from status on save.
|
||||
*/
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (self $user): void {
|
||||
$user->is_active = $user->status === self::STATUS_ACTIVE;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user