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
+178
View File
@@ -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;
}
}
}