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:
2026-10-17 21:46:23 -03:00
parent 79d12fa2f4
commit 79ce2a1825
132 changed files with 11809 additions and 1036 deletions
+68
View File
@@ -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',
];
}
}
+258
View File
@@ -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;
}
}
+34
View File
@@ -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');
}
}
+113
View File
@@ -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',
];
}
}
+32
View File
@@ -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);
}
}
+56
View File
@@ -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',
];
}
}
+46
View File
@@ -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');
}
}
+57
View File
@@ -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
View File
@@ -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.
*