diff --git a/Admin/Templates/config.tpl b/Admin/Templates/config.tpl index 05c0f1dc..d43201f5 100644 --- a/Admin/Templates/config.tpl +++ b/Admin/Templates/config.tpl @@ -105,10 +105,6 @@ $editIcon = 'New Alliance & Embassy Mechanics ?https://github.com/Shadowss/TravianZ/wiki/New-Alliance-&-Embassy-MechanicsEnabled" : "Disabled"; ?> New forum post message ?Enable (Disable) forum subscription messagesEnabled" : "Disabled"; ?> Tribes images in profile ?Enable (Disable) displaying images of tribesEnabled" : "Disabled"; ?> - New Tribe: Huns ?Enable (Disable) the Huns tribeEnabled" : "Disabled"; ?> - New Tribe: Egyptians ?Enable (Disable) the Egyptians tribeEnabled" : "Disabled"; ?> - New Tribe: Spartans ?Enable (Disable) the Spartans tribeEnabled" : "Disabled"; ?> - New Tribe: Vikings ?Enable (Disable) the Vikings tribeEnabled" : "Disabled"; ?> MHs images in profile ?Enable (Disable) displaying images of MultihuntersEnabled" : "Disabled"; ?> Display artifact in profile ?Enable (Disable) the display of the artifactEnabled" : "Disabled"; ?> Display WoW in profile ?Enable (Disable) the display of the wonderEnabled" : "Disabled"; ?> @@ -123,6 +119,10 @@ $editIcon = 'Server Milestones?Server MilestonesEnabled" : "Disabled"; ?> Server Medal Reset Timer?Server Medal Reset TimerEnabled" : "Disabled"; ?> T4 Hero (items, adventures, auction)?T4 Hero (items, adventures, auction)Enabled" : "Disabled"; ?> + New Tribe: Huns ?Enable (Disable) the Huns tribeEnabled" : "Disabled"; ?> + New Tribe: Egyptians ?Enable (Disable) the Egyptians tribeEnabled" : "Disabled"; ?> + New Tribe: Spartans ?Enable (Disable) the Spartans tribeEnabled" : "Disabled"; ?> + New Tribe: Vikings ?Enable (Disable) the Vikings tribeEnabled" : "Disabled"; ?> diff --git a/Admin/Templates/editNewFunctions.tpl b/Admin/Templates/editNewFunctions.tpl index 71316cef..9b97ae15 100644 --- a/Admin/Templates/editNewFunctions.tpl +++ b/Admin/Templates/editNewFunctions.tpl @@ -287,6 +287,23 @@ if($_SESSION['access'] < 9) die(ACCESS_DENIED_ADMIN); + + + Registration Bonus Gold ?Enable (Disable) a one-time gold bonus granted to every newly registered player at account creation (system accounts id≤3 are skipped). The amount is set in the field below. +
Not present in config.php yet — saving this form once will add it (defaults to False until then). + + + + + + + Registration Bonus Gold – amount ?How much gold each new player receives when the toggle above is True. Default: 200. + + + diff --git a/Admin/Templates/multiacc.tpl b/Admin/Templates/multiacc.tpl new file mode 100644 index 00000000..ae7582d0 --- /dev/null +++ b/Admin/Templates/multiacc.tpl @@ -0,0 +1,168 @@ +Access denied.

'; + return; +} + +// Filters (GET). All optional. +$days = isset($_GET['days']) && ctype_digit((string)$_GET['days']) ? max(1, min(365, (int)$_GET['days'])) : MultiAccount::WINDOW_DAYS; +$minScore = isset($_GET['min']) && ctype_digit((string)$_GET['min']) ? max(0, min(100, (int)$_GET['min'])) : MultiAccount::MIN_REPORT_SCORE; +$focusUid = isset($_GET['focus']) && ctype_digit((string)$_GET['focus']) ? (int)$_GET['focus'] : 0; + +$data = MultiAccount::riskPairs(['days' => $days, 'min_score' => $minScore, 'focus_uid' => $focusUid]); +$pairs = $data['pairs']; +?> + + +
+

Multi-Account Detection

+

+ Heuristic correlation of account pairs by shared IP, subnet, device/browser + fingerprint (User-Agent), overlapping login times, and resource-transfer flow. + This is a risk score, not proof — use it to prioritise which + pairs a human should investigate. Nothing is banned automatically. +

+ +
+ +
+ + +
+
+ + +
+
+ + +
+ + + reset + +
+ + +
+ Row cap reached while scanning login history (window is large / very busy server). + Results still valid but may be incomplete — narrow the window for full coverage. +
+ + +
+ Window: last days  |  + scanned login-log + + fingerprint rows  |  + suspicious pair +  | focused on UID +
+ + +
+ No account pairs at or above the current score threshold.
+ + Tip: the User-Agent signal starts filling in only after players log in + once with this feature deployed. IP & login-time signals work on + existing history immediately. + + Try lowering the minimum score or widening the window. + +
+ + + + + + + + + + + + + + + + + + + + +
RiskAccount AAccount BWhy
+
+ +
+
+ + # + + login log + focus + + + + # + + login log + focus + + + + + + +
+ IPs: +
+ + 0): ?> +
+ transfer volume: res + +
+ +
+ +
diff --git a/Admin/Templates/pushprot.tpl b/Admin/Templates/pushprot.tpl new file mode 100644 index 00000000..c1a3b8a5 --- /dev/null +++ b/Admin/Templates/pushprot.tpl @@ -0,0 +1,170 @@ +Access denied.

'; + return; +} + +$days = isset($_GET['days']) && ctype_digit((string)$_GET['days']) ? max(1, min(30, (int)$_GET['days'])) : PushProtection::WINDOW_DAYS; +$hours = isset($_GET['hours']) && ctype_digit((string)$_GET['hours']) ? max(1, min(168, (int)$_GET['hours'])): PushProtection::HOURS_ALLOWED; +$only = isset($_GET['only']) && in_array($_GET['only'], ['all','over','flagged'], true) ? $_GET['only'] : 'all'; + +$data = PushProtection::dashboard(['days' => $days, 'hours' => $hours, 'only' => $only]); +$rows = $data['rows']; + +function pp_num($n) { return number_format((int)$n); } +?> + + +
+

Push Protection Dashboard

+

+ Per-player 7-day resource balance (received from other players) versus an + automatic limit derived from the player's hourly production + (h of production per window). + WW villages and artefact villages are flagged as supply exceptions. Set a manual + override to exempt a player (WW / trusted) or give a custom cap. Nothing is blocked + automatically — this is visibility & control for a human moderator. +

