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-Mechanics Enabled" : "Disabled "; ?>
New forum post message ?Enable (Disable) forum subscription messages Enabled" : "Disabled "; ?>
Tribes images in profile ?Enable (Disable) displaying images of tribes Enabled" : "Disabled "; ?>
- New Tribe: Huns ?Enable (Disable) the Huns tribe Enabled" : "Disabled "; ?>
- New Tribe: Egyptians ?Enable (Disable) the Egyptians tribe Enabled" : "Disabled "; ?>
- New Tribe: Spartans ?Enable (Disable) the Spartans tribe Enabled" : "Disabled "; ?>
- New Tribe: Vikings ?Enable (Disable) the Vikings tribe Enabled" : "Disabled "; ?>
MHs images in profile ?Enable (Disable) displaying images of Multihunters Enabled" : "Disabled "; ?>
Display artifact in profile ?Enable (Disable) the display of the artifact Enabled" : "Disabled "; ?>
Display WoW in profile ?Enable (Disable) the display of the wonder Enabled" : "Disabled "; ?>
@@ -123,6 +119,10 @@ $editIcon = 'Server Milestones?Server Milestones Enabled" : "Disabled "; ?>
Server Medal Reset Timer?Server Medal Reset Timer Enabled" : "Disabled "; ?>
T4 Hero (items, adventures, auction)?T4 Hero (items, adventures, auction) Enabled" : "Disabled "; ?>
+ New Tribe: Huns ?Enable (Disable) the Huns tribe Enabled" : "Disabled "; ?>
+ New Tribe: Egyptians ?Enable (Disable) the Egyptians tribe Enabled" : "Disabled "; ?>
+ New Tribe: Spartans ?Enable (Disable) the Spartans tribe Enabled" : "Disabled "; ?>
+ New Tribe: Vikings ?Enable (Disable) the Vikings tribe Enabled" : "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);
>False
+
+
+ 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).
+
+
+
+ >True
+ >False
+
+
+
+
+ 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.
+
+
+
+
+
+
+ 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.
+
+
+
+
+
+
+ Risk
+ Account A
+ Account B
+ Why
+
+
+
+
+
+
+
+
+
+
+
+
+ #
+
+ 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.
+
+
+
+
+
+ Player
+ Villages / Pop
+ Prod/h
+ Received (7d)
+ Limit
+ Usage
+ Override
+
+
+
+
+
+
+
+ #
+
+
+
+
+ /
+
+
+
+ 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){
";
}
?>
-
+
+ Registration Bonus Gold – amount (used only when the toggle above is true)
+
+
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`
--