From 6f80be1f4c21a9fb2be8905eb86a5826124dfb88 Mon Sep 17 00:00:00 2001 From: novgorodschi catalin Date: Thu, 23 Jul 2026 09:43:16 +0300 Subject: [PATCH] Add Cron Jobs for Automation Add Cron Jobs for Automation --- Admin/Templates/config.tpl | 89 ++++++++ Admin/Templates/editCronSet.tpl | 146 ++++++++++++ Admin/admin.php | 5 + GameEngine/Admin/Mods/config_template.php | 47 +++- GameEngine/Admin/Mods/editCronSet.php | 146 ++++++++++++ GameEngine/Village.php | 186 +++++++++------- README.md | 2 + cron.php | 256 ++++++++++++++++++---- install/data/constant_format.tpl | 20 ++ install/process.php | 7 + 10 files changed, 776 insertions(+), 128 deletions(-) create mode 100644 Admin/Templates/editCronSet.tpl create mode 100644 GameEngine/Admin/Mods/editCronSet.php diff --git a/Admin/Templates/config.tpl b/Admin/Templates/config.tpl index c813f2e8..4a2df605 100644 --- a/Admin/Templates/config.tpl +++ b/Admin/Templates/config.tpl @@ -95,6 +95,95 @@ $editIcon = ' + + 0 ? (time() - $cronLastRun) : -1; +$cronActive = ($cronAge >= 0 && $cronAge < 90); + +$cronLoop = defined('CRON_LOOP_SECONDS') ? (int) CRON_LOOP_SECONDS : 300; +$cronTick = defined('CRON_TICK_SECONDS') ? (int) CRON_TICK_SECONDS : 60; +$cronKey = defined('CRON_KEY') ? (string) CRON_KEY : ''; + +$cronBase = defined('HOMEPAGE') ? rtrim(HOMEPAGE, '/') : ''; +$cronUrl = ($cronBase !== '' && $cronKey !== '') ? $cronBase . '/cron.php?key=' . $cronKey : ''; + +$cronPath = realpath(__DIR__ . '/../../cron.php'); +$cronCmd = $cronPath ? '*/5 * * * * /usr/local/bin/php ' . $cronPath . ' >/dev/null 2>&1' : ''; + +// cheia afisata mascat; se dezvaluie la cerere (evita expunerea in screenshot-uri) +$cronKeyMasked = ($cronKey === '') + ? '' + : (strlen($cronKey) > 12 ? substr($cronKey, 0, 6) . str_repeat('•', 8) . substr($cronKey, -4) : str_repeat('•', 8)); +?> +
+
Cron & Automation
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Automation source ?Where the game tick (battles, movements, training, construction) is processed from. When the cron job is running, players' page loads no longer carry the processing.Cron job" + : "Page loads (fallback)"; ?>
Last cron tick ?Written by cron.php on every tick into GameEngine/Prevention/cron_active.txt. Under 90 seconds means the cron is alive. 0) { + echo date('d.m.Y H:i:s', $cronLastRun) . ' (' . $cronAge . 's ago)'; + } else { + echo "Never"; + } + ?>
Invocation length ?CRON_LOOP_SECONDS - how long one cron.php invocation keeps running. Hosts that only allow a cron every 5 minutes still get a tick every minute this way. 0 = a single tick per invocation. 0 + ? $cronLoop . ' s (' . (int) floor($cronLoop / max($cronTick, 1)) . ' ticks per invocation)' + : "Single tick"; ?>
Tick interval ?CRON_TICK_SECONDS - how often Automation runs inside one invocation. Automation itself expects to run about every 60 seconds. s
HTTP trigger key ?CRON_KEY - only needed to call cron.php over HTTP (external cron service). Running it from the server's own cron job does not use this key. Generated automatically at install. + Not set + + + + show +
HTTP trigger URL ?Optional. Use this with an external cron service if the host cannot run a local cron job. One HTTP call = one tick.hidden – use "show" above +
Cron job command ?Add this in cPanel → Cron Jobs. The path is detected from this installation.
+
+
New Mechanics and Functions
diff --git a/Admin/Templates/editCronSet.tpl b/Admin/Templates/editCronSet.tpl new file mode 100644 index 00000000..b3bc22ee --- /dev/null +++ b/Admin/Templates/editCronSet.tpl @@ -0,0 +1,146 @@ + 0 ? (time() - $cronLastRun) : -1; +$cronActive = ($cronAge >= 0 && $cronAge < 90); + +$cronLoop = defined('CRON_LOOP_SECONDS') ? (int) CRON_LOOP_SECONDS : 300; +$cronTick = defined('CRON_TICK_SECONDS') ? (int) CRON_TICK_SECONDS : 60; +$cronKey = defined('CRON_KEY') ? (string) CRON_KEY : ''; + +$cronPath = realpath(__DIR__ . '/../../cron.php'); +$cronCmd = $cronPath ? '*/5 * * * * /usr/local/bin/php ' . $cronPath . ' >> ' . dirname(dirname($cronPath)) . '/cron.log 2>&1' : ''; +?> + + +
+
Cron & Automation
+ +
+ + + +
+
Current status
+ + + + + + + + + + + + + + + +
Automation sourceCron job" + : "Page loads (fallback)"; ?> + When the cron job is not running, the game still works: players' page loads process the tick, exactly as before the cron was introduced.
Last cron tick 0) { + echo date('d.m.Y H:i:s', $cronLastRun) . ' (' . $cronAge . 's ago)'; + } else { + echo "Never"; + } + ?>
Cron job command + Add this in cPanel → Cron Jobs. Writing to cron.log instead of /dev/null lets you see errors if it fails to start.
+
+ +
+
Settings
+ + + + + + + + + + + + + +
Invocation length (seconds) + ?How long one cron.php invocation keeps working. Most shared hosts only allow a cron every 5 minutes, while Automation expects to run about every minute — so one invocation runs several ticks in a row. Use 300 for a "*/5" cron. Set 0 only if your host allows a cron every minute. + + 0 = a single tick per invocation. Maximum 3300 (55 minutes).
Tick interval (seconds) + ?How often Automation runs inside one invocation. Automation's own guard expects roughly 60 seconds; going lower mostly adds load without processing anything new. + + Recommended: 60. Allowed range 15–900.
HTTP trigger key + ?Only needed when calling cron.php over HTTP (for example from an external cron service). The server's own cron job does not use it. Regenerating invalidates any URL you configured elsewhere. + + + Not set – one will be generated on save. + + •••••••• + + show + + +
+
+ +
+ + +
+
+
diff --git a/Admin/admin.php b/Admin/admin.php index 26b62c73..291dfaa0 100644 --- a/Admin/admin.php +++ b/Admin/admin.php @@ -94,6 +94,7 @@ function admin_validated_page(string $raw): string 'maintenenceResetPlus', 'givePlusRes', 'maintenenceResetPlusBonus', 'addUsers', 'users', 'admin_log', 'config', 'debug_log', 'editServerSet', 'editPlusSet', 'editLogSet', 'editNewsboxSet', + 'editCronSet', 'editExtraSet', 'editAdminInfo', 'resetServer', 'player', 'editUser', 'deletion', 'Newmessage', 'editPlus', 'editSitter', 'editPassword', 'editProtection', 'editOverall', @@ -302,6 +303,10 @@ if ($page !== '') { $subpage = 'Server Configuration'; break; + case 'editCronSet': + $subpage = 'Cron & Automation'; + break; + case 'editPlusSet': $subpage = 'PLUS Settings'; break; diff --git a/GameEngine/Admin/Mods/config_template.php b/GameEngine/Admin/Mods/config_template.php index a3c1e46b..5c9a6419 100644 --- a/GameEngine/Admin/Mods/config_template.php +++ b/GameEngine/Admin/Mods/config_template.php @@ -39,9 +39,52 @@ if (!function_exists('admin_config_template_path')) { return admin_config_template_path() !== false; } - // Raw template contents (placeholders untouched), or false when unavailable. + // Raw template contents, or false when unavailable. + // + // Placeholders are left untouched for the caller to fill in, with ONE + // exception: %CRONKEY%. Every edit*.php mod regenerates config.php from this + // template and replaces only the placeholders it knows about, so a value that + // no mod handles would be written to config.php literally — breaking the key + // used to call cron.php over HTTP. CRON_KEY is not something the admin edits + // in any of those forms, so it is resolved here, once, for all callers: + // - existing server -> keep the current CRON_KEY (survives config saves) + // - key not defined -> provision a fresh random one (servers installed + // before CRON_KEY existed in the template) function admin_config_template_contents() { $path = admin_config_template_path(); - return $path !== false ? file_get_contents($path) : false; + + if ($path === false) { + return false; + } + + $text = file_get_contents($path); + + if ($text === false) { + return false; + } + + if (strpos($text, '%CRONKEY%') !== false) { + $cronKey = (defined('CRON_KEY') && CRON_KEY !== '' && CRON_KEY !== '%CRONKEY%') + ? CRON_KEY + : bin2hex(random_bytes(24)); + + $text = str_replace('%CRONKEY%', $cronKey, $text); + } + + // Acelasi tratament pentru durata ciclului si intervalul de tick: sunt + // editate din ACP (editCronSet), deci orice alt mod care regenereaza + // config.php trebuie sa PASTREZE valorile curente, nu sa le reseteze la + // cele din template. + if (strpos($text, '%CRONLOOP%') !== false) { + $cronLoop = defined('CRON_LOOP_SECONDS') ? (int) CRON_LOOP_SECONDS : 300; + $text = str_replace('%CRONLOOP%', (string) $cronLoop, $text); + } + + if (strpos($text, '%CRONTICK%') !== false) { + $cronTick = defined('CRON_TICK_SECONDS') ? (int) CRON_TICK_SECONDS : 60; + $text = str_replace('%CRONTICK%', (string) $cronTick, $text); + } + + return $text; } } diff --git a/GameEngine/Admin/Mods/editCronSet.php b/GameEngine/Admin/Mods/editCronSet.php new file mode 100644 index 00000000..56be5938 --- /dev/null +++ b/GameEngine/Admin/Mods/editCronSet.php @@ -0,0 +1,146 @@ + 3300) { $loop = 3300; } + +// Automation se asteapta sa ruleze pe la 60s; sub 15s doar incarcam serverul. +if ($tick < 15) { $tick = 15; } +if ($tick > 900) { $tick = 900; } + +// Un ciclu care nu incape nici macar un tick nu are sens - il tratam ca "o +// singura rulare", ca sa nu iasa o configuratie care pare activa dar nu e. +if ($loop > 0 && $loop < $tick) { + $loop = 0; +} + +$regenerateKey = !empty($_POST['regenerate_key']); + +$cronKey = (defined('CRON_KEY') && CRON_KEY !== '' && CRON_KEY !== '%CRONKEY%') + ? CRON_KEY + : ''; + +if ($regenerateKey || $cronKey === '') { + $cronKey = bin2hex(random_bytes(24)); +} + +// --------------------------------------------------------------------------- +// Editarea chirurgicala a fisierului de configuratie +// --------------------------------------------------------------------------- +$configFile = "../../config.php"; +$config = @file_get_contents($configFile); + +if ($config === false) { + die("


Can't read file: GameEngine\\config.php"); +} + +/** + * Inlocuieste valoarea unei constante daca linia exista; altfel intoarce false + * ca apelantul sa stie ca trebuie inserata. + * + * $rawValue se scrie exact asa cum e dat (deja citat pentru siruri). + */ +function cron_set_define(&$text, $constant, $rawValue) +{ + $pattern = "/define\s*\(\s*(['\"])" . preg_quote($constant, '/') . "\\1\s*,.*?\)\s*;/s"; + + if (!preg_match($pattern, $text)) { + return false; + } + + // str_replace pe potrivirea gasita, ca sa nu interpretam $ sau \ din valoare + // ca referinte de grup (capcana clasica a lui preg_replace). + preg_match($pattern, $text, $m); + $text = str_replace($m[0], "define('" . $constant . "', " . $rawValue . ");", $text); + + return true; +} + +$missing = array(); + +if (!cron_set_define($config, 'CRON_LOOP_SECONDS', (string) $loop)) { + $missing[] = "define('CRON_LOOP_SECONDS', " . $loop . ");"; +} + +if (!cron_set_define($config, 'CRON_TICK_SECONDS', (string) $tick)) { + $missing[] = "define('CRON_TICK_SECONDS', " . $tick . ");"; +} + +if (!cron_set_define($config, 'CRON_KEY', "'" . addcslashes($cronKey, "'\\") . "'")) { + $missing[] = "define('CRON_KEY', '" . addcslashes($cronKey, "'\\") . "');"; +} + +// Server instalat inainte ca aceste constante sa existe: le adaugam dupa +// AUTOMATION_LOCK_FILE_NAME (locul lor din template). +if (!empty($missing)) { + $anchorPattern = "/define\s*\(\s*(['\"])AUTOMATION_LOCK_FILE_NAME\\1\s*,.*?\)\s*;/s"; + + $block = "\n\n//////////////////////////////////\n" + . "// ***** CRON / AUTOMATION *****//\n" + . "//////////////////////////////////\n" + . implode("\n", $missing); + + if (preg_match($anchorPattern, $config, $anchor)) { + $config = str_replace($anchor[0], $anchor[0] . $block, $config); + } else { + // fara ancora: adaugam la finalul fisierului, inaintea eventualului + // tag de inchidere PHP (atentie: nu-l scriem literal in comentariu, + // fiindca intr-un comentariu "//" ar inchide chiar blocul PHP curent) + $config = preg_replace('/\?>\s*$/', '', $config) . $block . "\n"; + } +} + +$fh = @fopen($configFile, 'w') or die("


Can't open file: GameEngine\\config.php"); +fwrite($fh, $config); +fclose($fh); + +$database->query( + "Insert into " . TB_PREFIX . "admin_log values (0," . $id . ",'Changed Cron & Automation Settings'," . time() . ")" +); + +header("Location: ../../../Admin/admin.php?p=config"); diff --git a/GameEngine/Village.php b/GameEngine/Village.php index 1d34bd7c..14fa4108 100755 --- a/GameEngine/Village.php +++ b/GameEngine/Village.php @@ -18,12 +18,12 @@ ## License : TravianZ Project ## ## Copyright : TravianZ (c) 2010-2026. All rights reserved. ## ## --------------------------------------------------------------------------- ## -## Refactor D1 : clamp productie in memorie = clamp SQL; 1 UPDATE/hit; ## -## eliminat wid=wref; flock pe lock-ul de automation ## -## Refactor W1 : getProdFor() unic (fara cele 4 metode copy-paste); ## -## scan f1..f38 o singura data; globals trase in constructor ## -## Refactor W2 : constructor fara side effects; tick() + ActionControl() ## -## explicite in bootstrap; garduri statice eliminate ## +## Refactor D1 : in-memory production clamp = SQL clamp; 1 UPDATE/hit; ## +## removed wid=wref; flock on the automation lock ## +## Refactor W1 : single getProdFor() (no 4 copy-paste methods); ## +## scan f1..f38 only once; globals pulled in constructor ## +## Refactor W2 : constructor without side effects; explicit tick() + ## +## ActionControl() in bootstrap; static guards removed ## ################################################################################# include_once("Session.php"); @@ -52,20 +52,20 @@ class Village { private $oasisowned, $ocounter = []; private $oasisFactor = 0.25; - // W1 #6: dependinte trase o singura data din globals, in constructor + // W1 #6: dependencies pulled from globals once, in the constructor private $db; private $sess; private $tech; private $bids = []; - // W1 #4: index-ul campurilor f1..f38, construit lazy, o data per incarcare + // W1 #4: index of the f1..f38 fields, built lazily, once per load private $fieldIndex = null; /** - * W1 #4: configuratia per resursa pentru getProdFor(). - * field = tipul campului de productie (si nr. tabelei $bidN de productie), - * bonus = gid-urile cladirilor bonus (Sawmill/Brickyard/Foundry/Grain Mill+Bakery), - * oasis = indexul in $this->ocounter, sessionBonus = flag-ul de bonus 25%. + * W1 #4: per-resource configuration for getProdFor(). + * field = the production field type (and the number of the $bidN production table), + * bonus = the gids of the bonus buildings (Sawmill/Brickyard/Foundry/Grain Mill+Bakery), + * oasis = the index into $this->ocounter, sessionBonus = the 25% bonus flag. */ private const PROD_CONFIG = [ 'wood' => ['field' => 1, 'bonus' => [5], 'oasis' => 0, 'sessionBonus' => 'bonus1'], @@ -75,11 +75,11 @@ class Village { ]; /** - * W2 #7: constructorul DOAR incarca. Fara scrieri in DB, fara redirect, - * fara tick de productie. "new Village" e sigur de apelat oriunde - * (instante multiple, admin switch). Gardurile statice loadedWid / - * cacheCleared au fost eliminate (W2 #10) - nu mai sunt necesare cu - * side effects explicite. + * W2 #7: the constructor ONLY loads. No DB writes, no redirect, + * no production tick. "new Village" is safe to call anywhere + * (multiple instances, admin switch). The static loadedWid / + * cacheCleared guards were removed (W2 #10) - they are no longer + * needed now that side effects are explicit. */ function __construct() { global $database, $session, $technology, @@ -91,7 +91,7 @@ class Village { $this->bids = [ 1 => $bid1, 2 => $bid2, 3 => $bid3, 4 => $bid4, 5 => $bid5, 6 => $bid6, 7 => $bid7, 8 => $bid8, 9 => $bid9, - 45 => $bid45, // Waterworks (Egipteni) - folosit de getOasisBonusFactor() + 45 => $bid45, // Waterworks (Egyptians) - used by getOasisBonusFactor() ]; // set village id @@ -113,15 +113,15 @@ class Village { } /** - * W2 #8: tick explicit - calculeaza si aplica productia. Apelat din - * bootstrap-ul de la finalul fisierului (nu din dorf1/dorf2 individual: - * 28 de pagini includ Village.php si toate au nevoie de resurse la zi). + * W2 #8: explicit tick - computes and applies production. Called from the + * bootstrap at the end of this file (not from dorf1/dorf2 individually: + * 28 pages include Village.php and all of them need up-to-date resources). */ public function tick(): void { - // W2 #10: clear-ul de cache era gardat de static::$cacheCleared ca sa - // ruleze o data per request; cu tick explicit (apelat o data, din - // bootstrap) gardul nu mai e necesar. Un clear repetat la o a doua - // instanta inseamna doar cache rece, nu un bug de corectitudine. + // W2 #10: the cache clear used to be guarded by static::$cacheCleared so it + // would run once per request; with an explicit tick (called once, from the + // bootstrap) that guard is no longer needed. A repeated clear on a second + // instance only means a cold cache, not a correctness bug. $db = get_class($this->db); $db::clearVillageCache(); @@ -150,8 +150,8 @@ class Village { /** * LOAD VILLAGE DATA (single source of truth) - * W2: parametrul mort $second_run eliminat (nu era pasat de nicaieri; - * getResearching primea mereu use_cache = true). + * W2: the dead $second_run parameter was removed (it was not passed from + * anywhere; getResearching always received use_cache = true). */ private function LoadTown(): void { global $logging; @@ -187,9 +187,10 @@ class Village { $this->natar = $this->infoarray['natar']; $this->currentcel = $this->infoarray['celebration']; $this->currentfestival = $this->infoarray['festival']; - // FIX #2 (Day 1): eliminat "$this->wid = $this->infoarray['wref']" - suprascria - // id-ul satului la mijlocul incarcarii; in flux normal era no-op (wref == wid), - // dar masca orice rand gresit venit din cache. wid ramane sursa de adevar. + // FIX #2 (Day 1): removed "$this->wid = $this->infoarray['wref']" - it overwrote + // the village id in the middle of the load; in the normal flow it was a no-op + // (wref == wid), but it masked any wrong row coming from the cache. wid stays + // the source of truth. $this->vname = $this->infoarray['name']; $this->awood = $this->infoarray['wood']; @@ -208,15 +209,15 @@ class Village { $this->master = count($this->db->getMasterJobs($this->wid)); - // FIX #1a (Day 1): overflow fix doar in memorie - fara updateResource() aici. - // Clamp-ul SQL din modifyResource() (IF > maxstore -> maxstore) corecteaza - // oricum DB-ul in acelasi request, in processProduction(). Scapa un UPDATE. + // FIX #1a (Day 1): overflow fix in memory only - no updateResource() here. + // The SQL clamp in modifyResource() (IF > maxstore -> maxstore) corrects the + // DB in the same request anyway, in processProduction(). Saves one UPDATE. if ($this->awood > $this->maxstore) $this->awood = $this->maxstore; if ($this->aclay > $this->maxstore) $this->aclay = $this->maxstore; if ($this->airon > $this->maxstore) $this->airon = $this->maxstore; if ($this->acrop > $this->maxcrop) $this->acrop = $this->maxcrop; - // NOTA (pre-existent, nemodificat): $this->atotal e calculat mai sus din - // valorile ne-clamp-uite si inainte de tick-ul de productie. + // NOTE (pre-existing, unchanged): $this->atotal is computed above from the + // un-clamped values and before the production tick. } /** @@ -239,7 +240,7 @@ class Village { * APPLY PRODUCTION (NO MORE LOADTOWN RELOAD) */ private function processProduction(): void { - // hardening: nu lasa un lastupdate din viitor (clock skew) sa produca delta negativ + // hardening: do not let a lastupdate from the future (clock skew) produce a negative delta $timepast = max(0, time() - $this->infoarray['lastupdate']); $nwood = min(($this->production['wood'] / 3600) * $timepast, $this->maxstore); @@ -247,34 +248,34 @@ class Village { $niron = min(($this->production['iron'] / 3600) * $timepast, $this->maxstore); $ncrop = min(($this->production['crop'] / 3600) * $timepast, $this->maxcrop); - // FIX #1b (Day 1): un singur UPDATE - lastupdate e setat in aceeasi interogare - // cu resursele (param $setLastupdate in modifyResource); updateVillage() eliminat. + // FIX #1b (Day 1): a single UPDATE - lastupdate is set in the same query as the + // resources (the $setLastupdate param in modifyResource); updateVillage() removed. $this->db->modifyResource($this->wid, $nwood, $nclay, $niron, $ncrop, 1, true); // lightweight sync ONLY (no DB reload) $this->infoarray['lastupdate'] = time(); - // FIX #0 (Day 1): sincronizarea in memorie oglindeste exact clamp-ul SQL - // din modifyResource: podea la 0 (inainte, crop-ul negativ din starvation - // putea cobori sub 0 in memorie in timp ce DB-ul ramanea la 0 -> divergenta). + // FIX #0 (Day 1): the in-memory sync mirrors exactly the SQL clamp from + // modifyResource: floor at 0 (before, negative crop from starvation could + // drop below 0 in memory while the DB stayed at 0 -> divergence). $this->awood = min(max($this->awood + $nwood, 0), $this->maxstore); $this->aclay = min(max($this->aclay + $nclay, 0), $this->maxstore); $this->airon = min(max($this->airon + $niron, 0), $this->maxstore); $this->acrop = min(max($this->acrop + $ncrop, 0), $this->maxcrop); } - // W1 #4: cele 4 metode raman ca wrappere de o linie - zero schimbari de API, - // toata logica e in getProdFor(). + // W1 #4: the 4 methods remain as one-line wrappers - zero API changes, + // all the logic lives in getProdFor(). private function getWoodProd() { return $this->getProdFor('wood'); } private function getClayProd() { return $this->getProdFor('clay'); } private function getIronProd() { return $this->getProdFor('iron'); } private function getCropProd() { return $this->getProdFor('crop'); } /** - * W1 #4: productia bruta pentru o resursa. Inlocuieste cele 4 metode - * copy-paste (~120 linii). Ordinea operatiilor e pastrata 1:1 cu originalul: - * suma campuri -> bonus oaze -> bonus cladire (%) -> bonus cont (x1.25) - * -> round(x * SPEED). + * W1 #4: gross production for one resource. Replaces the 4 copy-paste + * methods (~120 lines). The order of the operations is kept 1:1 with the + * original: sum of fields -> oasis bonus -> building bonus (%) -> + * account bonus (x1.25) -> round(x * SPEED). */ private function getProdFor(string $type): float { $cfg = self::PROD_CONFIG[$type]; @@ -289,10 +290,11 @@ class Village { $amount += $amount * $this->oasisFactor * $this->ocounter[$cfg['oasis']]; - // NOTA: garda isset() pe 'attri' exista in original doar la crop; aici e - // aplicata peste tot (identic numeric pe date valide, fara warning pe date - // corupte). Nivelul cladirii bonus = valoarea ULTIMULUI camp de acel tip, - // exact ca in original (in date reale exista cel mult o cladire per tip). + // NOTE: the isset() guard on 'attri' existed in the original only for crop; + // here it is applied everywhere (numerically identical on valid data, no + // warning on corrupted data). The bonus building level = the value of the + // LAST field of that type, exactly as in the original (on real data there is + // at most one building per type). $bonusPct = 0; foreach ($cfg['bonus'] as $buildingType) { $level = $index['last'][$buildingType] ?? 0; @@ -310,11 +312,11 @@ class Village { } /** - * W1 #4: scaneaza campurile f1..f38 O SINGURA data per incarcare (in loc de - * 4 treceri) si le bucket-uieste dupa tip. - * 'levels' => [tip => [nivelele fiecarui camp de acel tip]] - * 'last' => [tip => nivelul (raw) al ultimului camp de acel tip] - - * semantica identica cu atribuirile suprascrise din original. + * W1 #4: scans the f1..f38 fields ONLY once per load (instead of 4 passes) + * and buckets them by type. + * 'levels' => [type => [the levels of every field of that type]] + * 'last' => [type => the (raw) level of the last field of that type] - + * identical semantics to the overwritten assignments in the original. */ private function getFieldIndex(): array { if ($this->fieldIndex !== null) { @@ -337,10 +339,10 @@ class Village { * OASIS */ /** - * Bonusul de baza al oazei (25%) crescut de Waterworks (gid 45, Egipteni): - * attri = +5% relativ per nivel, pana la +100% la nivel 20 (0.25 -> 0.50). - * NOTA: ramane intentionat in afara getFieldIndex() - scaneaza f19..f40 - * (include f39/f40, peste limita de 38 a index-ului). + * The base oasis bonus (25%) increased by the Waterworks (gid 45, Egyptians): + * attri = +5% relative per level, up to +100% at level 20 (0.25 -> 0.50). + * NOTE: intentionally kept outside getFieldIndex() - it scans f19..f40 + * (includes f39/f40, past the index limit of 38). */ private function getOasisBonusFactor(): float { $wwLevel = 0; @@ -375,9 +377,9 @@ class Village { } /** - * W2 #9: scos din constructor; apelat explicit din bootstrap-ul de mai jos. - * Isi pastreaza singur garda pe build.php, deci efectul e identic cu a-l - * pune la inceputul build.php, dar fara sa editam paginile de intrare. + * W2 #9: moved out of the constructor; called explicitly from the bootstrap + * below. It keeps its own guard on build.php, so the effect is identical to + * putting it at the top of build.php, but without editing the entry pages. */ public function ActionControl(): void { $page = $_SERVER['SCRIPT_NAME']; @@ -392,32 +394,48 @@ class Village { } } -// W2: bootstrap cu side effects EXPLICITE, in aceeasi ordine ca vechiul -// constructor (load -> tick productie -> ActionControl). Village.php e -// include_once-uit, deci blocul ruleaza o singura data per request. -// "new Village" ramane sigur pentru instante suplimentare (admin switch). +// W2: bootstrap with EXPLICIT side effects, in the same order as the old +// constructor (load -> production tick -> ActionControl). Village.php is +// include_once-d, so this block runs only once per request. +// "new Village" stays safe for additional instances (admin switch). $village = new Village; $village->tick(); $village->ActionControl(); $building = new Building; // automation -// FIX #3 (Day 1): file_exists() + file_put_contents() era un TOCTOU clasic - doua -// request-uri simultane treceau amandoua de check si rulau Automation dublu. -// fopen('c') + flock(LOCK_EX|LOCK_NB) e atomic; lock-ul se elibereaza singur si -// daca procesul moare. Se foloseste ACELASI fisier, deci gardul intern din -// Automation.php (calea cron/directa: file_exists + mtime < 60s) ramane functional; -// touch() tine mtime proaspat cat rulam noi. -$__automationLock = @fopen(AUTOMATION_LOCK_FILE_NAME, 'c'); -if ($__automationLock !== false) { - if (flock($__automationLock, LOCK_EX | LOCK_NB)) { - define('AUTOMATION_MANUAL_RUN', true); - @touch(AUTOMATION_LOCK_FILE_NAME); - include_once("Automation.php"); - // Automation.php sterge singur fisierul la final (comportament pastrat - // pentru calea cron); pe Linux flock ramane valid pe inode pana la fclose. - flock($__automationLock, LOCK_UN); +// P2: if a dedicated cron is running (cron.php), we no longer trigger automation +// from the player pages - cron.php writes a timestamped marker on every run. +// A fresh marker (< 90s) => cron is active, so we skip. Fallback: if the cron +// stops (marker old or missing), the pages take over automatically, as before, so +// the game does not stall if you forget to set up the cron or if it dies. +$__cronMarker = __DIR__ . '/Prevention/cron_active.txt'; +$__cronActive = false; +if (@is_file($__cronMarker)) { + $__cronTime = (int)@file_get_contents($__cronMarker); + if ($__cronTime > 0 && (time() - $__cronTime) < 90) { + $__cronActive = true; } - fclose($__automationLock); } -?> + +if (!$__cronActive) { + // FIX #3 (Day 1): file_exists() + file_put_contents() was a classic TOCTOU - two + // simultaneous requests both passed the check and ran Automation twice. + // fopen('c') + flock(LOCK_EX|LOCK_NB) is atomic; the lock is released by itself + // even if the process dies. It uses the SAME file, so the internal guard in + // Automation.php (cron/direct path: file_exists + mtime < 60s) keeps working; + // touch() keeps mtime fresh while we are running. + $__automationLock = @fopen(AUTOMATION_LOCK_FILE_NAME, 'c'); + if ($__automationLock !== false) { + if (flock($__automationLock, LOCK_EX | LOCK_NB)) { + define('AUTOMATION_MANUAL_RUN', true); + @touch(AUTOMATION_LOCK_FILE_NAME); + include_once("Automation.php"); + // Automation.php deletes the file itself at the end (behavior kept for the + // cron path); on Linux flock stays valid on the inode until fclose. + flock($__automationLock, LOCK_UN); + } + fclose($__automationLock); + } +} +?> \ No newline at end of file diff --git a/README.md b/README.md index 8b8a399f..5c529017 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,8 @@ Notes: - World data - Croppers build 5. After success, access the game root. +6. Installation in cPanel (Cron Jobs → Add New Cron Job, every minute): + - * * * * * /usr/bin/php /home/USER/public_html/cron.php >/dev/null 2>&1 (REPLACE USER WITH YOUR USERNAME) ## Environment Configuration diff --git a/cron.php b/cron.php index 9a28ba64..e13b0e1b 100644 --- a/cron.php +++ b/cron.php @@ -3,18 +3,40 @@ ################################################################################# ## -= YOU MAY NOT REMOVE OR CHANGE THIS NOTICE =- ## ## --------------------------------------------------------------------------- ## -## Filename : cron.php ## -## Type : Standalone automation runner (P2 - performance) ## +## Filename : cron.php ## +## Type : Standalone automation runner (P2 - Performance) ## ## --------------------------------------------------------------------------- ## -## Scop : Ruleaza Automation.php separat de request-urile ## -## jucatorilor. Se apeleaza dintr-un cron job cPanel la ## -## fiecare minut, astfel incat procesarea (batalii, miscari, ## -## antrenamente) nu mai e carata de pagina unui jucator. ## +## Purpose : Runs Automation.php independently from player requests ## +## so processing (battles, troop movements, training, etc.) ## +## is no longer handled by a player's page request. ## ## --------------------------------------------------------------------------- ## -## Instalare cron cPanel (la fiecare minut): ## -## * * * * * /usr/bin/php /home/USER/public_html/cron.php >/dev/null 2>&1 ## -## sau prin wget/curl daca php-cli nu e disponibil: ## -## * * * * * wget -q -O /dev/null "https://DOMENIU/cron.php?key=SECRET" ## +## WHY AN INTERNAL LOOP: ## +## Automation is designed to run approximately every 60 seconds (its ## +## internal guard checks "time() - filemtime > 60"). Many cPanel hosting ## +## providers do not allow 1-minute cron jobs, only a minimum interval of ## +## 5 minutes. If Automation were executed only once every 5 minutes, ## +## attacks and building completions could be delayed by up to 5 minutes. ## +## For this reason, a single cron invocation performs one TICK every 60 ## +## seconds for approximately 5 minutes, then exits and waits for the next ## +## scheduled cron execution. ## +## ## +## Each tick rewrites Prevention/cron_active.txt, keeping the marker file ## +## fresh so player page requests will no longer trigger Automation at all. ## +## --------------------------------------------------------------------------- ## +## cPanel Cron Installation (every 5 minutes - recommended with the ## +## internal loop): ## +##*/5 * * * * /usr/local/bin/php /home/USER/public_html/cron.php>/dev/null 2>&1## +## ## +## If your hosting provider allows a 1-minute cron, set the Minute field to ## +## "*" and add the following to GameEngine/config.php: ## +## define('CRON_LOOP_SECONDS', 0); ## +## (0 = a single tick per invocation, without the internal loop) ## +## --------------------------------------------------------------------------- ## +## Optional configuration in GameEngine/config.php: ## +## define('CRON_LOOP_SECONDS', 300); // Duration of one invocation ## +## // (0 = single tick) ## +## define('CRON_TICK_SECONDS', 60); // Tick interval inside the loop ## +## define('CRON_KEY', 'secret'); // Required only for HTTP execution ## ## --------------------------------------------------------------------------- ## ## Contact : cata7007@gmail.com ## ## Project : TravianZ ## @@ -22,18 +44,44 @@ ################################################################################# // ----------------------------------------------------------------------------- -// 1. Detectare mod de rulare -// - CLI (php cron.php): mereu permis, e apelat doar de cron-ul serverului. -// - HTTP (wget/curl): permis DOAR daca cheia din config se potriveste, ca sa -// nu poata declansa oricine automation-ul de pe internet. +// 1. Execution Mode +// - CLI (php cron.php): Invoked by the server's cron scheduler and always +// allowed. +// - HTTP (wget/curl): Allowed ONLY when the correct CRON_KEY is provided, +// preventing anyone on the Internet from triggering Automation. +// - "--once" / "?once=1": Executes a single tick without the internal loop +// (useful for a "Force Run" button in the admin panel). // ----------------------------------------------------------------------------- $isCli = (PHP_SAPI === 'cli'); +if ($isCli) { + $forceSingleTick = isset($argv) && in_array('--once', $argv, true); +} else { + $forceSingleTick = isset($_GET['once']); +} + // ----------------------------------------------------------------------------- -// 2. Bootstrap minimal - NU includem Session.php (porneste sesiune, se leaga de -// request si de un user logat). Automation are nevoie doar de config (constante -// SQL_*, TB_PREFIX, AUTOMATION_LOCK_FILE_NAME) si de $database; restul (buidata, -// unitdata, Units, Battle, etc.) si le include Automation.php singur. +// 1b. IMPORTANT: The server's cron launches this script with the current working +// directory set to the account's HOME directory (e.g. /home/user), NOT the +// game's directory. Without the line below, the relative lookups in step 2 +// (file_exists('autoloader.php'), etc.) fail, the script exits with exit(1), +// and the error message is swallowed by ">/dev/null"—making it appear that +// the cron job "does nothing" with no trace in the logs. +// This problem does not occur when accessed via HTTP (where the current +// working directory is the script's directory), which is why running it from +// the browser works while the cron job does not. +// ----------------------------------------------------------------------------- +if (@chdir(__DIR__) === false) { + if ($isCli) { fwrite(STDERR, "cron: unable to change to directory " . __DIR__ . "\n"); } + exit(1); +} + +// ----------------------------------------------------------------------------- +// 2. Minimal Bootstrap +// - We do NOT include Session.php, as it starts a session and is tied to +// HTTP requests and authenticated users. +// - Automation.php loads everything else it needs on its own +// (buidata, unitdata, Units, Battle, etc.). // ----------------------------------------------------------------------------- $autoprefix = ''; for ($i = 0; $i < 5; $i++) { @@ -46,7 +94,7 @@ for ($i = 0; $i < 5; $i++) { require_once($autoprefix . 'autoloader.php'); if (!file_exists($autoprefix . 'GameEngine/config.php')) { - // instalarea nu e finalizata - nu avem ce rula + // Installation is not yet complete - there is nothing to run. if ($isCli) { fwrite(STDERR, "cron: config.php lipseste, jocul nu e instalat\n"); } exit(1); } @@ -54,61 +102,185 @@ if (!file_exists($autoprefix . 'GameEngine/config.php')) { include_once($autoprefix . 'GameEngine/config.php'); // ----------------------------------------------------------------------------- -// 3. Verificare cheie pentru accesul HTTP (nu se aplica in CLI) -// Defineste in GameEngine/config.php: define('CRON_KEY', 'un-secret-lung'); -// Apoi apeleaza: https://domeniu/cron.php?key=un-secret-lung -// Daca CRON_KEY nu e definit, accesul HTTP este refuzat (doar CLI merge). +// 3. Verify the access key for HTTP requests (not applicable in CLI mode) // ----------------------------------------------------------------------------- if (!$isCli) { $providedKey = isset($_GET['key']) ? (string)$_GET['key'] : ''; + if (!defined('CRON_KEY') || CRON_KEY === '' || !hash_equals(CRON_KEY, $providedKey)) { header('HTTP/1.1 403 Forbidden'); exit('Forbidden'); } - // nu tinem conexiunea deschisa pentru client - raspundem si continuam in fundal + // Do not keep the client connection open - send the response immediately and continue processing in the background. ignore_user_abort(true); @set_time_limit(0); header('Content-Type: text/plain'); echo "OK\n"; + if (function_exists('fastcgi_finish_request')) { fastcgi_finish_request(); } + + // When executed via HTTP, we do not keep the process running for 5 minutes + // (PHP-FPM has its own execution timeouts). + // An HTTP request performs only a single tick. + // The internal loop is intended for CLI execution only. + $forceSingleTick = true; } // ----------------------------------------------------------------------------- -// 4. Conexiune la baza de date (acelasi mod ca in Database.php - variabila globala -// $database folosita de Automation prin "global $database"). +// 4. Loop Parameters +// ----------------------------------------------------------------------------- +$loopSeconds = defined('CRON_LOOP_SECONDS') ? (int)CRON_LOOP_SECONDS : 300; +$tickSeconds = defined('CRON_TICK_SECONDS') ? (int)CRON_TICK_SECONDS : 60; + +if ($tickSeconds < 15) { $tickSeconds = 15; } // sub 15s nu aduce nimic, doar incarca serverul +if ($loopSeconds < 0) { $loopSeconds = 0; } +if ($forceSingleTick) { $loopSeconds = 0; } + +// ----------------------------------------------------------------------------- +// 5. Overlap Lock +// If the previous invocation is still running its loop (e.g., on a slow +// server), the next invocation exits immediately instead of processing +// everything twice. The flock() lock is automatically released, even if +// the process terminates unexpectedly. +// ----------------------------------------------------------------------------- +$runLockPath = $autoprefix . 'GameEngine/Prevention/cron_running.lock'; +$runLockHandle = @fopen($runLockPath, 'c'); + +if ($runLockHandle === false) { + if ($isCli) { fwrite(STDERR, "cron: nu pot deschide lock-ul ($runLockPath)\n"); } + exit(1); +} + +if (!flock($runLockHandle, LOCK_EX | LOCK_NB)) { + fclose($runLockHandle); + if ($isCli) { echo "cron: o invocare anterioara inca ruleaza, ies\n"; } + exit(0); +} + +// ----------------------------------------------------------------------------- +// 6. Database Connection +// Initializes the database connection ($database), which is accessed by +// Automation.php through the global scope. // ----------------------------------------------------------------------------- include_once($autoprefix . 'GameEngine/Database.php'); if (!isset($database) || !$database) { if ($isCli) { fwrite(STDERR, "cron: conexiunea la baza de date a esuat\n"); } + flock($runLockHandle, LOCK_UN); + fclose($runLockHandle); exit(1); } -// ----------------------------------------------------------------------------- -// 5. Marcam ca suntem sursa oficiala de automation. AUTOMATION_MANUAL_RUN spune -// Automation.php sa NU mai treaca prin gardul de lock file (acela era pentru -// coordonarea request-urilor concurente ale jucatorilor; cron-ul e sursa unica, -// o data pe minut, deci nu are nevoie de el). -// -// In plus scriem un marker cu timestamp, ca Village.php sa stie ca exista un -// cron activ si sa NU mai declanseze automation din paginile jucatorilor -// (fallback: daca cron-ul se opreste > 90s, paginile preiau automat). -// ----------------------------------------------------------------------------- +// AUTOMATION_MANUAL_RUN tells Automation.php to bypass its own lock-file guard. +// That guard is designed to coordinate concurrent player requests, whereas +// this script is the only execution source and is already protected by the +// overlap lock implemented above. if (!defined('AUTOMATION_MANUAL_RUN')) { define('AUTOMATION_MANUAL_RUN', true); } -$cronMarker = $autoprefix . 'GameEngine/Prevention/cron_active.txt'; -@file_put_contents($cronMarker, (string)time(), LOCK_EX); +$cronMarkerPath = $autoprefix . 'GameEngine/Prevention/cron_active.txt'; + +/** + * Clears the database class's request-level caches. + * + * The static caches in MYSQLi_DB are designed to live only for the duration + * of a single request. In a process that executes multiple ticks in sequence, + * they would remain populated, causing subsequent ticks to operate on stale + * data (for example, building levels already modified by the previous tick). + * Therefore, the caches are reset between ticks so that each tick starts with + * the same clean state as a new request. + * + * Only static array properties whose names contain "cache" are reset. + */ +function cron_reset_db_caches() +{ + if (!class_exists('MYSQLi_DB')) { + return; + } + + try { + $ref = new ReflectionClass('MYSQLi_DB'); + + foreach ($ref->getProperties(ReflectionProperty::IS_STATIC) as $prop) { + if (stripos($prop->getName(), 'cache') === false) { + continue; + } + + $prop->setAccessible(true); + + if (is_array($prop->getValue())) { + $prop->setValue(null, []); + } + } + } catch (Throwable $e) { + // If reflection fails, it is better to continue than to stop Automation. + } +} + +/** + * A tick represents one complete execution of Automation. + * + * The first tick includes Automation.php (the file ends with "new Automation", + * so including it starts the processing automatically). Subsequent ticks simply + * instantiate the Automation class again, since include_once would not execute + * the file a second time. + */ +function cron_run_tick(&$firstTick, $autoprefix, $cronMarkerPath) +{ + @file_put_contents($cronMarkerPath, (string)time(), LOCK_EX); + + if ($firstTick) { + include_once($autoprefix . 'GameEngine/Automation.php'); + $firstTick = false; + return; + } + + cron_reset_db_caches(); + new Automation(); +} // ----------------------------------------------------------------------------- -// 6. Rulam automation-ul propriu-zis. Constructorul din Automation.php face toata -// treaba (procNewClimbers, buildComplete, trainingComplete, batalii, miscari...). +// 7. Execution // ----------------------------------------------------------------------------- -include_once($autoprefix . 'GameEngine/Automation.php'); +$firstTick = true; +$startedAt = time(); +$ticks = 0; + +do { + $tickStart = time(); + + cron_run_tick($firstTick, $autoprefix, $cronMarkerPath); + $ticks++; + + if ($loopSeconds <= 0) { + break; + } + + // Is there enough time remaining in the invocation budget for another complete tick? + $elapsed = time() - $startedAt; + if (($elapsed + $tickSeconds) >= $loopSeconds) { + break; + } + + // Sleep until the next tick, subtracting the time spent executing the current tick. + $spent = time() - $tickStart; + $sleep = $tickSeconds - $spent; + + if ($sleep > 0) { + sleep($sleep); + } + +} while (true); + +// ----------------------------------------------------------------------------- +// 8. Release the Lock +// ----------------------------------------------------------------------------- +flock($runLockHandle, LOCK_UN); +fclose($runLockHandle); if ($isCli) { - echo "cron: automation rulat la " . date('Y-m-d H:i:s') . "\n"; + echo "cron: " . $ticks . " tick(uri) in " . (time() - $startedAt) . "s, terminat la " . date('Y-m-d H:i:s') . "\n"; } diff --git a/install/data/constant_format.tpl b/install/data/constant_format.tpl index 48719dae..9a179d1a 100644 --- a/install/data/constant_format.tpl +++ b/install/data/constant_format.tpl @@ -22,6 +22,26 @@ define("ERROR_REPORT","%ERRORREPORT%"); %ERROR% define('AUTOMATION_LOCK_FILE_NAME', 'automation.lck'); +////////////////////////////////// +// ***** CRON / AUTOMATION *****// +////////////////////////////////// +// Automation ruleaza din cron.php (cron job pe server), nu din paginile +// jucatorilor. Vezi comentariile din cron.php pentru instalarea cron job-ului. +// +// CRON_LOOP_SECONDS = cat timp tine o invocare de cron.php. +// Multe hosturi nu permit cron mai des de 5 minute, iar Automation vrea sa +// ruleze la ~60s. De aceea o invocare ruleaza mai multe tick-uri la rand. +// 300 = potrivit pentru un cron "*/5 * * * *". Pune 0 daca hostul tau permite +// cron la fiecare minut (atunci o invocare = un singur tick). +// CRON_TICK_SECONDS = la cat timp se repeta un tick in interiorul invocarii. +define('CRON_LOOP_SECONDS', %CRONLOOP%); +define('CRON_TICK_SECONDS', %CRONTICK%); + +// Cheie pentru apelarea cron.php prin HTTP (wget/curl sau un serviciu extern de +// cron). Rularea din linia de comanda (cron job cPanel) NU are nevoie de ea. +// Generata automat la instalare; pastrata la salvarile de configuratie din ACP. +define('CRON_KEY', '%CRONKEY%'); + ////////////////////////////////// // ***** SERVER SETTINGS *****// ////////////////////////////////// diff --git a/install/process.php b/install/process.php index b5fc7588..e1c8e268 100644 --- a/install/process.php +++ b/install/process.php @@ -107,6 +107,13 @@ class Process { $findReplace["%QTYPE%"] = $_POST['qtype']; $findReplace["%BEGINNER%"] = $_POST['beginner']; $findReplace["%STARTTIME%"] = time(); + // Cheie aleatoare pentru apelul HTTP al cron.php (vezi CRON_KEY in config). + // Generata o singura data, la instalare; ACP-ul o pastreaza la resalvari. + $findReplace["%CRONKEY%"] = bin2hex(random_bytes(24)); + // Valori implicite pentru ciclul intern al cron.php; editabile ulterior + // din ACP (Config -> Cron & Automation -> edit). + $findReplace["%CRONLOOP%"] = 300; + $findReplace["%CRONTICK%"] = 60; $findReplace["%DOMAIN%"] = $_POST['domain']; $findReplace["%HOMEPAGE%"] = $_POST['homepage']; $findReplace["%SERVER%"] = $_POST['server'];