- Player-to-player marketplace with listings, buying and selling - Mission system with progress tracking and money rewards - Character appearance customization (skin, hair, face, beard, accessory) - Multiplayer presence feed showing nearby citizens in near-real-time - Admin world dashboard with live platform metrics - Bus and train transportation with temporary ride state - Mobile joystick controls and minimap HUD - Education bonus applied to work salary
203 lines
7.2 KiB
PHP
Executable File
203 lines
7.2 KiB
PHP
Executable File
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Models\Building;
|
|
use App\Models\Character;
|
|
use App\Support\MissionTracker;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
// Temporary bus/train rides expire after ride_until.
|
|
if ($character->ride_kind !== null && $character->ride_until !== null
|
|
&& $character->ride_until->isPast()) {
|
|
$character->forceFill(['ride_kind' => null, 'ride_until' => null])->save();
|
|
}
|
|
|
|
$elapsedMin = 1.0;
|
|
if ($character->last_tick_at !== null) {
|
|
$elapsedMin = max(0.5, $character->last_tick_at->diffInMinutes($now));
|
|
}
|
|
|
|
// 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);
|
|
// Education boosts take-home pay up to +50% at 100% education.
|
|
$bonus = 1 + (min(100, (int) $character->education) / 200);
|
|
$character->money = round((float) $character->money + ($salary * $bonus / 30) * $elapsedMin, 2);
|
|
}
|
|
$character->energy = max(0, (int) $character->energy - (int) round(1.2 * $elapsedMin));
|
|
break;
|
|
|
|
case Character::STATE_STUDYING:
|
|
$character->education = min(100, (int) $character->education + (int) round(0.5 * $elapsedMin));
|
|
$character->energy = max(0, (int) $character->energy - (int) round(0.8 * $elapsedMin));
|
|
MissionTracker::sync($character, 'education');
|
|
break;
|
|
|
|
case Character::STATE_EXERCISING:
|
|
$character->energy = max(0, (int) $character->energy - (int) round(1.0 * $elapsedMin));
|
|
if ((int) $character->energy <= 5) {
|
|
$character->state = Character::STATE_AWAKE;
|
|
}
|
|
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;
|
|
}
|
|
|
|
// Offline exercise sessions end after the next tick so NPCs recover.
|
|
if ($character->state === Character::STATE_EXERCISING) {
|
|
$character->state = Character::STATE_AWAKE;
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|