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; } } }