- 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
179 lines
6.2 KiB
PHP
Executable File
179 lines
6.2 KiB
PHP
Executable File
<?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;
|
|
}
|
|
}
|
|
}
|