+ +
+ +
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ Window: last days  |  + limit = h × hourly production  |  + player shown +
+ + +
+ No inter-player transfers recorded in this window yet.
+ The 7-day balance fills in as merchant deliveries between different players + are processed after this feature is deployed. +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
PlayerVillages / PopProd/hReceived (7d)LimitUsageOverride
+ + # + + + + / + + from + + unlimited' : pp_num($r['eff_limit']); ?> + + + + +
%
+ +
+
+ note: +
+ + + + + + +
+
+ +
diff --git a/Admin/admin.php b/Admin/admin.php index 005b66f1..e0dc9f0c 100644 --- a/Admin/admin.php +++ b/Admin/admin.php @@ -42,6 +42,8 @@ include_once("../GameEngine/Lang/" . LANG . ".php"); include_once("../GameEngine/Admin/database.php"); include_once("../GameEngine/Data/buidata.php"); include_once("../GameEngine/Artifacts.php"); +include_once("../GameEngine/MultiAccount.php"); +include_once("../GameEngine/PushProtection.php"); // ─── SECURITY HELPERS ──────────────────────────────────────────────────────── @@ -95,6 +97,8 @@ function admin_validated_page(string $raw): string 'village', 'editResources', 'addTroops', 'addABTroops', 'editVillage', 'villagelog', 'techlog', 'msg', 'alliance', 'editAli', 'delAli','editNewFunctions', + 'multiacc', + 'pushprot', ]; return in_array($raw, $whitelist, true) ? $raw : ''; @@ -169,6 +173,14 @@ if ($page !== '') { $subpage = 'Search IGMs/Reports'; break; + case 'multiacc': + $subpage = 'Multi-Account Detection'; + break; + + case 'pushprot': + $subpage = 'Push Protection'; + break; + case 'massmessage': $subpage = 'Mass Message'; break; @@ -847,6 +859,8 @@ body.app #menu li.sub ul li a:hover{color:#d97706!important}
  • Admin diff --git a/GameEngine/Admin/Mods/editAdminInfo.php b/GameEngine/Admin/Mods/editAdminInfo.php index d36a1240..7d7700b4 100755 --- a/GameEngine/Admin/Mods/editAdminInfo.php +++ b/GameEngine/Admin/Mods/editAdminInfo.php @@ -73,12 +73,13 @@ $fh = fopen($myFile, 'w') or die("


    Can't open file: GameEngine\con $NEW_FUNCTIONS_MEDAL_10YEAR = (NEW_FUNCTIONS_MEDAL_10YEAR == false ? 'false' : 'true'); $NEW_FUNCTIONS_SPECIAL_MEDALS_SYSTEM = (NEW_FUNCTIONS_SPECIAL_MEDALS_SYSTEM == false ? 'false' : 'true'); $NEW_FUNCTIONS_MILESTONES = (NEW_FUNCTIONS_MILESTONES == false ? 'false' : 'true'); - $NEW_FUNCTIONS_HERO_T4 = (defined('NEW_FUNCTIONS_HERO_T4') && NEW_FUNCTIONS_HERO_T4 ? 'true' : 'false'); // fix: lipsea (bug preexistent) + $NEW_FUNCTIONS_HERO_T4 = (defined('NEW_FUNCTIONS_HERO_T4') && NEW_FUNCTIONS_HERO_T4 ? 'true' : 'false'); $NEW_FUNCTION_TRIBE_HUNS = (defined('NEW_FUNCTION_TRIBE_HUNS') && NEW_FUNCTION_TRIBE_HUNS ? 'true' : 'false'); $NEW_FUNCTION_TRIBE_EGIPTEANS = (defined('NEW_FUNCTION_TRIBE_EGIPTEANS') && NEW_FUNCTION_TRIBE_EGIPTEANS ? 'true' : 'false'); $NEW_FUNCTION_TRIBE_SPARTANS = (defined('NEW_FUNCTION_TRIBE_SPARTANS') && NEW_FUNCTION_TRIBE_SPARTANS ? 'true' : 'false'); $NEW_FUNCTION_TRIBE_VIKINGS = (defined('NEW_FUNCTION_TRIBE_VIKINGS') && NEW_FUNCTION_TRIBE_VIKINGS ? 'true' : 'false'); $NEW_FUNCTIONS_MEDAL_RESET = (NEW_FUNCTIONS_MEDAL_RESET == false ? 'false' : 'true'); + $text = admin_config_template_contents(); $text = preg_replace("'%ERRORREPORT%'", $ERRORREPORT, $text); @@ -171,12 +172,15 @@ $fh = fopen($myFile, 'w') or die("


    Can't open file: GameEngine\con $text = preg_replace("'%NEW_FUNCTIONS_MEDAL_10YEAR%'", $NEW_FUNCTIONS_MEDAL_10YEAR, $text); $text = preg_replace("'%NEW_FUNCTIONS_SPECIAL_MEDALS_SYSTEM%'", $NEW_FUNCTIONS_SPECIAL_MEDALS_SYSTEM, $text); $text = preg_replace("'%NEW_FUNCTIONS_MILESTONES%'", $NEW_FUNCTIONS_MILESTONES, $text); - $text = preg_replace("'%NEW_FUNCTIONS_HERO_T4%'", $NEW_FUNCTIONS_HERO_T4, $text); // fix: lipsea + $text = preg_replace("'%NEW_FUNCTIONS_HERO_T4%'", $NEW_FUNCTIONS_HERO_T4, $text); $text = preg_replace("'%NEW_FUNCTION_TRIBE_HUNS%'", $NEW_FUNCTION_TRIBE_HUNS, $text); $text = preg_replace("'%NEW_FUNCTION_TRIBE_EGIPTEANS%'", $NEW_FUNCTION_TRIBE_EGIPTEANS, $text); $text = preg_replace("'%NEW_FUNCTION_TRIBE_SPARTANS%'", $NEW_FUNCTION_TRIBE_SPARTANS, $text); $text = preg_replace("'%NEW_FUNCTION_TRIBE_VIKINGS%'", $NEW_FUNCTION_TRIBE_VIKINGS, $text); $text = preg_replace("'%NEW_FUNCTIONS_MEDAL_RESET%'", $NEW_FUNCTIONS_MEDAL_RESET, $text); + // Preserve registration-bonus-gold settings (owned by editNewFunctions.php). + $text = preg_replace("'%NEW_FUNCTION_REGISTRATION_GOLD%'", (defined('NEW_FUNCTION_REGISTRATION_GOLD') && NEW_FUNCTION_REGISTRATION_GOLD ? 'true' : 'false'), $text); + $text = preg_replace("'%NEW_FUNCTION_REGISTRATION_GOLD_VALUE%'", (string) (defined('NEW_FUNCTION_REGISTRATION_GOLD_VALUE') ? (int) NEW_FUNCTION_REGISTRATION_GOLD_VALUE : 200), $text); // PLUS settings need to be kept intact $text = preg_replace("'%PLUS_TIME%'", PLUS_TIME, $text); diff --git a/GameEngine/Admin/Mods/editExtraSet.php b/GameEngine/Admin/Mods/editExtraSet.php index 9055f858..c12d6fe3 100755 --- a/GameEngine/Admin/Mods/editExtraSet.php +++ b/GameEngine/Admin/Mods/editExtraSet.php @@ -174,6 +174,9 @@ $fh = fopen($myFile, 'w') or die("


    Can't open file: GameEngine\con $text = preg_replace("'%NEW_FUNCTION_TRIBE_EGIPTEANS%'", $NEW_FUNCTION_TRIBE_EGIPTEANS, $text); $text = preg_replace("'%NEW_FUNCTION_TRIBE_SPARTANS%'", $NEW_FUNCTION_TRIBE_SPARTANS, $text); $text = preg_replace("'%NEW_FUNCTION_TRIBE_VIKINGS%'", $NEW_FUNCTION_TRIBE_VIKINGS, $text); + // Preserve registration-bonus-gold settings (owned by editNewFunctions.php). + $text = preg_replace("'%NEW_FUNCTION_REGISTRATION_GOLD%'", (defined('NEW_FUNCTION_REGISTRATION_GOLD') && NEW_FUNCTION_REGISTRATION_GOLD ? 'true' : 'false'), $text); + $text = preg_replace("'%NEW_FUNCTION_REGISTRATION_GOLD_VALUE%'", (string) (defined('NEW_FUNCTION_REGISTRATION_GOLD_VALUE') ? (int) NEW_FUNCTION_REGISTRATION_GOLD_VALUE : 200), $text); // PLUS settings need to be kept intact $text = preg_replace("'%PLUS_TIME%'", PLUS_TIME, $text); diff --git a/GameEngine/Admin/Mods/editLogSet.php b/GameEngine/Admin/Mods/editLogSet.php index db0282d4..271d46f6 100755 --- a/GameEngine/Admin/Mods/editLogSet.php +++ b/GameEngine/Admin/Mods/editLogSet.php @@ -174,6 +174,9 @@ $fh = fopen($myFile, 'w') or die("


    Can't open file: GameEngine\con $text = preg_replace("'%NEW_FUNCTION_TRIBE_EGIPTEANS%'", $NEW_FUNCTION_TRIBE_EGIPTEANS, $text); $text = preg_replace("'%NEW_FUNCTION_TRIBE_SPARTANS%'", $NEW_FUNCTION_TRIBE_SPARTANS, $text); $text = preg_replace("'%NEW_FUNCTION_TRIBE_VIKINGS%'", $NEW_FUNCTION_TRIBE_VIKINGS, $text); + // Preserve registration-bonus-gold settings (owned by editNewFunctions.php). + $text = preg_replace("'%NEW_FUNCTION_REGISTRATION_GOLD%'", (defined('NEW_FUNCTION_REGISTRATION_GOLD') && NEW_FUNCTION_REGISTRATION_GOLD ? 'true' : 'false'), $text); + $text = preg_replace("'%NEW_FUNCTION_REGISTRATION_GOLD_VALUE%'", (string) (defined('NEW_FUNCTION_REGISTRATION_GOLD_VALUE') ? (int) NEW_FUNCTION_REGISTRATION_GOLD_VALUE : 200), $text); // PLUS settings need to be kept intact $text = preg_replace("'%PLUS_TIME%'", PLUS_TIME, $text); diff --git a/GameEngine/Admin/Mods/editNewFunctions.php b/GameEngine/Admin/Mods/editNewFunctions.php index a9745454..7f6b4c74 100644 --- a/GameEngine/Admin/Mods/editNewFunctions.php +++ b/GameEngine/Admin/Mods/editNewFunctions.php @@ -136,6 +136,12 @@ $fh = fopen($myFile, 'w') or die("


    Can't open file: GameEngine\con $text = preg_replace("'%NEW_FUNCTION_TRIBE_EGIPTEANS%'", $_POST['new_function_tribe_egipteans'], $text); $text = preg_replace("'%NEW_FUNCTION_TRIBE_SPARTANS%'", $_POST['new_function_tribe_spartans'], $text); $text = preg_replace("'%NEW_FUNCTION_TRIBE_VIKINGS%'", $_POST['new_function_tribe_vikings'], $text); + // Registration bonus gold: owned by THIS Mod (edited from the New Functions form). + $__reg_gold_on = (isset($_POST['new_function_registration_gold']) && strtolower((string)$_POST['new_function_registration_gold']) === 'true') ? 'true' : 'false'; + $__reg_gold_val = (int) ($_POST['new_function_registration_gold_value'] ?? 200); + if ($__reg_gold_val < 0) { $__reg_gold_val = 0; } + $text = preg_replace("'%NEW_FUNCTION_REGISTRATION_GOLD%'", $__reg_gold_on, $text); + $text = preg_replace("'%NEW_FUNCTION_REGISTRATION_GOLD_VALUE%'", (string) $__reg_gold_val, $text); // PLUS settings need to be kept intact $text = preg_replace("'%PLUS_TIME%'", PLUS_TIME, $text); diff --git a/GameEngine/Admin/Mods/editNewsboxSet.php b/GameEngine/Admin/Mods/editNewsboxSet.php index 69d0a1ce..67292fff 100755 --- a/GameEngine/Admin/Mods/editNewsboxSet.php +++ b/GameEngine/Admin/Mods/editNewsboxSet.php @@ -180,6 +180,9 @@ $fh = fopen($myFile, 'w') or die("


    Can't open file: GameEngine\con $text = preg_replace("'%NEW_FUNCTION_TRIBE_EGIPTEANS%'", $NEW_FUNCTION_TRIBE_EGIPTEANS, $text); $text = preg_replace("'%NEW_FUNCTION_TRIBE_SPARTANS%'", $NEW_FUNCTION_TRIBE_SPARTANS, $text); $text = preg_replace("'%NEW_FUNCTION_TRIBE_VIKINGS%'", $NEW_FUNCTION_TRIBE_VIKINGS, $text); + // Preserve registration-bonus-gold settings (owned by editNewFunctions.php). + $text = preg_replace("'%NEW_FUNCTION_REGISTRATION_GOLD%'", (defined('NEW_FUNCTION_REGISTRATION_GOLD') && NEW_FUNCTION_REGISTRATION_GOLD ? 'true' : 'false'), $text); + $text = preg_replace("'%NEW_FUNCTION_REGISTRATION_GOLD_VALUE%'", (string) (defined('NEW_FUNCTION_REGISTRATION_GOLD_VALUE') ? (int) NEW_FUNCTION_REGISTRATION_GOLD_VALUE : 200), $text); // PLUS settings need to be kept intact $text = preg_replace("'%PLUS_TIME%'", PLUS_TIME, $text); diff --git a/GameEngine/Admin/Mods/editPlusSet.php b/GameEngine/Admin/Mods/editPlusSet.php index 17bad46d..af1e2342 100644 --- a/GameEngine/Admin/Mods/editPlusSet.php +++ b/GameEngine/Admin/Mods/editPlusSet.php @@ -158,6 +158,9 @@ $fh = fopen($myFile, 'w') or die("


    Can't open file: GameEngine\con $text = preg_replace("'%NEW_FUNCTION_TRIBE_EGIPTEANS%'", $NEW_FUNCTION_TRIBE_EGIPTEANS, $text); $text = preg_replace("'%NEW_FUNCTION_TRIBE_SPARTANS%'", $NEW_FUNCTION_TRIBE_SPARTANS, $text); $text = preg_replace("'%NEW_FUNCTION_TRIBE_VIKINGS%'", $NEW_FUNCTION_TRIBE_VIKINGS, $text); + // Preserve registration-bonus-gold settings (owned by editNewFunctions.php). + $text = preg_replace("'%NEW_FUNCTION_REGISTRATION_GOLD%'", (defined('NEW_FUNCTION_REGISTRATION_GOLD') && NEW_FUNCTION_REGISTRATION_GOLD ? 'true' : 'false'), $text); + $text = preg_replace("'%NEW_FUNCTION_REGISTRATION_GOLD_VALUE%'", (string) (defined('NEW_FUNCTION_REGISTRATION_GOLD_VALUE') ? (int) NEW_FUNCTION_REGISTRATION_GOLD_VALUE : 200), $text); // PLUS SETTINGS $text = preg_replace("'%PLUS_TIME%'", $_POST['plus_time'], $text); diff --git a/GameEngine/Admin/Mods/editServerSet.php b/GameEngine/Admin/Mods/editServerSet.php index 73c5e434..b49e9d0c 100755 --- a/GameEngine/Admin/Mods/editServerSet.php +++ b/GameEngine/Admin/Mods/editServerSet.php @@ -169,6 +169,9 @@ $fh = fopen($myFile, 'w') or die("


    Can't open file: GameEngine\con $text = preg_replace("'%NEW_FUNCTION_TRIBE_EGIPTEANS%'", $NEW_FUNCTION_TRIBE_EGIPTEANS, $text); $text = preg_replace("'%NEW_FUNCTION_TRIBE_SPARTANS%'", $NEW_FUNCTION_TRIBE_SPARTANS, $text); $text = preg_replace("'%NEW_FUNCTION_TRIBE_VIKINGS%'", $NEW_FUNCTION_TRIBE_VIKINGS, $text); + // Preserve registration-bonus-gold settings (owned by editNewFunctions.php). + $text = preg_replace("'%NEW_FUNCTION_REGISTRATION_GOLD%'", (defined('NEW_FUNCTION_REGISTRATION_GOLD') && NEW_FUNCTION_REGISTRATION_GOLD ? 'true' : 'false'), $text); + $text = preg_replace("'%NEW_FUNCTION_REGISTRATION_GOLD_VALUE%'", (string) (defined('NEW_FUNCTION_REGISTRATION_GOLD_VALUE') ? (int) NEW_FUNCTION_REGISTRATION_GOLD_VALUE : 200), $text); // PLUS settings need to be kept intact $text = preg_replace("'%PLUS_TIME%'", PLUS_TIME, $text); diff --git a/GameEngine/Admin/Mods/pushOverride.php b/GameEngine/Admin/Mods/pushOverride.php new file mode 100644 index 00000000..6d547cbe --- /dev/null +++ b/GameEngine/Admin/Mods/pushOverride.php @@ -0,0 +1,73 @@ + 3) { + // Normalise mode to the allowed set. + if (!in_array($mode, [ + PushProtection::OV_NONE, + PushProtection::OV_UNLIMITED, + PushProtection::OV_CUSTOM, + ], true)) { + $mode = PushProtection::OV_NONE; + } + if ($limit < 0) { + $limit = 0; + } + + PushProtection::setOverride($uid, $mode, $limit, $note, $admid); + + $modeLabel = $mode === PushProtection::OV_UNLIMITED ? 'exempt' + : ($mode === PushProtection::OV_CUSTOM ? ('custom cap ' . $limit) : 'cleared'); + $logMsg = 'Push protection override for uid ' . $uid . ' set to ' . $modeLabel . ''; + $logMsg = mysqli_real_escape_string($GLOBALS['link'], $logMsg); + mysqli_query($GLOBALS['link'], + "INSERT INTO " . TB_PREFIX . "admin_log VALUES (0, " . $admid . ", '" . $logMsg . "', " . time() . ")"); +} + +header("Location: ../../../Admin/admin.php?p=pushprot"); +exit; +?> diff --git a/GameEngine/Automation.php b/GameEngine/Automation.php index 6b2a5674..ab2feef0 100644 --- a/GameEngine/Automation.php +++ b/GameEngine/Automation.php @@ -709,6 +709,18 @@ class Automation { $database->addNotice($from['owner'],$to['wref'],$ownally,$sort_type,''.addslashes($from['name']).' send resources to '.addslashes($to['name']).'',''.$from['owner'].','.$from['wref'].','.$data['wood'].','.$data['clay'].','.$data['iron'].','.$data['crop'].'',$data['endtime']); } $database->modifyResource($data['to'],$data['wood'],$data['clay'],$data['iron'],$data['crop'],1); + + // Push protection: record completed cross-player deliveries so the + // admin dashboard can compute 7-day resource balance. Best-effort; + // skips own-village transfers internally. + if (!$ownTransfer) { + PushProtection::logTransfer( + (int)$from['wref'], (int)$to['wref'], + (int)$from['owner'], (int)$to['owner'], + (int)$data['wood'], (int)$data['clay'], (int)$data['iron'], (int)$data['crop'], + (int)$data['endtime'] + ); + } $targettribe = $userData_to["tribe"]; $endtime = $units->getWalkingTroopsTime($data['from'], $data['to'], 0, 0, [$targettribe], 0) + $data['endtime']; $database->addMovement(2, $data['to'], $data['from'], $data['merchant'], time(), $endtime, $data['send'], $data['wood'], $data['clay'], $data['iron'], $data['crop']); diff --git a/GameEngine/Database.php b/GameEngine/Database.php index db3fccfe..7c92ff42 100755 --- a/GameEngine/Database.php +++ b/GameEngine/Database.php @@ -834,6 +834,7 @@ class MYSQLi_DB implements IDbConnection { if($stmt->execute()){ $id = $stmt->insert_id ?: $uid; $stmt->close(); + $this->grantRegistrationGold($id); return $id; } $stmt->close(); @@ -849,11 +850,52 @@ class MYSQLi_DB implements IDbConnection { if($stmt2->execute()){ $id = $stmt2->insert_id ?: $uid; $stmt2->close(); + $this->grantRegistrationGold($id); return $id; } $stmt2->close(); return false; } + /** + * Registration bonus gold (NEW_FUNCTION_REGISTRATION_GOLD). + * Grants a one-time gold bonus to a freshly created player account. Called + * from register() right after the users row is inserted, so it covers every + * registration path (email activation, instant registration, admin-created + * users). No-ops when disabled, amount <= 0, or system account (id <= 3). + */ + private function grantRegistrationGold($id) { + $id = (int) $id; + if ($id <= 3) { + return; // system accounts: admin / nature / natars + } + if (!defined('NEW_FUNCTION_REGISTRATION_GOLD') || !NEW_FUNCTION_REGISTRATION_GOLD) { + return; + } + $amount = defined('NEW_FUNCTION_REGISTRATION_GOLD_VALUE') ? (int) NEW_FUNCTION_REGISTRATION_GOLD_VALUE : 0; + if ($amount <= 0) { + return; + } + $this->modifyGold($id, $amount, 1); // mode 1 = add + + // Best-effort audit trail. The village does not exist yet, so wid = 0. + if (defined('LOG_GOLD_FIN') && LOG_GOLD_FIN) { + try { + $now = time(); + $details = 'Registration bonus'; + $stmt = $this->dblink->prepare( + "INSERT INTO `".TB_PREFIX."gold_fin_log` (wid, uid, action, gold, time, details) + VALUES (0, ?, 'Registration bonus Gold', ?, ?, ?)" + ); + if ($stmt) { + $stmt->bind_param("iiis", $id, $amount, $now, $details); + $stmt->execute(); + $stmt->close(); + } + } catch (\Throwable $e) { + // swallow: logging must never block account creation + } + } + } function activate($username, $password, $email, $tribe, $locate, $act, $act2) { $username = trim($username); diff --git a/GameEngine/Market.php b/GameEngine/Market.php index 3f6320b3..fff7d849 100755 --- a/GameEngine/Market.php +++ b/GameEngine/Market.php @@ -186,6 +186,45 @@ class Market /** * Send resources. */ + /** + * Push-protection enforcement (Travian-Legends style). + * Returns true when this send must be BLOCKED because the recipient has + * reached the amount of resources it may receive from other players in the + * rolling window (see PushProtection::remainingAllowance). + * + * No-ops (returns false) for: own-village transfers, system accounts, when + * the PushProtection engine is absent, or when hard enforcement is globally + * disabled via define("PUSH_PROTECTION_ENFORCE", false); in config.php + * (the dashboard and logging keep working either way — default is ON). + * + * WW villages and manual "Exempt" overrides return an unlimited allowance + * inside remainingAllowance(), so they are never blocked here. + */ + private function exceedsPushLimit($database, $fromWid, $toWid, array $resource) + { + if (defined('PUSH_PROTECTION_ENFORCE') && !PUSH_PROTECTION_ENFORCE) { + return false; + } + if (!class_exists('PushProtection')) { + return false; // engine not deployed -> no enforcement + } + + $fromOwner = (int) $database->getVillageField($fromWid, 'owner'); + $toOwner = (int) $database->getVillageField($toWid, 'owner'); + + // Own transfer or system account -> never limited. + if ($fromOwner === $toOwner || $toOwner <= 3) { + return false; + } + + $sendTotal = (int) $resource[0] + (int) $resource[1] + (int) $resource[2] + (int) $resource[3]; + if ($sendTotal <= 0) { + return false; + } + + return $sendTotal > PushProtection::remainingAllowance($toOwner); + } + private function sendResource($post) { global $database, $village, $session, $generator, $logging, $form; @@ -249,6 +288,16 @@ class Market ($post['send3'] > 1 && !$session->goldclub) ) { $form->addError('error', INVALID_MERCHANTS_REPETITION); + } elseif ( + $this->exceedsPushLimit($database, $village->wid, $id, $resource) + ) { + // Push protection: recipient has reached its transfer limit for the + // current window. Uses a language constant when available, else a + // clear English fallback (add PUSH_PROTECTION_LIMIT to your Lang + // files to localise). + $form->addError('error', defined('PUSH_PROTECTION_LIMIT') + ? PUSH_PROTECTION_LIMIT + : 'This account has reached its resource-transfer limit for now and cannot receive that many resources. Send less, or wait for the limit to recover.'); } elseif ( $availableWood >= $post['r1'] && $availableClay >= $post['r2'] && diff --git a/GameEngine/MultiAccount.php b/GameEngine/MultiAccount.php new file mode 100644 index 00000000..518e794c --- /dev/null +++ b/GameEngine/MultiAccount.php @@ -0,0 +1,609 @@ + "currently pushing" + * trade signal. + * - resource_transfer_log : if present (created by the Push-Protection phase), + * gives a richer historical trade-flow signal. Used + * automatically when the table exists. + * + * The engine is intentionally self-contained (static methods, resolves the DB + * link from globals) so it can be called from both the in-game login flow and + * the admin panel without wiring. + */ +class MultiAccount +{ + /* ---- Tunables (safe to adjust) ------------------------------------- */ + + /** How far back to look, in days. */ + const WINDOW_DAYS = 30; + + /** Hard cap on login rows scanned per source (memory / runtime guard). */ + const MAX_ROWS = 60000; + + /** A shared key (IP / subnet / UA) used by MORE than this many accounts is + * treated as a public/NAT/proxy artefact: it still counts, but with a + * reduced weight and it never explodes pair generation. */ + const POPULAR_KEY_CAP = 12; + + /** Two logins closer than this (seconds) from the SAME IP look like one + * person switching accounts. */ + const SWITCH_WINDOW = 900; // 15 minutes + + /** Only pairs at or above this score are returned. */ + const MIN_REPORT_SCORE = 20; + + /** Max pairs returned to the UI. */ + const MAX_PAIRS = 150; + + /* ---- DB plumbing --------------------------------------------------- */ + + /** Resolve the raw mysqli link from whatever context we run in. */ + private static function link() + { + if (isset($GLOBALS['link']) && $GLOBALS['link']) { + return $GLOBALS['link']; + } + if (isset($GLOBALS['database']) && isset($GLOBALS['database']->dblink)) { + return $GLOBALS['database']->dblink; + } + return null; + } + + /** + * Create the mad_session table if it does not exist. Called lazily so the + * feature works even on servers that never re-ran the installer. + */ + public static function ensureSchema() + { + $link = self::link(); + if (!$link) { + return; + } + $sql = "CREATE TABLE IF NOT EXISTS `" . TB_PREFIX . "mad_session` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `uid` int(11) NOT NULL, + `ip` varbinary(16) DEFAULT NULL, + `ip_text` varchar(45) NOT NULL DEFAULT '', + `ua_hash` char(32) NOT NULL DEFAULT '', + `ua_text` varchar(255) NOT NULL DEFAULT '', + `login_time` int(11) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `uid` (`uid`), + KEY `ip` (`ip`), + KEY `ua_hash` (`ua_hash`), + KEY `login_time` (`login_time`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8"; + @mysqli_query($link, $sql); + } + + /* ---- Data collection (called at login) ----------------------------- */ + + /** + * Record one login "session fingerprint". Best-effort: any failure is + * swallowed so it can never block a login. + * + * @param int $uid User id. + * @param string $ipText Client IP (already resolved by the caller). + * @param string $userAgent Raw User-Agent header ($_SERVER['HTTP_USER_AGENT']). + */ + public static function recordSession($uid, $ipText, $userAgent) + { + try { + $uid = (int) $uid; + if ($uid <= 3) { + return; // system accounts + } + + $link = self::link(); + if (!$link) { + return; + } + + self::ensureSchema(); + + $ipText = (string) $ipText; + $packed = @inet_pton($ipText); + if ($packed === false) { + $packed = null; + } + $uaText = substr((string) $userAgent, 0, 255); + $uaHash = md5($uaText); + $now = time(); + + $stmt = mysqli_prepare( + $link, + "INSERT INTO `" . TB_PREFIX . "mad_session` + (uid, ip, ip_text, ua_hash, ua_text, login_time) + VALUES (?,?,?,?,?,?)" + ); + if (!$stmt) { + return; + } + // ip is binary -> bind as blob ('b') via send_long_data-free path: + // mysqli lets us bind a string to a varbinary column with type 's'. + $ipBind = $packed === null ? '' : $packed; + mysqli_stmt_bind_param($stmt, 'issssi', $uid, $ipBind, $ipText, $uaHash, $uaText, $now); + mysqli_stmt_execute($stmt); + mysqli_stmt_close($stmt); + } catch (\Throwable $e) { + // never break login + } + } + + /* ---- Correlation / scoring ---------------------------------------- */ + + /** + * Build the ranked list of suspicious account pairs. + * + * @param array $opts Optional overrides: 'days', 'min_score', 'focus_uid'. + * @return array { + * 'pairs' => list of pair rows (see below), sorted by score desc, + * 'scanned' => ['login_log'=>int, 'mad_session'=>int], + * 'window_days' => int, + * 'truncated' => bool, // true if a source hit MAX_ROWS + * } + * + * Each pair row: + * uid_a, name_a, uid_b, name_b, score (0-100), label, + * shared_ips, shared_subnets, shared_uas, switch_events, + * trade_gross, trade_dir, reasons (string[]) + */ + public static function riskPairs(array $opts = []) + { + $link = self::link(); + if (!$link) { + return ['pairs' => [], 'scanned' => ['login_log' => 0, 'mad_session' => 0], + 'window_days' => self::WINDOW_DAYS, 'truncated' => false]; + } + self::ensureSchema(); + + $days = isset($opts['days']) ? max(1, (int) $opts['days']) : self::WINDOW_DAYS; + $minScore = isset($opts['min_score']) ? (int) $opts['min_score'] : self::MIN_REPORT_SCORE; + $focusUid = isset($opts['focus_uid']) ? (int) $opts['focus_uid'] : 0; + $since = time() - $days * 86400; + + // Per-uid aggregates and inverted indices. + $ips = []; // uid => [ip => true] + $subnets = []; // uid => [subnet => true] + $uas = []; // uid => [uaHash => true] + $logins = []; // uid => [ [ts, ip], ... ] + + $ipIndex = []; // ip => [uid => true] + $subnetIndex = []; // subnet => [uid => true] + $uaIndex = []; // uaHash => [uid => true] + + $scannedLogin = 0; + $scannedMad = 0; + $truncated = false; + + // ---- Source 1: login_log (uid, ip text, date) ---- + $cap = self::MAX_ROWS; + $q = "SELECT uid, ip, UNIX_TIMESTAMP(`date`) AS ts + FROM `" . TB_PREFIX . "login_log` + WHERE uid > 3 AND UNIX_TIMESTAMP(`date`) >= " . (int) $since . " + ORDER BY `date` DESC + LIMIT " . (int) $cap; + if ($res = @mysqli_query($link, $q)) { + while ($row = mysqli_fetch_assoc($res)) { + $scannedLogin++; + self::ingest((int) $row['uid'], (string) $row['ip'], (int) $row['ts'], '', + $ips, $subnets, $uas, $logins, $ipIndex, $subnetIndex, $uaIndex); + } + mysqli_free_result($res); + if ($scannedLogin >= $cap) { + $truncated = true; + } + } + + // ---- Source 2: mad_session (adds UA) ---- + $q = "SELECT uid, ip_text, ua_hash, login_time + FROM `" . TB_PREFIX . "mad_session` + WHERE uid > 3 AND login_time >= " . (int) $since . " + ORDER BY login_time DESC + LIMIT " . (int) $cap; + if ($res = @mysqli_query($link, $q)) { + while ($row = mysqli_fetch_assoc($res)) { + $scannedMad++; + self::ingest((int) $row['uid'], (string) $row['ip_text'], (int) $row['login_time'], + (string) $row['ua_hash'], + $ips, $subnets, $uas, $logins, $ipIndex, $subnetIndex, $uaIndex); + } + mysqli_free_result($res); + if ($scannedMad >= $cap) { + $truncated = true; + } + } + + // ---- Candidate pair generation from shared keys ---- + $candidates = []; // "a:b" => true + self::pairsFromIndex($ipIndex, $candidates, $focusUid); + self::pairsFromIndex($subnetIndex, $candidates, $focusUid); + self::pairsFromIndex($uaIndex, $candidates, $focusUid); + + // ---- Score each candidate pair ---- + $pairs = []; + foreach ($candidates as $key => $_) { + list($a, $b) = explode(':', $key); + $a = (int) $a; + $b = (int) $b; + + $scored = self::scorePair($a, $b, $ips, $subnets, $uas, $logins, $link, $days); + if ($scored['score'] >= $minScore) { + $pairs[] = $scored; + } + } + + // Resolve names for the pairs we keep (bounded set). + self::attachNames($pairs, $link); + + // Sort by score desc, then trade gross desc. + usort($pairs, function ($x, $y) { + if ($x['score'] === $y['score']) { + return $y['trade_gross'] <=> $x['trade_gross']; + } + return $y['score'] <=> $x['score']; + }); + + if (count($pairs) > self::MAX_PAIRS) { + $pairs = array_slice($pairs, 0, self::MAX_PAIRS); + } + + return [ + 'pairs' => $pairs, + 'scanned' => ['login_log' => $scannedLogin, 'mad_session' => $scannedMad], + 'window_days' => $days, + 'truncated' => $truncated, + ]; + } + + /** Fold one login event into all aggregates + indices. */ + private static function ingest($uid, $ip, $ts, $uaHash, + &$ips, &$subnets, &$uas, &$logins, &$ipIndex, &$subnetIndex, &$uaIndex) + { + if ($uid <= 3) { + return; + } + $ip = trim($ip); + if ($ip !== '' && $ip !== '0.0.0.0') { + $ips[$uid][$ip] = true; + $ipIndex[$ip][$uid] = true; + $sub = self::subnet($ip); + if ($sub !== '') { + $subnets[$uid][$sub] = true; + $subnetIndex[$sub][$uid] = true; + } + } + if ($uaHash !== '' && $uaHash !== md5('')) { + $uas[$uid][$uaHash] = true; + $uaIndex[$uaHash][$uid] = true; + } + if ($ts > 0) { + $logins[$uid][] = [$ts, $ip]; + } + } + + /** /24 for IPv4, /48-ish (first 3 hextets) for IPv6. */ + private static function subnet($ip) + { + if (strpos($ip, '.') !== false) { + $p = explode('.', $ip); + if (count($p) === 4) { + return $p[0] . '.' . $p[1] . '.' . $p[2] . '.'; + } + } elseif (strpos($ip, ':') !== false) { + $p = explode(':', $ip); + return $p[0] . ':' . ($p[1] ?? '') . ':' . ($p[2] ?? '') . '::'; + } + return ''; + } + + /** + * Turn an inverted index (key => [uid => true]) into candidate pairs. + * Skips popular keys (public NAT/proxy) to avoid noise and O(n^2) blowup. + */ + private static function pairsFromIndex(array $index, array &$candidates, $focusUid) + { + foreach ($index as $key => $uidSet) { + $uids = array_keys($uidSet); + $n = count($uids); + if ($n < 2 || $n > self::POPULAR_KEY_CAP) { + continue; + } + sort($uids); + for ($i = 0; $i < $n; $i++) { + for ($j = $i + 1; $j < $n; $j++) { + $a = $uids[$i]; + $b = $uids[$j]; + if ($focusUid && $a !== $focusUid && $b !== $focusUid) { + continue; + } + $candidates[$a . ':' . $b] = true; + } + } + } + } + + /** Score a single (a,b) pair and produce a reason breakdown. */ + private static function scorePair($a, $b, $ips, $subnets, $uas, $logins, $link, $days) + { + $sharedIps = array_keys(array_intersect_key( + $ips[$a] ?? [], $ips[$b] ?? [] + )); + $sharedSubs = array_keys(array_intersect_key( + $subnets[$a] ?? [], $subnets[$b] ?? [] + )); + $sharedUas = array_keys(array_intersect_key( + $uas[$a] ?? [], $uas[$b] ?? [] + )); + + // Subnets that are shared but NOT already covered by a shared full IP. + // Compare exact subnets (self::subnet) — a string-prefix test would treat + // e.g. "85.204.1." as a prefix of IP "85.204.13.9" (false positive). + $subOnly = []; + foreach ($sharedSubs as $s) { + $hasFull = false; + foreach ($sharedIps as $ip) { + if (self::subnet($ip) === $s) { + $hasFull = true; + break; + } + } + if (!$hasFull) { + $subOnly[] = $s; + } + } + + // Login "switch" events: logins of a and b from the same shared IP within + // SWITCH_WINDOW seconds of each other. + $switch = self::switchEvents($logins[$a] ?? [], $logins[$b] ?? [], $sharedIps); + + // Trade flow between the pair (villages resolved inside). + $trade = self::tradeFlow($a, $b, $link, $days); + + // ---- Weighted score ---- + $score = 0; + $reasons = []; + + if (count($sharedIps) > 0) { + $add = min(50, 35 + 5 * (count($sharedIps) - 1)); + $score += $add; + $reasons[] = count($sharedIps) . ' shared IP' . (count($sharedIps) > 1 ? 's' : ''); + } + if (count($sharedUas) > 0) { + $add = min(30, 20 + 5 * (count($sharedUas) - 1)); + $score += $add; + $reasons[] = count($sharedUas) . ' identical device/browser fingerprint' + . (count($sharedUas) > 1 ? 's' : ''); + } + if (count($subOnly) > 0) { + $score += 10; + $reasons[] = 'same /24 subnet'; + } + if ($switch > 0) { + $add = min(30, 15 + 5 * $switch); + $score += $add; + $reasons[] = $switch . ' rapid account switch' . ($switch > 1 ? 'es' : '') + . ' from one IP'; + } + if ($trade['gross'] > 0 && $trade['directional']) { + $score += 20; + $reasons[] = 'one-directional resource transfers'; + } elseif ($trade['gross'] > 0) { + $score += 8; + $reasons[] = 'resource transfers between them'; + } + + $score = (int) min(100, $score); + $label = $score >= 70 ? 'High' : ($score >= 40 ? 'Medium' : 'Low'); + + return [ + 'uid_a' => $a, + 'uid_b' => $b, + 'name_a' => '', + 'name_b' => '', + 'score' => $score, + 'label' => $label, + 'shared_ips' => count($sharedIps), + 'shared_ip_list' => array_slice($sharedIps, 0, 6), + 'shared_subnets' => count($subOnly), + 'shared_uas' => count($sharedUas), + 'switch_events' => $switch, + 'trade_gross' => (int) $trade['gross'], + 'trade_dir' => $trade['directional'] ? 1 : 0, + 'reasons' => $reasons, + ]; + } + + /** Count login "switches" from a shared IP within SWITCH_WINDOW seconds. */ + private static function switchEvents(array $la, array $lb, array $sharedIps) + { + if (empty($sharedIps) || empty($la) || empty($lb)) { + return 0; + } + $sharedSet = array_flip($sharedIps); + + // Keep only events from shared IPs. + $ea = []; + foreach ($la as $e) { + if (isset($sharedSet[$e[1]])) { + $ea[] = $e[0]; + } + } + $eb = []; + foreach ($lb as $e) { + if (isset($sharedSet[$e[1]])) { + $eb[] = $e[0]; + } + } + if (empty($ea) || empty($eb)) { + return 0; + } + sort($ea); + sort($eb); + + // Two-pointer count of a<->b logins within the window. + $count = 0; + $j = 0; + $m = count($eb); + foreach ($ea as $ta) { + while ($j < $m && $eb[$j] < $ta - self::SWITCH_WINDOW) { + $j++; + } + $k = $j; + while ($k < $m && $eb[$k] <= $ta + self::SWITCH_WINDOW) { + $count++; + $k++; + if ($count >= 20) { + return 20; // cap + } + } + } + return $count; + } + + /** + * Resource-transfer signal between two players. + * Prefers resource_transfer_log (Push-Protection phase) when present, else + * falls back to in-flight merchant movements. Returns gross total moved and + * whether flow is strongly one-directional. + */ + private static function tradeFlow($a, $b, $link, $days) + { + $out = ['gross' => 0, 'directional' => false]; + + // Village ids owned by each player. + $va = self::villagesOf($a, $link); + $vb = self::villagesOf($b, $link); + if (empty($va) || empty($vb)) { + return $out; + } + $vaIn = implode(',', array_map('intval', $va)); + $vbIn = implode(',', array_map('intval', $vb)); + $since = time() - $days * 86400; + + $aToB = 0; + $bToA = 0; + + // Preferred: persistent transfer log (created by push protection). + $hasLog = @mysqli_query($link, + "SELECT 1 FROM `" . TB_PREFIX . "resource_transfer_log` LIMIT 1"); + if ($hasLog !== false) { + @mysqli_free_result($hasLog); + $sumCols = "(wood+clay+iron+crop)"; + $r = @mysqli_query($link, + "SELECT COALESCE(SUM($sumCols),0) FROM `" . TB_PREFIX . "resource_transfer_log` + WHERE from_vref IN ($vaIn) AND to_vref IN ($vbIn) AND `time` >= " . (int) $since); + if ($r && ($row = mysqli_fetch_row($r))) { + $aToB = (int) $row[0]; + } + $r = @mysqli_query($link, + "SELECT COALESCE(SUM($sumCols),0) FROM `" . TB_PREFIX . "resource_transfer_log` + WHERE from_vref IN ($vbIn) AND to_vref IN ($vaIn) AND `time` >= " . (int) $since); + if ($r && ($row = mysqli_fetch_row($r))) { + $bToA = (int) $row[0]; + } + } else { + // Fallback: in-flight merchant transfers (movement sort_type = 0), + // resources live in the linked `send` row (m.ref = s.id). + $sql = "SELECT COALESCE(SUM(s.wood+s.clay+s.iron+s.crop),0) + FROM `" . TB_PREFIX . "movement` m + JOIN `" . TB_PREFIX . "send` s ON m.ref = s.id + WHERE m.sort_type = 0 AND m.proc = 0 + AND m.`from` IN (%FROM%) AND m.`to` IN (%TO%)"; + $r = @mysqli_query($link, str_replace(['%FROM%', '%TO%'], [$vaIn, $vbIn], $sql)); + if ($r && ($row = mysqli_fetch_row($r))) { + $aToB = (int) $row[0]; + } + $r = @mysqli_query($link, str_replace(['%FROM%', '%TO%'], [$vbIn, $vaIn], $sql)); + if ($r && ($row = mysqli_fetch_row($r))) { + $bToA = (int) $row[0]; + } + } + + $gross = $aToB + $bToA; + $out['gross'] = $gross; + if ($gross > 0) { + $maxDir = max($aToB, $bToA); + // Strongly one-directional if >= 85% flows one way and it is material. + $out['directional'] = ($maxDir >= 0.85 * $gross) && ($gross >= 5000); + } + return $out; + } + + /** Village wrefs owned by a user (cached per-request). */ + private static function villagesOf($uid, $link) + { + static $cache = []; + $uid = (int) $uid; + if (isset($cache[$uid])) { + return $cache[$uid]; + } + $out = []; + $r = @mysqli_query($link, + "SELECT wref FROM `" . TB_PREFIX . "vdata` WHERE owner = " . $uid); + if ($r) { + while ($row = mysqli_fetch_row($r)) { + $out[] = (int) $row[0]; + } + mysqli_free_result($r); + } + return $cache[$uid] = $out; + } + + /** Fill name_a / name_b for the reported pairs in one query. */ + private static function attachNames(array &$pairs, $link) + { + if (empty($pairs)) { + return; + } + $ids = []; + foreach ($pairs as $p) { + $ids[$p['uid_a']] = true; + $ids[$p['uid_b']] = true; + } + $in = implode(',', array_map('intval', array_keys($ids))); + $names = []; + $r = @mysqli_query($link, + "SELECT id, username FROM `" . TB_PREFIX . "users` WHERE id IN ($in)"); + if ($r) { + while ($row = mysqli_fetch_assoc($r)) { + $names[(int) $row['id']] = $row['username']; + } + mysqli_free_result($r); + } + foreach ($pairs as &$p) { + $p['name_a'] = $names[$p['uid_a']] ?? ('#' . $p['uid_a']); + $p['name_b'] = $names[$p['uid_b']] ?? ('#' . $p['uid_b']); + } + unset($p); + } +} diff --git a/GameEngine/PushProtection.php b/GameEngine/PushProtection.php new file mode 100644 index 00000000..79af2393 --- /dev/null +++ b/GameEngine/PushProtection.php @@ -0,0 +1,529 @@ + automatic limit applies + const OV_UNLIMITED = 1; // exempt (WW villages, trusted) -> no limit + const OV_CUSTOM = 3; // custom_limit is an absolute cap (resources / window) + + /* ---- DB plumbing --------------------------------------------------- */ + + private static function link() + { + if (isset($GLOBALS['link']) && $GLOBALS['link']) { + return $GLOBALS['link']; + } + if (isset($GLOBALS['database']) && isset($GLOBALS['database']->dblink)) { + return $GLOBALS['database']->dblink; + } + return null; + } + + /** Create both tables if missing (lazy — works without re-running installer). */ + public static function ensureSchema() + { + $link = self::link(); + if (!$link) { + return; + } + @mysqli_query($link, "CREATE TABLE IF NOT EXISTS `" . TB_PREFIX . "resource_transfer_log` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `from_vref` int(11) NOT NULL DEFAULT 0, + `to_vref` int(11) NOT NULL DEFAULT 0, + `from_uid` int(11) NOT NULL DEFAULT 0, + `to_uid` int(11) NOT NULL DEFAULT 0, + `wood` int(11) NOT NULL DEFAULT 0, + `clay` int(11) NOT NULL DEFAULT 0, + `iron` int(11) NOT NULL DEFAULT 0, + `crop` int(11) NOT NULL DEFAULT 0, + `time` int(11) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `to_uid_time` (`to_uid`,`time`), + KEY `from_uid_time` (`from_uid`,`time`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8"); + + @mysqli_query($link, "CREATE TABLE IF NOT EXISTS `" . TB_PREFIX . "push_override` ( + `uid` int(11) NOT NULL, + `mode` tinyint(2) NOT NULL DEFAULT 0, + `custom_limit` bigint(20) NOT NULL DEFAULT 0, + `note` varchar(255) NOT NULL DEFAULT '', + `set_by` int(11) NOT NULL DEFAULT 0, + `time` int(11) NOT NULL DEFAULT 0, + PRIMARY KEY (`uid`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8"); + } + + /* ---- Data collection (called from Automation on delivery) ---------- */ + + /** + * Log one completed cross-player resource delivery. Best-effort; never + * throws. Skips same-owner transfers and system accounts. + */ + public static function logTransfer($fromVref, $toVref, $fromUid, $toUid, + $wood, $clay, $iron, $crop, $time = 0) + { + try { + $fromUid = (int) $fromUid; + $toUid = (int) $toUid; + if ($fromUid === $toUid || $fromUid <= 3 || $toUid <= 3) { + return; // own transfer or system account + } + $wood = (int) $wood; $clay = (int) $clay; $iron = (int) $iron; $crop = (int) $crop; + if (($wood + $clay + $iron + $crop) <= 0) { + return; + } + $link = self::link(); + if (!$link) { + return; + } + self::ensureSchema(); + + $fromVref = (int) $fromVref; + $toVref = (int) $toVref; + $time = $time > 0 ? (int) $time : time(); + + $stmt = mysqli_prepare($link, + "INSERT INTO `" . TB_PREFIX . "resource_transfer_log` + (from_vref, to_vref, from_uid, to_uid, wood, clay, iron, crop, `time`) + VALUES (?,?,?,?,?,?,?,?,?)"); + if (!$stmt) { + return; + } + mysqli_stmt_bind_param($stmt, 'iiiiiiiii', + $fromVref, $toVref, $fromUid, $toUid, $wood, $clay, $iron, $crop, $time); + mysqli_stmt_execute($stmt); + mysqli_stmt_close($stmt); + } catch (\Throwable $e) { + // never break the automation cron + } + } + + /* ---- Overrides ----------------------------------------------------- */ + + /** All overrides as uid => ['mode','custom_limit','note','set_by','time']. */ + public static function overrides() + { + $link = self::link(); + $out = []; + if (!$link) { + return $out; + } + self::ensureSchema(); + $r = @mysqli_query($link, "SELECT * FROM `" . TB_PREFIX . "push_override`"); + if ($r) { + while ($row = mysqli_fetch_assoc($r)) { + $out[(int) $row['uid']] = $row; + } + mysqli_free_result($r); + } + return $out; + } + + /** Insert/update or clear an override. mode OV_NONE removes it. */ + public static function setOverride($uid, $mode, $customLimit, $note, $adminId) + { + $link = self::link(); + if (!$link) { + return false; + } + self::ensureSchema(); + + $uid = (int) $uid; + $mode = (int) $mode; + $custom = (int) $customLimit; + $admin = (int) $adminId; + $now = time(); + + if ($uid <= 3) { + return false; + } + + if ($mode === self::OV_NONE) { + $stmt = mysqli_prepare($link, + "DELETE FROM `" . TB_PREFIX . "push_override` WHERE uid = ?"); + if (!$stmt) { return false; } + mysqli_stmt_bind_param($stmt, 'i', $uid); + $ok = mysqli_stmt_execute($stmt); + mysqli_stmt_close($stmt); + return $ok; + } + + $note = substr((string) $note, 0, 255); + $stmt = mysqli_prepare($link, + "INSERT INTO `" . TB_PREFIX . "push_override` (uid, mode, custom_limit, note, set_by, `time`) + VALUES (?,?,?,?,?,?) + ON DUPLICATE KEY UPDATE mode=VALUES(mode), custom_limit=VALUES(custom_limit), + note=VALUES(note), set_by=VALUES(set_by), `time`=VALUES(`time`)"); + if (!$stmt) { return false; } + mysqli_stmt_bind_param($stmt, 'iiisii', $uid, $mode, $custom, $note, $admin, $now); + $ok = mysqli_stmt_execute($stmt); + mysqli_stmt_close($stmt); + return $ok; + } + + /* ---- Exception sets (WW / artefact villages) ----------------------- */ + + /** Owners holding at least one WW village (fdata.f99t = 40 = WONDER). */ + public static function wwOwners() + { + $link = self::link(); + $out = []; + if (!$link) { return $out; } + $r = @mysqli_query($link, + "SELECT DISTINCT v.owner + FROM `" . TB_PREFIX . "vdata` v + JOIN `" . TB_PREFIX . "fdata` f ON v.wref = f.vref + WHERE f.f99t = 40 AND v.owner > 3"); + if ($r) { + while ($row = mysqli_fetch_row($r)) { $out[(int) $row[0]] = true; } + mysqli_free_result($r); + } + return $out; + } + + /** Owners holding at least one active artefact. */ + public static function artefactOwners() + { + $link = self::link(); + $out = []; + if (!$link) { return $out; } + $r = @mysqli_query($link, + "SELECT DISTINCT owner FROM `" . TB_PREFIX . "artefacts` + WHERE active = 1 AND (del = 0 OR del IS NULL) AND owner > 3"); + if ($r) { + while ($row = mysqli_fetch_row($r)) { $out[(int) $row[0]] = true; } + mysqli_free_result($r); + } + return $out; + } + + /* ---- Production model ---------------------------------------------- */ + + /** + * Net hourly production for a whole account (sum of all villages). + * Wood+Clay+Iron + max(0, Crop - population). Uses the same field/factory + * math as Village.php (base fields + factory %, x SPEED), but omits oasis / + * hero / plus bonuses (not resolvable in a batch context) — a documented, + * slightly conservative approximation. Returns int resources/hour. + */ + public static function hourlyProduction($uid, $link = null) + { + $link = $link ?: self::link(); + if (!$link) { return 0; } + + $uid = (int) $uid; + $total = 0; + $r = @mysqli_query($link, + "SELECT f.*, v.pop + FROM `" . TB_PREFIX . "fdata` f + JOIN `" . TB_PREFIX . "vdata` v ON v.wref = f.vref + WHERE v.owner = " . $uid); + if (!$r) { return 0; } + while ($f = mysqli_fetch_assoc($r)) { + list($w, $c, $i, $cr) = self::villageProd($f); + $pop = (int) ($f['pop'] ?? 0); + $cropNet = max(0, $cr - $pop); + $total += $w + $c + $i + $cropNet; + } + mysqli_free_result($r); + return (int) $total; + } + + /** Per-village production [wood,clay,iron,crop] per hour from an fdata row. */ + private static function villageProd(array $f) + { + global $bid1, $bid2, $bid3, $bid4, $bid5, $bid6, $bid7, $bid8, $bid9; + if (!isset($bid1)) { + return [0, 0, 0, 0]; // buidata not loaded in this context + } + $wood = $clay = $iron = $crop = 0; + $saw = $brick = $found = $mill = $bake = 0; + + for ($i = 1; $i <= 38; $i++) { + $t = (int) ($f["f{$i}t"] ?? 0); + $lv = (int) ($f["f{$i}"] ?? 0); + if ($t === 1) { $wood += $bid1[$lv]['prod'] ?? 0; } + elseif ($t === 2) { $clay += $bid2[$lv]['prod'] ?? 0; } + elseif ($t === 3) { $iron += $bid3[$lv]['prod'] ?? 0; } + elseif ($t === 4) { $crop += $bid4[$lv]['prod'] ?? 0; } + elseif ($t === 5) { $saw = $lv; } + elseif ($t === 6) { $brick = $lv; } + elseif ($t === 7) { $found = $lv; } + elseif ($t === 8) { $mill = $lv; } + elseif ($t === 9) { $bake = $lv; } + } + if ($saw) { $wood += $wood / 100 * ($bid5[$saw]['attri'] ?? 0); } + if ($brick) { $clay += $clay / 100 * ($bid6[$brick]['attri'] ?? 0); } + if ($found) { $iron += $iron / 100 * ($bid7[$found]['attri'] ?? 0); } + if ($mill || $bake) { + $crop += $crop / 100 * (($bid8[$mill]['attri'] ?? 0) + ($bid9[$bake]['attri'] ?? 0)); + } + $spd = defined('SPEED') ? SPEED : 1; + return [ + (int) round($wood * $spd), + (int) round($clay * $spd), + (int) round($iron * $spd), + (int) round($crop * $spd), + ]; + } + + /* ---- 7-day balance ------------------------------------------------- */ + + /** + * Total resources each player RECEIVED FROM OTHERS in the window, keyed by + * to_uid. Returns uid => ['total','wood','clay','iron','crop','senders']. + */ + public static function receivedFromOthers($sinceTs, $link = null) + { + $link = $link ?: self::link(); + $out = []; + if (!$link) { return $out; } + self::ensureSchema(); + + $r = @mysqli_query($link, + "SELECT to_uid, + SUM(wood) w, SUM(clay) c, SUM(iron) i, SUM(crop) cr, + SUM(wood+clay+iron+crop) tot, + COUNT(DISTINCT from_uid) senders + FROM `" . TB_PREFIX . "resource_transfer_log` + WHERE `time` >= " . (int) $sinceTs . " AND from_uid <> to_uid + GROUP BY to_uid"); + if ($r) { + while ($row = mysqli_fetch_assoc($r)) { + $out[(int) $row['to_uid']] = [ + 'total' => (int) $row['tot'], + 'wood' => (int) $row['w'], + 'clay' => (int) $row['c'], + 'iron' => (int) $row['i'], + 'crop' => (int) $row['cr'], + 'senders' => (int) $row['senders'], + ]; + } + mysqli_free_result($r); + } + return $out; + } + + /* ---- Dashboard ----------------------------------------------------- */ + + /** + * Build the push-protection dashboard rows. + * + * @param array $opts 'days','hours','only' ('all'|'over'|'flagged'). + * @return array ['rows'=>..., 'window_days'=>..., 'hours_allowed'=>...] + */ + public static function dashboard(array $opts = []) + { + $link = self::link(); + if (!$link) { + return ['rows' => [], 'window_days' => self::WINDOW_DAYS, 'hours_allowed' => self::HOURS_ALLOWED]; + } + self::ensureSchema(); + + $days = isset($opts['days']) ? max(1, (int) $opts['days']) : self::WINDOW_DAYS; + $hours = isset($opts['hours']) ? max(1, (int) $opts['hours']) : self::HOURS_ALLOWED; + $only = $opts['only'] ?? 'all'; + $since = time() - $days * 86400; + + $received = self::receivedFromOthers($since, $link); + $overrides = self::overrides(); + $ww = self::wwOwners(); + $arte = self::artefactOwners(); + + // Candidate set: anyone who received from others, plus flagged/override. + $uids = []; + foreach (array_keys($received) as $u) { $uids[$u] = true; } + foreach (array_keys($overrides) as $u) { $uids[$u] = true; } + foreach (array_keys($ww) as $u) { $uids[$u] = true; } + foreach (array_keys($arte) as $u) { $uids[$u] = true; } + unset($uids[0]); + + if (empty($uids)) { + return ['rows' => [], 'window_days' => $days, 'hours_allowed' => $hours]; + } + + // Names / pop / village count in one pass. + $in = implode(',', array_map('intval', array_keys($uids))); + $meta = []; + $r = @mysqli_query($link, + "SELECT u.id, u.username, + (SELECT COUNT(*) FROM `" . TB_PREFIX . "vdata` v WHERE v.owner = u.id) AS villages, + (SELECT COALESCE(SUM(v2.pop),0) FROM `" . TB_PREFIX . "vdata` v2 WHERE v2.owner = u.id) AS pop + FROM `" . TB_PREFIX . "users` u WHERE u.id IN ($in)"); + if ($r) { + while ($row = mysqli_fetch_assoc($r)) { + $meta[(int) $row['id']] = $row; + } + mysqli_free_result($r); + } + + $rows = []; + foreach (array_keys($uids) as $uid) { + $uid = (int) $uid; + if ($uid <= 3 || !isset($meta[$uid])) { + continue; + } + $rec = $received[$uid]['total'] ?? 0; + $prod = self::hourlyProduction($uid, $link); + $autoLimit = $hours * $prod; + + $isWW = isset($ww[$uid]); + $isArte = isset($arte[$uid]); + $ov = $overrides[$uid] ?? null; + + // Effective limit + status. + $unlimited = false; + $effLimit = $autoLimit; + $ovLabel = ''; + if ($ov) { + $mode = (int) $ov['mode']; + if ($mode === self::OV_UNLIMITED) { + $unlimited = true; + $ovLabel = 'Exempt (manual)'; + } elseif ($mode === self::OV_CUSTOM) { + $effLimit = (int) $ov['custom_limit']; + $ovLabel = 'Custom cap'; + } + } + + if ($unlimited) { + $pct = 0; + $status = 'Exempt'; + } elseif ($effLimit <= 0) { + $pct = $rec > 0 ? 999 : 0; + $status = $rec > 0 ? 'Over' : 'OK'; + } else { + $pct = (int) round($rec / $effLimit * 100); + $status = $pct > 100 ? 'Over' : ($pct >= self::NEAR_LIMIT_PCT ? 'Near' : 'OK'); + } + + $flags = []; + if ($isWW) { $flags[] = 'WW village'; } + if ($isArte) { $flags[] = 'Artefact village'; } + + $row = [ + 'uid' => $uid, + 'name' => $meta[$uid]['username'], + 'villages' => (int) $meta[$uid]['villages'], + 'pop' => (int) $meta[$uid]['pop'], + 'prod_h' => $prod, + 'auto_limit' => $autoLimit, + 'eff_limit' => $unlimited ? -1 : $effLimit, // -1 = unlimited + 'received' => $rec, + 'senders' => $received[$uid]['senders'] ?? 0, + 'pct' => $pct, + 'status' => $status, + 'is_ww' => $isWW, + 'is_arte' => $isArte, + 'ov_mode' => $ov ? (int) $ov['mode'] : self::OV_NONE, + 'ov_label' => $ovLabel, + 'ov_note' => $ov['note'] ?? '', + 'flags' => $flags, + ]; + + if ($only === 'over' && $status !== 'Over') { continue; } + if ($only === 'flagged' && !$isWW && !$isArte && !$ov) { continue; } + + $rows[] = $row; + } + + // Sort: Over first, then by pct desc, then received desc. + usort($rows, function ($a, $b) { + $rank = ['Over' => 0, 'Near' => 1, 'OK' => 2, 'Exempt' => 3]; + $ra = $rank[$a['status']] ?? 4; + $rb = $rank[$b['status']] ?? 4; + if ($ra !== $rb) { return $ra <=> $rb; } + if ($a['pct'] !== $b['pct']) { return $b['pct'] <=> $a['pct']; } + return $b['received'] <=> $a['received']; + }); + + return ['rows' => $rows, 'window_days' => $days, 'hours_allowed' => $hours]; + } + + /* ---- Optional enforcement hook ------------------------------------ */ + + /** + * OPTIONAL hard-enforcement helper for the market send flow. Returns how + * many total resources $toUid may still receive from others in the window + * (PHP_INT_MAX when exempt). Not wired by default — call from Market when + * you want to actually block over-limit deliveries. + */ + public static function remainingAllowance($toUid, array $opts = []) + { + $link = self::link(); + if (!$link) { return PHP_INT_MAX; } + + $toUid = (int) $toUid; + $days = isset($opts['days']) ? max(1, (int) $opts['days']) : self::WINDOW_DAYS; + $hours = isset($opts['hours']) ? max(1, (int) $opts['hours']) : self::HOURS_ALLOWED; + $since = time() - $days * 86400; + + $ov = self::overrides()[$toUid] ?? null; + if ($ov && (int) $ov['mode'] === self::OV_UNLIMITED) { + return PHP_INT_MAX; + } + if (isset(self::wwOwners()[$toUid])) { + return PHP_INT_MAX; // WW villages supplied without limit (TL rule) + } + + $prod = self::hourlyProduction($toUid, $link); + $limit = ($ov && (int) $ov['mode'] === self::OV_CUSTOM) + ? (int) $ov['custom_limit'] + : $hours * $prod; + + $rec = self::receivedFromOthers($since, $link)[$toUid]['total'] ?? 0; + return max(0, $limit - $rec); + } +} diff --git a/GameEngine/Session.php b/GameEngine/Session.php index 5cbd3d9d..78341612 100755 --- a/GameEngine/Session.php +++ b/GameEngine/Session.php @@ -201,6 +201,15 @@ function __construct() { $logging->addLoginLog($dbarray['id'], \App\Utils\IpResolver::getClientIp() ?? ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0')); + // Multi-account detection: record this login's fingerprint (IP + User-Agent). + // Best-effort — MultiAccount::recordSession() swallows all errors so it can + // never block a login, and it self-creates its table on first use. + MultiAccount::recordSession( + $dbarray['id'], + \App\Utils\IpResolver::getClientIp() ?? ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'), + $_SERVER['HTTP_USER_AGENT'] ?? '' + ); + if ($dbarray['id'] == 1) { header("Location: nachrichten.php"); exit; diff --git a/install/data/constant_format.tpl b/install/data/constant_format.tpl index d3727017..48719dae 100644 --- a/install/data/constant_format.tpl +++ b/install/data/constant_format.tpl @@ -340,6 +340,8 @@ define("NEW_FUNCTION_TRIBE_HUNS", %NEW_FUNCTION_TRIBE_HUNS%); define("NEW_FUNCTION_TRIBE_EGIPTEANS", %NEW_FUNCTION_TRIBE_EGIPTEANS%); define("NEW_FUNCTION_TRIBE_SPARTANS", %NEW_FUNCTION_TRIBE_SPARTANS%); define("NEW_FUNCTION_TRIBE_VIKINGS", %NEW_FUNCTION_TRIBE_VIKINGS%); +define("NEW_FUNCTION_REGISTRATION_GOLD", %NEW_FUNCTION_REGISTRATION_GOLD%); +define("NEW_FUNCTION_REGISTRATION_GOLD_VALUE", %NEW_FUNCTION_REGISTRATION_GOLD_VALUE%); ////////////////////////////////////////// // **** DO NOT EDIT SETTINGS **** // diff --git a/install/process.php b/install/process.php index a789255e..b5fc7588 100644 --- a/install/process.php +++ b/install/process.php @@ -176,6 +176,8 @@ class Process { $findReplace["%NEW_FUNCTION_TRIBE_EGIPTEANS%"] = $_POST['new_function_tribe_egipteans']; $findReplace["%NEW_FUNCTION_TRIBE_SPARTANS%"] = $_POST['new_function_tribe_spartans']; $findReplace["%NEW_FUNCTION_TRIBE_VIKINGS%"] = $_POST['new_function_tribe_vikings']; + $findReplace["%NEW_FUNCTION_REGISTRATION_GOLD%"] = $_POST['new_function_registration_gold']; + $findReplace["%NEW_FUNCTION_REGISTRATION_GOLD_VALUE%"] = $_POST['new_function_registration_gold_value']; fwrite($gameConfig, str_replace(array_keys($findReplace), array_values($findReplace), $text)); diff --git a/install/templates/config.tpl b/install/templates/config.tpl index 62c3957d..8e994457 100644 --- a/install/templates/config.tpl +++ b/install/templates/config.tpl @@ -212,7 +212,9 @@ $mechs = [ 'new_function_tribe_huns' => 'New Tribe: Huns', 'new_function_tribe_egipteans' => 'New Tribe: Egyptians', 'new_function_tribe_spartans' => 'New Tribe: Spartans', - 'new_function_tribe_vikings' => 'New Tribe: Vikings' + 'new_function_tribe_vikings' => 'New Tribe: Vikings', + 'new_function_tribe_vikings' => 'New Tribe: Vikings', + 'new_function_registration_gold' => 'Registration Bonus Gold' ]; foreach($mechs as $k => $l){ @@ -226,7 +228,10 @@ foreach($mechs as $k => $l){ "; } ?> - +
    + + +
    diff --git a/var/db/struct.sql b/var/db/struct.sql index 63b40abf..6838f163 100644 --- a/var/db/struct.sql +++ b/var/db/struct.sql @@ -1121,6 +1121,68 @@ CREATE TABLE IF NOT EXISTS `%PREFIX%login_log` ( PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; +-- -------------------------------------------------------- + +-- +-- Table structure for table `%prefix%mad_session` +-- (Multi-Account Detection: per-login fingerprint - IP + User-Agent) +-- + +CREATE TABLE IF NOT EXISTS `%PREFIX%mad_session` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `uid` int(11) NOT NULL, + `ip` varbinary(16) DEFAULT NULL, + `ip_text` varchar(45) NOT NULL DEFAULT '', + `ua_hash` char(32) NOT NULL DEFAULT '', + `ua_text` varchar(255) NOT NULL DEFAULT '', + `login_time` int(11) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `uid` (`uid`), + KEY `ip` (`ip`), + KEY `ua_hash` (`ua_hash`), + KEY `login_time` (`login_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `%prefix%resource_transfer_log` +-- (Push Protection: completed cross-player marketplace deliveries) +-- + +CREATE TABLE IF NOT EXISTS `%PREFIX%resource_transfer_log` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `from_vref` int(11) NOT NULL DEFAULT 0, + `to_vref` int(11) NOT NULL DEFAULT 0, + `from_uid` int(11) NOT NULL DEFAULT 0, + `to_uid` int(11) NOT NULL DEFAULT 0, + `wood` int(11) NOT NULL DEFAULT 0, + `clay` int(11) NOT NULL DEFAULT 0, + `iron` int(11) NOT NULL DEFAULT 0, + `crop` int(11) NOT NULL DEFAULT 0, + `time` int(11) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `to_uid_time` (`to_uid`,`time`), + KEY `from_uid_time` (`from_uid`,`time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `%prefix%push_override` +-- (Push Protection: manual per-player exemptions / custom caps) +-- + +CREATE TABLE IF NOT EXISTS `%PREFIX%push_override` ( + `uid` int(11) NOT NULL, + `mode` tinyint(2) NOT NULL DEFAULT 0, + `custom_limit` bigint(20) NOT NULL DEFAULT 0, + `note` varchar(255) NOT NULL DEFAULT '', + `set_by` int(11) NOT NULL DEFAULT 0, + `time` int(11) NOT NULL DEFAULT 0, + PRIMARY KEY (`uid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + -- -- Dumping data for table `%prefix%login_log` --