diff --git a/GameEngine/Automation/AutomationBattleResolution.php b/GameEngine/Automation/AutomationBattleResolution.php index 41b12065..c443908f 100644 --- a/GameEngine/Automation/AutomationBattleResolution.php +++ b/GameEngine/Automation/AutomationBattleResolution.php @@ -347,6 +347,130 @@ trait AutomationBattleResolution { return (mysqli_affected_rows($database->dblink) === 1); } + + /** + * Claim atomic pentru un retur de trupe. + * + * Un attack ref poate avea legitim un singur movement de retur + * (sort_type = 4). + * + * Daca exista deja un alt return pentru acelasi ref care a fost + * procesat, acest movement este considerat duplicat si este marcat + * processed fara a mai credita trupele. + */ + + private function claimReturnMovementRecord($moveid, $ref) { + global $database; + + $moveid = (int)$moveid; + $ref = (int)$ref; + + if ($moveid <= 0 || $ref <= 0) { + return false; + } + + /* + * Lock pe ATTACK REF, nu pe moveid. + * + * Astfel doua movement-uri duplicate cu moveid diferit + * nu pot trece simultan de verificarea de unicitate. + */ + $lockResult = mysqli_query( + $database->dblink, + "SELECT GET_LOCK('return_attack_ref_$ref', 10) AS locked" + ); + + if (!$lockResult) { + return false; + } + + $lockRow = mysqli_fetch_assoc($lockResult); + + if ( + !isset($lockRow['locked']) || + (int)$lockRow['locked'] !== 1 + ) { + return false; + } + + try { + /* + * Verificam daca acest movement mai este pending. + */ + $q = " + SELECT moveid + FROM " . TB_PREFIX . "movement + WHERE moveid = $moveid + AND sort_type = 4 + AND proc = 0 + LIMIT 1 + "; + + $result = mysqli_query( + $database->dblink, + $q + ); + + if (!$result || mysqli_num_rows($result) === 0) { + return false; + } + + /* + * Verificam daca un alt return pentru acelasi attack + * a fost deja procesat. + */ + $q = " + SELECT moveid + FROM " . TB_PREFIX . "movement + WHERE ref = $ref + AND sort_type = 4 + AND proc = 1 + AND moveid <> $moveid + LIMIT 1 + "; + + $result = mysqli_query( + $database->dblink, + $q + ); + + if ($result && mysqli_num_rows($result) > 0) { + /* + * Este un duplicate. + * + * Il consumam fara sa adaugam trupele. + */ + mysqli_query( + $database->dblink, + "UPDATE " . TB_PREFIX . "movement + SET proc = 1 + WHERE moveid = $moveid + AND proc = 0" + ); + + return false; + } + + /* + * Claim atomic pentru movement-ul legitim. + */ + $result = mysqli_query( + $database->dblink, + "UPDATE " . TB_PREFIX . "movement + SET proc = 1 + WHERE moveid = $moveid + AND proc = 0" + ); + + return $result && mysqli_affected_rows($database->dblink) === 1; + + } finally { + mysqli_query( + $database->dblink, + "SELECT RELEASE_LOCK('return_attack_ref_$ref')" + ); + } + } /** * Handle hero evasion: if the defender has evasion active and can afford it, diff --git a/GameEngine/Automation/AutomationTroopMovements.php b/GameEngine/Automation/AutomationTroopMovements.php index 9f916cb3..06a8eeb6 100644 --- a/GameEngine/Automation/AutomationTroopMovements.php +++ b/GameEngine/Automation/AutomationTroopMovements.php @@ -211,7 +211,7 @@ trait AutomationTroopMovements { $time = time(); $q = " SELECT - `to`, `from`, moveid, starttime, endtime, wood, clay, iron, crop, + `to`, `from`, moveid, ref, starttime, endtime, wood, clay, iron, crop, t1, t2, t3, t4, t5, t6, t7, t8, t9, t10, t11 FROM ".TB_PREFIX."movement, @@ -238,9 +238,11 @@ trait AutomationTroopMovements { $database->getOasisEnforce($vilIDs, 1); foreach($dataarray as $data) { - if (!$this->claimMovementRecord($data['moveid'])) { - continue; - } + if (!$this->claimReturnMovementRecord( + $data['moveid'], + $data['ref'])) { + continue; + } $tribe = $database->getUserField($database->getVillageField($data['to'], "owner"), "tribe", 0); $u = $tribe == 1 ? "" : $tribe - 1; diff --git a/GameEngine/Database/DatabaseMovementQueries.php b/GameEngine/Database/DatabaseMovementQueries.php index f4813c50..60818291 100644 --- a/GameEngine/Database/DatabaseMovementQueries.php +++ b/GameEngine/Database/DatabaseMovementQueries.php @@ -192,6 +192,52 @@ trait DatabaseMovementQueries { return (mysqli_affected_rows($this->dblink) === 1); } + /** + * Revendicare ATOMICA a unei miscari care urmeaza sa fie anulata din rally point + * (build.php?mode=troops&cancel=1&moveid=X). + * + * DE CE EXISTA: varianta veche din build.php facea, in trei pasi separati: + * SELECT COUNT(*) ... WHERE proc = 0 AND moveid = X -> daca Total == 1 + * UPDATE ... SET proc = 1 WHERE proc = 0 AND moveid = X + * addMovement(4, to, from, REF-UL VECHI, now, end) + * Doua request-uri simultane treceau AMANDOUA de SELECT COUNT (ambele vad Total = 1), + * UPDATE-ul era idempotent, deci amandoua ajungeau la addMovement() si inserau DOUA + * randuri sort_type = 4 cu ACELASI ref. Cron-ul (AutomationTroopMovements) livreaza + * trupele o data PER RAND DE MOVEMENT, nu per ref => trupele se dublau la fiecare + * ciclu trimite-anuleaza: 30k -> 60k -> 120k -> 242k -> ... + * + * Acum proprietatea, tipul, fereastra de 90 de secunde si proc = 0 sunt toate in + * acelasi UPDATE conditionat. Doar request-ul care chiar modifica randul primeste + * true (affected_rows === 1); toate celelalte pierd cursa si nu creeaza nimic. + * + * ANOMALIE SEMNALATA, NU SCHIMBATA TACIT: codul vechi nu verifica deloc sort_type, + * deci se putea "anula" si un sort_type = 4 (retur) al carui `from` era satul tau - + * adica returul unei intariri straine care pleaca din satul tau - iar returul nou + * generat aducea acele trupe LA TINE. Restrictia sort_type IN (3, 5) inchide si + * acest vector si pastreaza exact cele doua cazuri legitime din 16_walking.tpl: + * atac/intarire iesita din sat (3) si colonisti (5). + * + * @param int $moveid ID-ul miscarii + * @param int $wid Satul curent (trebuie sa fie expeditorul) + * @return bool True doar pentru request-ul care a revendicat miscarea + */ + function claimMovementCancel($moveid, $wid) { + $moveid = (int) $moveid; + $wid = (int) $wid; + + if ($moveid <= 0 || $wid <= 0) return false; + + $limit = time() - 90; + + $q = "UPDATE " . TB_PREFIX . "movement SET proc = 1 WHERE moveid = $moveid AND proc = 0 AND `from` = $wid AND sort_type IN (3, 5) AND starttime > $limit"; + + if (!mysqli_query($this->dblink, $q)) { + return false; + } + + return (mysqli_affected_rows($this->dblink) === 1); + } + // no need to cache this method function getA2b($ckey) { list($ckey) = $this->escape_input($ckey); @@ -740,4 +786,4 @@ trait DatabaseMovementQueries { self::$prisonersCache = []; } -} +} \ No newline at end of file diff --git a/GameEngine/Database/DatabaseTroopQueries.php b/GameEngine/Database/DatabaseTroopQueries.php index 458a2408..0eb6dfab 100644 --- a/GameEngine/Database/DatabaseTroopQueries.php +++ b/GameEngine/Database/DatabaseTroopQueries.php @@ -95,6 +95,79 @@ trait DatabaseTroopQueries { $id = (int) $id; mysqli_query($this->dblink, "SELECT RELEASE_LOCK('enforce_$id')"); } + + /** + * Lock pentru procesarea returului trupelor dintr-o oaza. + * + * Este separat de enforce_$id deoarece o oaza poate avea mai multe + * randuri de reinforcement. + * + * Scop: + * Request A -> citeste reinforcement din oasis + * Request B -> citeste acelasi reinforcement + * => fara lock, ambele pot crea movement de retur. + */ + function getOasisReturnLock($wref) { + $wref = (int) $wref; + + $result = mysqli_query( + $this->dblink, + "SELECT GET_LOCK('oasis_return_$wref', 10) AS locked" + ); + + if (!$result) { + return false; + } + + $row = mysqli_fetch_assoc($result); + + return isset($row['locked']) && (int)$row['locked'] === 1; + } + + /** + * Elibereaza lock-ul pentru returul unei oaze. + */ + function releaseOasisReturnLock($wref) { + $wref = (int) $wref; + + mysqli_query( + $this->dblink, + "SELECT RELEASE_LOCK('oasis_return_$wref')" + ); + } + + /** + * Citeste DIRECT din DB toate reinforcement-urile aflate in oaza. + * + * IMPORTANT: + * Nu folosim cache aici. Functia este apelata DUPA obtinerea lock-ului + * pentru a preveni TOCTOU/race condition. + */ + function getOasisEnforceByWref($wref, $use_cache = false) { + $wref = (int) $wref; + + if ($wref <= 0) { + return []; + } + + $q = " + SELECT e.*, o.conqured + FROM " . TB_PREFIX . "enforcement AS e + LEFT JOIN " . TB_PREFIX . "odata AS o + ON e.vref = o.wref + WHERE e.vref = $wref + AND o.wref = $wref + AND o.conqured > 0 + "; + + $result = mysqli_query($this->dblink, $q); + + if (!$result) { + return []; + } + + return $this->mysqli_fetch_all($result); + } /** * Add the unit table(s) and troops if presents diff --git a/GameEngine/Units.php b/GameEngine/Units.php index b2444b54..798e94df 100755 --- a/GameEngine/Units.php +++ b/GameEngine/Units.php @@ -351,35 +351,353 @@ class Units { return ""; } + /** + * Returneaza trupele stationate in sat si/sau in oazele acestuia. + * + * IMPORTANT: + * - fiecare reinforcement este procesat sub lock; + * - fiecare oaza este procesata sub un lock separat; + * - datele sunt re-citite din DB dupa obtinerea lock-ului; + * - nu folosim cache pentru datele critice. + * + * @param int $wref + * @param int $mode + * + * mode = 0: + * returneaza reinforcement-urile din sat + oazele lui + * + * mode = 1: + * returneaza doar reinforcement-urile din oazele satului + */ public function returnTroops($wref, $mode = 0) { global $database; - - if(!$mode){ - $getenforce = $database->getEnforceVillage($wref, 0); - foreach($getenforce as $enforce) $this->processReturnTroops($enforce); - } - - // check oasis - $getenforce1 = $database->getOasisEnforce($wref, 1); - foreach($getenforce1 as $enforce) $this->processReturnTroops($enforce); - - // set oasis to default - if(count($getenforce1) > 0) $database->regenerateOasisUnits($getenforce1[0]['vref']); + + $wref = (int) $wref; + + if ($wref <= 0) { + return; + } + + /* + * Reinforcement-uri stationate direct in sat. + * + * Le procesam individual sub enforce lock. + */ + if (!$mode) { + $getenforce = $database->getEnforceVillage($wref, 0, false); + + if ($getenforce && count($getenforce)) { + foreach ($getenforce as $enforce) { + if (!empty($enforce['id'])) { + $this->returnEnforcementRecord((int)$enforce['id']); + } + } + } + } + + /* + * OAZE + * + * getOasisEnforce() ne da toate reinforcement-urile din oazele + * cucerite de acest sat. + * + * Nu procesam direct rezultatul deoarece acesta poate fi cached. + * Extragem doar ID-urile oazelor si apoi fiecare oaza este re-citita + * dupa obtinerea lock-ului. + */ + $getenforce1 = $database->getOasisEnforce($wref, 1, false); + + if (!$getenforce1 || !count($getenforce1)) { + return; + } + + $oasisRefs = []; + + foreach ($getenforce1 as $enforce) { + $oasisWref = isset($enforce['vref']) ? (int)$enforce['vref'] : 0; + + if ($oasisWref > 0) { + $oasisRefs[$oasisWref] = true; + } + } + + foreach (array_keys($oasisRefs) as $oasisWref) { + $this->returnOasisTroops($oasisWref); + } } + /** + * Returneaza TOATE reinforcement-urile dintr-o singura oaza. + * + * Aceasta este protectia principala impotriva exploitului de duplicare. + * + * Doua request-uri simultane pentru aceeasi oaza: + * + * Request A -> obtine lock + * Request B -> asteapta + * + * A -> citeste reinforcement + * A -> creeaza movement + * A -> sterge reinforcement + * A -> release lock + * + * B -> obtine lock + * B -> re-citeste DB + * B -> nu mai gaseste reinforcement + * B -> nu mai poate duplica nimic + */ + public function returnOasisTroops($oasisWref) { + global $database; + + $oasisWref = (int)$oasisWref; + + if ($oasisWref <= 0) { + return false; + } + + if (!$database->getOasisReturnLock($oasisWref)) { + return false; + } + + try { + /* + * IMPORTANT: + * Re-fetch DIRECT din DB dupa lock. + * Nu folosim getOasisEnforce(..., cache). + */ + $reinforcements = $database->getOasisEnforceByWref( + $oasisWref, + false + ); + + if (!$reinforcements || !count($reinforcements)) { + return false; + } + + foreach ($reinforcements as $enforce) { + if (empty($enforce['id'])) { + continue; + } + + /* + * processReturnTroops() sterge reinforcement-ul dupa ce + * creeaza movement-ul de retur. + */ + $this->processReturnTroops($enforce); + } + + /* + * Comportamentul original: + * dupa ce reinforcement-urile au fost returnate, oaza isi + * regenereaza trupele naturale. + */ + $database->regenerateOasisUnits($oasisWref); + + return true; + + } finally { + $database->releaseOasisReturnLock($oasisWref); + } + } + + /** + * Returneaza un singur reinforcement record. + * + * Protectie suplimentara pentru cazurile in care un sat este sters + * sau reinforcement-ul este procesat dintr-o alta cale. + */ + private function returnEnforcementRecord($enforceId) { + global $database; + + $enforceId = (int)$enforceId; + + if ($enforceId <= 0) { + return false; + } + + if (!$database->getEnforceLock($enforceId)) { + return false; + } + + try { + /* + * Re-fetch dupa lock. + */ + $enforce = $database->getEnforceArray( + $enforceId, + 0, + false + ); + + /* + * Poate sa fi fost deja procesat de alt request. + */ + if (!$enforce || empty($enforce['id'])) { + return false; + } + + $this->processReturnTroops($enforce); + + return true; + + } finally { + $database->releaseEnforceLock($enforceId); + } + } + + /** + * Creeaza movement-ul de retur pentru un reinforcement. + * + * ATENTIE: + * Aceasta functie NU mai este responsabila de locking. + * Lock-ul este facut de: + * + * returnEnforcementRecord() + * returnOasisTroops() + * + * astfel incat sa avem o singura responsabilitate pentru lock. + */ private function processReturnTroops($enforce) { global $database; - - $to = $database->getVillage($enforce['from']); - $tribe = $database->getUserField($to['owner'], 'tribe', 0); + + if (empty($enforce['id']) || empty($enforce['from'])) { + return false; + } + + $fromWref = (int)$enforce['from']; + $oasisWref = (int)$enforce['vref']; + + /* + * Satul de origine al reinforcement-ului. + */ + $to = $database->getVillage($fromWref); + + if (!$to || empty($to['owner'])) { + return false; + } + + $tribe = (int)$database->getUserField( + $to['owner'], + 'tribe', + 0 + ); + + if ($tribe <= 0) { + return false; + } + $start = ($tribe - 1) * 10 + 1; - - $troopsTime = $this->getWalkingTroopsTime($enforce['from'], $enforce['vref'], $to['owner'], $tribe, $enforce, 1); - $time = $database->getArtifactsValueInfluence($from['owner'], $enforce['from'], 2, $troopsTime); - - $reference = $database->addAttack($enforce['from'], $enforce['u'.$start], $enforce['u'.($start + 1)], $enforce['u'.($start + 2)], $enforce['u'.($start + 3)], $enforce['u'.($start + 4)], $enforce['u'.($start + 5)], $enforce['u'.($start + 6)], $enforce['u'.($start + 7)], $enforce['u'.($start + 8)], $enforce['u'.($start + 9)], $enforce['hero'], 2, 0, 0, 0, 0); - $database->addMovement(4, $enforce['vref'], $enforce['from'], $reference, time(), ($time + time())); - $database->deleteReinf($enforce['id']); + + /* + * Calculeaza timpul de mers Oaza -> Sat. + */ + $troopsTime = $this->getWalkingTroopsTime( + $fromWref, + $oasisWref, + $to['owner'], + $tribe, + $enforce, + 1 + ); + + /* + * BUG FIX: + * + * Codul vechi folosea: + * + * $from['owner'] + * + * dar $from nu exista in aceasta functie. + * + * Proprietarul corect este $to['owner']. + */ + $time = $database->getArtifactsValueInfluence( + $to['owner'], + $fromWref, + 2, + $troopsTime + ); + + /* + * Unit-urile din reinforcement sunt stocate in coloanele + * corespunzatoare tribului proprietarului. + */ + $t1 = (int)($enforce['u' . $start] ?? 0); + $t2 = (int)($enforce['u' . ($start + 1)] ?? 0); + $t3 = (int)($enforce['u' . ($start + 2)] ?? 0); + $t4 = (int)($enforce['u' . ($start + 3)] ?? 0); + $t5 = (int)($enforce['u' . ($start + 4)] ?? 0); + $t6 = (int)($enforce['u' . ($start + 5)] ?? 0); + $t7 = (int)($enforce['u' . ($start + 6)] ?? 0); + $t8 = (int)($enforce['u' . ($start + 7)] ?? 0); + $t9 = (int)($enforce['u' . ($start + 8)] ?? 0); + $t10 = (int)($enforce['u' . ($start + 9)] ?? 0); + $hero = (int)($enforce['hero'] ?? 0); + + /* + * Nu cream movement pentru un reinforcement gol. + */ + if ( + $t1 <= 0 && + $t2 <= 0 && + $t3 <= 0 && + $t4 <= 0 && + $t5 <= 0 && + $t6 <= 0 && + $t7 <= 0 && + $t8 <= 0 && + $t9 <= 0 && + $t10 <= 0 && + $hero <= 0 + ) { + $database->deleteReinf((int)$enforce['id']); + return false; + } + + /* + * Creeaza attack record pentru retur. + */ + $reference = $database->addAttack( + $fromWref, + $t1, + $t2, + $t3, + $t4, + $t5, + $t6, + $t7, + $t8, + $t9, + $t10, + $hero, + 2, + 0, + 0, + 0, + 0 + ); + + /* + * Creeaza EXACT UN singur movement de retur. + */ + $now = time(); + + $database->addMovement( + 4, + $oasisWref, + $fromWref, + $reference, + $now, + $now + $time + ); + + /* + * Acum reinforcement-ul nu mai poate fi procesat din nou, + * deoarece row-ul este sters IN TIMP CE lock-ul este detinut. + */ + $database->deleteReinf((int)$enforce['id']); + + return true; } private function sendTroops($post) { @@ -639,23 +957,38 @@ class Units { $to = $database->getVillage( $enforce['from'] ); $Gtribe = ($ownerTribe = $database->getUserField( $to['owner'], 'tribe', 0)) == 1 ? "" : $ownerTribe - 1; - for ( $i = 1; $i < 10; $i ++ ) { - if ( isset( $post[ 't' . $i ] ) ) { - if ( $i != 10 ) { - if ( $post[ 't' . $i ] > $enforce[ 'u' . $Gtribe . $i ] ) { - $form->addError( "error", "You can't send back more units than you have" ); - break; - } + for ($i = 1; $i <= 10; $i++) { + if (!isset($post['t'.$i])) { + $post['t'.$i] = 0; + continue; + } - if ( $post[ 't' . $i ] < 0 ) { - $form->addError( "error", "You can't send back negative units." ); - break; - } - } - } else { - $post[ 't' . $i . '' ] = '0'; - } - } + if (!is_numeric($post['t'.$i])) { + $form->addError( + "error", + "Invalid troop amount." + ); + break; + } + + $post['t'.$i] = (int)$post['t'.$i]; + + if ($post['t'.$i] < 0) { + $form->addError( + "error", + "You can't send back negative units." + ); + break; + } + + if ($post['t'.$i] > (int)$enforce['u'.$Gtribe.$i]) { + $form->addError( + "error", + "You can't send back more units than you have" + ); + break; + } + } if ( isset( $post['t11'] ) ) { if ( $post['t11'] > $enforce['hero'] ) { $form->addError( "error", "You can't send back more units than you have" ); diff --git a/Templates/Build/37_land.tpl b/Templates/Build/37_land.tpl index 2b8e80d7..2ea08a25 100644 --- a/Templates/Build/37_land.tpl +++ b/Templates/Build/37_land.tpl @@ -36,11 +36,59 @@ } $wwMultiplier = $wwFactor / 0.25; // 1.0 fara Waterworks, 1.5 la nivel maxim - if (isset($_GET['gid']) && $_GET['gid'] == 37 && isset($_GET['del']) && $database->getOasisField($_GET['del'], 'owner') == $session->uid) { - $units->returnTroops($village->wid, 1); - $database->removeOases($_GET['del']); - header("Location: build.php?id=" . $id . "&land"); - exit; + if ( + isset($_GET['gid']) && + (int)$_GET['gid'] === 37 && + isset($_GET['del']) + ) { + $oasisWref = (int)$_GET['del']; + + /* + * Verificam server-side ca: + * + * 1. oaza exista; + * 2. apartine jucatorului; + * 3. este cucerita de SATUL CURENT. + * + * Nu este suficient doar owner == session uid deoarece un jucator + * poate avea mai multe sate si mai multe oaze. + */ + $oasisOwner = (int)$database->getOasisField( + $oasisWref, + 'owner' + ); + + $oasisConquered = (int)$database->getOasisField( + $oasisWref, + 'conqured' + ); + + if ( + $oasisWref > 0 && + $oasisOwner === (int)$session->uid && + $oasisConquered === (int)$village->wid + ) { + /* + * FOARTE IMPORTANT: + * + * Returnam doar trupele din OAZA SELECTATA. + * + * returnTroops($village->wid, 1) NU trebuie folosit aici, + * deoarece acela proceseaza toate oazele satului. + * + * Functia are lock per oasis si re-citeste DB dupa lock. + */ + $units->returnOasisTroops($oasisWref); + + /* + * Dupa ce reinforcement-urile au fost returnate, + * oaza este eliberata. + */ + $database->removeOases($oasisWref); + } + + header("Location: build.php?id=" . $id . "&land"); + exit; } // Explicit lookup, instead of the original repetitive switch: diff --git a/Templates/Profile/overview.tpl b/Templates/Profile/overview.tpl index 7dbc59ae..e683879e 100644 --- a/Templates/Profile/overview.tpl +++ b/Templates/Profile/overview.tpl @@ -137,7 +137,7 @@ if ($displayarray['vac_mode'] == 1)