diff --git a/Admin/Templates/config.tpl b/Admin/Templates/config.tpl index 64650d82..9a8c4f51 100644 --- a/Admin/Templates/config.tpl +++ b/Admin/Templates/config.tpl @@ -118,6 +118,7 @@ $editIcon = 'Special Medals?Special MedalsEnabled" : "Disabled"; ?> 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"; ?> diff --git a/Admin/Templates/editHeroT4.tpl b/Admin/Templates/editHeroT4.tpl new file mode 100644 index 00000000..9547f6c1 --- /dev/null +++ b/Admin/Templates/editHeroT4.tpl @@ -0,0 +1,185 @@ +Missing uid.

'; return; } +$id = (int) $_GET['uid']; + +include_once("../GameEngine/Data/hero_items.php"); +include_once("../GameEngine/HeroItems.php"); +include_once("../GameEngine/HeroAuction.php"); +include_once("../GameEngine/HeroAdventure.php"); + +$t4HeroItems = new HeroItems(); +$t4Auction = new HeroAuction(); +$t4Adv = new HeroAdventure(); + +$t4Silver = $t4HeroItems->getSilver($id); +$t4Inventory = $t4HeroItems->getInventory($id); +$t4Sales = $t4Auction->getMySales($id); +$t4Bids = $t4Auction->getMyBids($id); +$t4Offers = $t4Adv->getOffers($id); +$t4Running = $t4Adv->getRunning($id); +$t4SlotNames = [1=>'Helmet',2=>'Body',3=>'Right hand',4=>'Left hand',5=>'Shoes',6=>'Horse',7=>'Bag']; +?> + + +

T4 Hero controls — user # + « classic hero editor +

+ +
Action completed.
+
Action failed (check the values and try again).
+ +
+

Silver

+
+
+ + + + + +
+
+
+ +
+

Grant item

+
+
+ + + + + Qty: + +
+
+
+ +
+

Inventory ()

+
+ + + + + + + + + + + + + +
RowItemSlotQtyEquipped
#(orphan itemid ' . (int) $t4Row['itemid'] . ')'; ?> +
+ + + + + +
+
+ No items. +
+
+ +
+

Open auctions by this player () / bids ()

+
+ + + + $t4Sales, 'role' => 'seller'], ['rows' => $t4Bids, 'role' => 'top bidder']] as $t4Group) { ?> + + + + + + + + + + + +
AuctionRoleItemPriceEnds
# (x) +
+ + + + + +
+
+ No open auction involvement. +
+
+ +
+

Adventures

+
+ +

Running: adventure #, + difficulty , + arrives (do not delete running adventures).

+ + + + + + + + + + + + + +
OfferDifficultyDurationExpires
# +
+ + + + + +
+
+ No available offers. +
+
diff --git a/Admin/Templates/editNewFunctions.tpl b/Admin/Templates/editNewFunctions.tpl index bc8113ba..0ff40069 100644 --- a/Admin/Templates/editNewFunctions.tpl +++ b/Admin/Templates/editNewFunctions.tpl @@ -232,6 +232,17 @@ if($_SESSION['access'] < 9) die(ACCESS_DENIED_ADMIN); + + + T4 Hero (items, adventures, auction) ?Enable (Disable) the "T4 Hero (items, adventures, auction)" System +
Not present in config.php yet — saving this form once will add it (defaults to False until then). + + + + diff --git a/Admin/Templates/map.tpl b/Admin/Templates/map.tpl index ebc9987e..2225e789 100644 --- a/Admin/Templates/map.tpl +++ b/Admin/Templates/map.tpl @@ -173,7 +173,7 @@ body{margin:0;background:#f1f5f9;font-family:system-ui,-apple-system,Segoe UI,Ro &hid=' title="Edit"> + + + + + diff --git a/Admin/admin.php b/Admin/admin.php index 6892f67d..005b66f1 100644 --- a/Admin/admin.php +++ b/Admin/admin.php @@ -91,7 +91,7 @@ function admin_validated_page(string $raw): string 'editExtraSet', 'editAdminInfo', 'resetServer', 'player', 'editUser', 'deletion', 'Newmessage', 'editPlus', 'editSitter', 'editPassword', 'editProtection', 'editOverall', - 'editWeek', 'userlogin', 'userillegallog', 'editHero', 'editAdditional', + 'editWeek', 'userlogin', 'userillegallog', 'editHero', 'editHeroT4', 'editAdditional', 'village', 'editResources', 'addTroops', 'addABTroops', 'editVillage', 'villagelog', 'techlog', 'msg', 'alliance', 'editAli', 'delAli','editNewFunctions', @@ -433,6 +433,16 @@ if ($page !== '') { } break; + case 'editHeroT4': + $uid = admin_input_id($_GET, 'uid'); + if ($uid !== null) { + $user = $database->getUserArray($uid, 1); + $subpage = 'T4 Hero Controls (' . e($user['username']) . ')'; + } else { + $subpage = 'T4 Hero Controls'; + } + break; + case 'editAdditional': $uid = admin_input_id($_GET, 'uid'); if ($uid !== null) { diff --git a/GameEngine/Admin/Mods/editExtraSet.php b/GameEngine/Admin/Mods/editExtraSet.php index 5ed682ee..51c57d7e 100755 --- a/GameEngine/Admin/Mods/editExtraSet.php +++ b/GameEngine/Admin/Mods/editExtraSet.php @@ -73,6 +73,7 @@ $fh = fopen($myFile, 'w') or die("


Can't open file: GameEngine\con $NEW_FUNCTIONS_SPECIAL_MEDALS_SYSTEM = (NEW_FUNCTIONS_SPECIAL_MEDALS_SYSTEM == false ? 'false' : 'true'); $NEW_FUNCTIONS_MILESTONES = (NEW_FUNCTIONS_MILESTONES == false ? 'false' : 'true'); $NEW_FUNCTIONS_MEDAL_RESET = (NEW_FUNCTIONS_MEDAL_RESET == false ? 'false' : 'true'); + $NEW_FUNCTIONS_HERO_T4 = (NEW_FUNCTIONS_HERO_T4 == false ? 'false' : 'true'); $text = admin_config_template_contents(); $text = preg_replace("'%ERRORREPORT%'", ERROR_REPORT, $text); @@ -164,6 +165,7 @@ $fh = fopen($myFile, 'w') or die("


Can't open file: GameEngine\con $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_MEDAL_RESET%'", $NEW_FUNCTIONS_MEDAL_RESET, $text); + $text = preg_replace("'%NEW_FUNCTIONS_HERO_T4%'", $NEW_FUNCTIONS_HERO_T4, $text); // PLUS settings need to be kept intact $text = preg_replace("'%PLUS_TIME%'", PLUS_TIME, $text); diff --git a/GameEngine/Admin/Mods/editHeroT4.php b/GameEngine/Admin/Mods/editHeroT4.php new file mode 100644 index 00000000..6201a56a --- /dev/null +++ b/GameEngine/Admin/Mods/editHeroT4.php @@ -0,0 +1,131 @@ += 9, same conventions as ## +## editHero.php): ## +## t4admin=setsilver uid, silver -> absolute silver balance ## +## t4admin=giveitem uid, itemid, qty -> grant catalog item ## +## t4admin=delitem uid, rowid -> delete inventory row ## +## t4admin=cancelauction aucid -> refund + return item ## +## t4admin=deladventure advid -> remove an offer ## +## ## +################################################################################# + +// Load CSRF helpers + admin_deny() before the access check (same as editHero.php #299). +require_once(__DIR__ . '/../csrf.php'); +if (!isset($_SESSION)) { + session_start(); +} +if (empty($_SESSION['access']) || $_SESSION['access'] < 9) { + admin_deny('You must be signed in as an administrator to view this page. Your session may have expired — please return to the admin panel and sign in again.'); +} + +// POSTed to directly, so it verifies the CSRF token itself (#139). +csrf_verify(); + +// --------------------------------------------------------------------------- +// Autoloader path (same discovery loop as the other Mods) +// --------------------------------------------------------------------------- +$autoprefix = ''; +for ($i = 0; $i < 5; $i++) { + $autoprefix = str_repeat('../', $i); + if (file_exists($autoprefix . 'autoloader.php')) { + break; + } +} + +include_once($autoprefix . "GameEngine/config.php"); +include_once($autoprefix . "GameEngine/Database.php"); +include_once($autoprefix . "GameEngine/Data/hero_items.php"); +include_once($autoprefix . "GameEngine/HeroItems.php"); +include_once($autoprefix . "GameEngine/HeroAuction.php"); + +$uid = (int) ($_POST['uid'] ?? 0); +$action = (string) ($_POST['t4admin'] ?? ''); +$status = '&e=1'; // error by default; flipped to &ok=1 on success + +$heroItems = new HeroItems(); + +switch ($action) { + + case 'setsilver': + // Absolute set (not delta) - simplest to reason about as an admin. + $silver = max(0, (int) ($_POST['silver'] ?? 0)); + if ($uid > 0) { + $stmt = $database->dblink->prepare( + "UPDATE " . TB_PREFIX . "hero SET silver = ? WHERE uid = ? LIMIT 1" + ); + $stmt->bind_param('ii', $silver, $uid); + $stmt->execute(); + if ($stmt->affected_rows > 0 || $heroItems->getSilver($uid) === $silver) { + $status = '&ok=1'; + } + $stmt->close(); + } + break; + + case 'giveitem': + $itemid = (int) ($_POST['itemid'] ?? 0); + $qty = max(1, (int) ($_POST['qty'] ?? 1)); + if ($uid > 0 && $heroItems->addItem($uid, $itemid, $qty) > 0) { + $status = '&ok=1'; + } + break; + + case 'delitem': + // Hard delete of an inventory row (equipped or not) - admin override. + $rowid = (int) ($_POST['rowid'] ?? 0); + if ($uid > 0 && $rowid > 0) { + $stmt = $database->dblink->prepare( + "DELETE FROM " . TB_PREFIX . "hero_items WHERE id = ? AND uid = ? LIMIT 1" + ); + $stmt->bind_param('ii', $rowid, $uid); + $stmt->execute(); + if ($stmt->affected_rows > 0) { + $status = '&ok=1'; + } + $stmt->close(); + } + break; + + case 'cancelauction': + $aucid = (int) ($_POST['aucid'] ?? 0); + $auction = new HeroAuction(); + if ($aucid > 0 && $auction->adminCancel($aucid)) { + $status = '&ok=1'; + } + break; + + case 'deladventure': + // Remove an available offer (status 0 only - never a running one, + // that would strand the hero mid-movement). + $advid = (int) ($_POST['advid'] ?? 0); + if ($advid > 0) { + $stmt = $database->dblink->prepare( + "DELETE FROM " . TB_PREFIX . "hero_adventure WHERE id = ? AND status = 0 LIMIT 1" + ); + $stmt->bind_param('i', $advid); + $stmt->execute(); + if ($stmt->affected_rows > 0) { + $status = '&ok=1'; + } + $stmt->close(); + } + break; +} + +header("Location: ../../../Admin/admin.php?p=editHeroT4&uid=$uid$status"); +exit; diff --git a/GameEngine/Admin/Mods/editLogSet.php b/GameEngine/Admin/Mods/editLogSet.php index 5230e643..faca4cb9 100755 --- a/GameEngine/Admin/Mods/editLogSet.php +++ b/GameEngine/Admin/Mods/editLogSet.php @@ -69,6 +69,7 @@ $fh = fopen($myFile, 'w') or die("


Can't open file: GameEngine\con $NEW_FUNCTIONS_SPECIAL_MEDALS_SYSTEM = (NEW_FUNCTIONS_SPECIAL_MEDALS_SYSTEM == false ? 'false' : 'true'); $NEW_FUNCTIONS_MILESTONES = (NEW_FUNCTIONS_MILESTONES == false ? 'false' : 'true'); $NEW_FUNCTIONS_MEDAL_RESET = (NEW_FUNCTIONS_MEDAL_RESET == false ? 'false' : 'true'); + $NEW_FUNCTIONS_HERO_T4 = (NEW_FUNCTIONS_HERO_T4 == false ? 'false' : 'true'); $text = admin_config_template_contents(); $text = preg_replace("'%ERRORREPORT%'", $ERRORREPORT, $text); @@ -164,6 +165,7 @@ $fh = fopen($myFile, 'w') or die("


Can't open file: GameEngine\con $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_MEDAL_RESET%'", $NEW_FUNCTIONS_MEDAL_RESET, $text); + $text = preg_replace("'%NEW_FUNCTIONS_HERO_T4%'", $NEW_FUNCTIONS_HERO_T4, $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 1ff6e27f..9056df49 100644 --- a/GameEngine/Admin/Mods/editNewFunctions.php +++ b/GameEngine/Admin/Mods/editNewFunctions.php @@ -131,6 +131,7 @@ $fh = fopen($myFile, 'w') or die("


Can't open file: GameEngine\con $text = preg_replace("'%NEW_FUNCTIONS_SPECIAL_MEDALS_SYSTEM%'", $_POST['new_functions_special_medals_system'], $text); $text = preg_replace("'%NEW_FUNCTIONS_MILESTONES%'", $_POST['new_functions_milestones'], $text); $text = preg_replace("'%NEW_FUNCTIONS_MEDAL_RESET%'", $_POST['new_functions_medal_reset'], $text); + $text = preg_replace("'%NEW_FUNCTIONS_HERO_T4%'", $_POST['new_functions_hero_t4'], $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 2660bbaf..2e5958cd 100755 --- a/GameEngine/Admin/Mods/editNewsboxSet.php +++ b/GameEngine/Admin/Mods/editNewsboxSet.php @@ -75,6 +75,7 @@ $fh = fopen($myFile, 'w') or die("


Can't open file: GameEngine\con $NEW_FUNCTIONS_SPECIAL_MEDALS_SYSTEM = (NEW_FUNCTIONS_SPECIAL_MEDALS_SYSTEM == false ? 'false' : 'true'); $NEW_FUNCTIONS_MILESTONES = (NEW_FUNCTIONS_MILESTONES == false ? 'false' : 'true'); $NEW_FUNCTIONS_MEDAL_RESET = (NEW_FUNCTIONS_MEDAL_RESET == false ? 'false' : 'true'); + $NEW_FUNCTIONS_HERO_T4 = (NEW_FUNCTIONS_HERO_T4 == false ? 'false' : 'true'); $text = admin_config_template_contents(); $text = preg_replace("'%ERRORREPORT%'", $ERRORREPORT, $text); @@ -170,6 +171,7 @@ $fh = fopen($myFile, 'w') or die("


Can't open file: GameEngine\con $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_MEDAL_RESET%'", $NEW_FUNCTIONS_MEDAL_RESET, $text); + $text = preg_replace("'%NEW_FUNCTIONS_HERO_T4%'", $NEW_FUNCTIONS_HERO_T4, $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 4450aff9..313e863b 100644 --- a/GameEngine/Admin/Mods/editPlusSet.php +++ b/GameEngine/Admin/Mods/editPlusSet.php @@ -57,6 +57,7 @@ $fh = fopen($myFile, 'w') or die("


Can't open file: GameEngine\con $NEW_FUNCTIONS_SPECIAL_MEDALS_SYSTEM = (NEW_FUNCTIONS_SPECIAL_MEDALS_SYSTEM == false ? 'false' : 'true'); $NEW_FUNCTIONS_MILESTONES = (NEW_FUNCTIONS_MILESTONES == false ? 'false' : 'true'); $NEW_FUNCTIONS_MEDAL_RESET = (NEW_FUNCTIONS_MEDAL_RESET == false ? 'false' : 'true'); + $NEW_FUNCTIONS_HERO_T4 = (NEW_FUNCTIONS_HERO_T4 == false ? 'false' : 'true'); // SERVER SETTINGS - we need to keep these intact $text = preg_replace("'%ERRORREPORT%'", ERROR_REPORT, $text); @@ -148,6 +149,7 @@ $fh = fopen($myFile, 'w') or die("


Can't open file: GameEngine\con $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_MEDAL_RESET%'", $NEW_FUNCTIONS_MEDAL_RESET, $text); + $text = preg_replace("'%NEW_FUNCTIONS_HERO_T4%'", $NEW_FUNCTIONS_HERO_T4, $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 6ab73c8c..826abc9a 100755 --- a/GameEngine/Admin/Mods/editServerSet.php +++ b/GameEngine/Admin/Mods/editServerSet.php @@ -68,6 +68,7 @@ $fh = fopen($myFile, 'w') or die("


Can't open file: GameEngine\con $NEW_FUNCTIONS_SPECIAL_MEDALS_SYSTEM = (NEW_FUNCTIONS_SPECIAL_MEDALS_SYSTEM == false ? 'false' : 'true'); $NEW_FUNCTIONS_MILESTONES = (NEW_FUNCTIONS_MILESTONES == false ? 'false' : 'true'); $NEW_FUNCTIONS_MEDAL_RESET = (NEW_FUNCTIONS_MEDAL_RESET == false ? 'false' : 'true'); + $NEW_FUNCTIONS_HERO_T4 = (NEW_FUNCTIONS_HERO_T4 == false ? 'false' : 'true'); $text = admin_config_template_contents(); $text = preg_replace("'%ERRORREPORT%'", $_POST['error'], $text); @@ -159,6 +160,7 @@ $fh = fopen($myFile, 'w') or die("


Can't open file: GameEngine\con $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_MEDAL_RESET%'", $NEW_FUNCTIONS_MEDAL_RESET, $text); + $text = preg_replace("'%NEW_FUNCTIONS_HERO_T4%'", $NEW_FUNCTIONS_HERO_T4, $text); // PLUS settings need to be kept intact $text = preg_replace("'%PLUS_TIME%'", PLUS_TIME, $text); diff --git a/GameEngine/Automation.php b/GameEngine/Automation.php index db44a779..727b2b0d 100644 --- a/GameEngine/Automation.php +++ b/GameEngine/Automation.php @@ -95,7 +95,8 @@ class Automation { "trainingComplete", "starvation", "celebrationComplete", "festivalComplete", "sendUnitsComplete", "loyaltyRegeneration", "sendreinfunitsComplete", "returnunitsComplete", "sendSettlersComplete", "spawnNatars", - "spawnWWVillages", "spawnWWBuildingPlans", "activateArtifacts"]; + "spawnWWVillages", "spawnWWBuildingPlans", "activateArtifacts", + "heroAdventureComplete"]; foreach($methodsArrays as $method){ $file = fopen($autoprefix."GameEngine/Prevention/".$method.".txt", "w"); @@ -2885,9 +2886,17 @@ class Automation { { $returningTroops = []; for($i = 1; $i <= 11; $i++) $returningTroops['t'.$i] = $data['t'.$i] - $traped[$i] - $dead[$i]; + // T4 hero port (Phase 6): bandages revive part of the + // losses when the hero comes home alive; revived units + // are written back to the attacks row (the source + // returnunitsComplete reads on arrival). No-op when off. + $returningTroops = HeroBattleBonus::applyBandages($from['owner'], $returningTroops, $dead, (int) $data['ref']); $troopsTime = $units->getWalkingTroopsTime($from['wref'], $to['wref'], $from['owner'], $owntribe, $returningTroops, 1, 't'); $endtime = $database->getArtifactsValueInfluence($from['owner'], $from['wref'], 2, $troopsTime); $endtime += $AttackArrivalTime; + // T4 hero port (Phase 5): the map shortens the return + // leg when the SURVIVING hero travels home. No-op when off. + $endtime = HeroBattleBonus::adjustReturnEndtime($from['owner'], $returningTroops['t11'], $AttackArrivalTime, $endtime); if($type == 1){ if($from['owner'] == 3){ // fix natar report by ronix $database->addNotice($to['owner'], $to['wref'], $targetally, 20, '' . addslashes($from['name']) . ' scouts ' . addslashes($to['name']) . '', $data2, $AttackArrivalTime); @@ -3130,6 +3139,15 @@ class Automation { $enforDefender = $defUnits['enforDefender']; $enforcementarray = $defUnits['enforcementarray']; + // T4 hero port (Phase 8): cages capture animals on an + // UNOCCUPIED oasis before the fight - captured animals do + // not defend and are stationed at home as reinforcements. + // No-op when the flag is off, no hero rides along, the + // oasis is owned, or the attacker has no cages. + if ($isoasis != 0 && (int) $conqureby === 0 && (int) $data['t11'] > 0) { + $Defender = HeroBattleBonus::applyCages($from['owner'], (int) $data['from'], (int) $data['to'], $Defender); + } + // attacker army built — extracted to buildAttackerUnits() [#155] $atkUnits = $this->buildAttackerUnits($dataarray[$data_num], $owntribe, $isoasis); $Attacker = $atkUnits['Attacker']; @@ -3363,7 +3381,10 @@ class Automation { $avcrop = $resAfter['avcrop']; // bounty distributed across the resources available after cranny protection (extracted to applyBounty() [#155]) - $steal = $this->applyBounty([$avwood, $avclay, $aviron, $avcrop], $battlepart['bounty']); + // T4 hero port (Phase 5): thief sacks raise the carry + // bounty when the attacker's living hero joined the strike. + $heroBounty = (int) round($battlepart['bounty'] * HeroBattleBonus::raidMultiplier($from['owner'], $data['t11'])); + $steal = $this->applyBounty([$avwood, $avclay, $aviron, $avcrop], $heroBounty); //chiefing village — extracted to handleConquest() [#155] if (!isset($village_destroyed)) $village_destroyed = 0; @@ -3431,6 +3452,9 @@ class Automation { $troopsTime = $units->getWalkingTroopsTime($from['wref'], $to['wref'], $from['owner'], $owntribe, $data, 1, 't'); $endtime = $database->getArtifactsValueInfluence($from['owner'], $from['wref'], 2, $troopsTime); $endtime += $AttackArrivalTime; + // T4 hero port (Phase 5): map return-speed bonus, same as + // the combat return leg above. No-op when the flag is off. + $endtime = HeroBattleBonus::adjustReturnEndtime($from['owner'], $data['t11'], $AttackArrivalTime, $endtime); $database->setMovementProc($data['moveid']); $database->addMovement(4, $to['wref'], $from['wref'], $data['ref'], $AttackArrivalTime, $endtime); @@ -4338,6 +4362,28 @@ class Automation { } + /** + * T4 Hero port (Phase 3+4): process adventure arrivals (sort_type 20, + * rewards + ntype 26 report + return movement), returns (sort_type 21, + * hero re-enters units.hero, loot credited), top up offer lists, then + * finalize ended auctions and restock the NPC merchant. + * Fully feature-flagged - a no-op unless NEW_FUNCTIONS_HERO_T4 is enabled. + */ + private function heroAdventureComplete() { + if (!defined('NEW_FUNCTIONS_HERO_T4') || !NEW_FUNCTIONS_HERO_T4) { + return; + } + + $adventures = new HeroAdventure(); + $adventures->processArrivals(); + $adventures->processReturns(); + $adventures->generateOffersBatch(); + + $auctions = new HeroAuction(); + $auctions->processFinished(); + $auctions->seedNpcAuctions(); + } + private function updateHero() { global $database, $hero_levels; diff --git a/GameEngine/Battle.php b/GameEngine/Battle.php index fa994310..afc07ba2 100644 --- a/GameEngine/Battle.php +++ b/GameEngine/Battle.php @@ -190,6 +190,16 @@ class Battle { $h_ob = 1 + (0.010 * ($attackBonus / 5)); $h_db = 1 + (0.010 * ($defenceBonus / 5)); + // T4 hero port (Phase 5): flat fighting strength from equipped items + // (weapons/cuirasses/shields) counts on whichever side the hero fights. + // No-ops to 0 when NEW_FUNCTIONS_HERO_T4 is off. + $itemStrength = HeroBattleBonus::statBonus($uid); + if ($itemStrength > 0) { + $h_atk += $itemStrength; + $h_di += $itemStrength; + $h_dc += $itemStrength; + } + return [ 'heroid' => (int)$hero['heroid'], 'unit' => $heroUnit, @@ -198,6 +208,8 @@ class Battle { 'dc' => $h_dc, 'ob' => $h_ob, 'db' => $h_db, + // T4 hero port: carried along for the offense/defense/damage hooks. + 'uid' => (int)$uid, 'health' => isset($hero['health']) ? (int)$hero['health'] : 0 @@ -550,6 +562,14 @@ class Battle { $units['Def_unit']['hero'] = $Defender['hero']; $own_cdp += $defenderhero['dc']; $own_dp += $defenderhero['di']; + + // T4 hero port (Phase 5): weapon adds +N defense per unit of + // its type in the defending army, counted in both pools + // (see HeroBattleBonus::unitDefense). No-op when flag is off. + $itemDef = HeroBattleBonus::unitDefense($defenderhero['uid'] ?? 0, $Defender); + $own_dp += $itemDef; + $own_cdp += $itemDef; + $own_dp *= $defenderhero['db']; $own_cdp *= $defenderhero['db']; } @@ -762,7 +782,14 @@ class Battle { $ap *= $atkhero['ob']; $cap *= $atkhero['ob']; - $ap += $atkhero['atk']; + // T4 hero port (Phase 5): hunting horn boosts the HERO's own + // contribution vs the Natars (uid 3); weapon adds +N attack per + // accompanying unit of its type. Neutral no-ops when the flag is off. + $ap += $atkhero['atk'] * HeroBattleBonus::natarMultiplier($atkhero['uid'] ?? 0, $DefenderID); + + list($itemAp, $itemCap) = HeroBattleBonus::unitOffense($atkhero['uid'] ?? 0, $Attacker, $calvaryLookup); + $ap += $itemAp; + $cap += $itemCap; } if ($offhero > 0 || $hero_strenght > 0) { @@ -1136,6 +1163,10 @@ class Battle { $hero_health = (int)$fdb['health']; $damage_health = round(100 * $result[1]); + // T4 hero port (Phase 5): armors reduce the health damage the + // hero takes in one battle (flat, floored at 0). No-op when off. + $damage_health = HeroBattleBonus::reduceDamage($atkhero['uid'] ?? 0, $damage_health); + if ($hero_health <= $damage_health || $damage_health > 90) { $result['casualties_attacker'][11] = 1; diff --git a/GameEngine/Data/hero_items.php b/GameEngine/Data/hero_items.php new file mode 100644 index 00000000..9d14d00e --- /dev/null +++ b/GameEngine/Data/hero_items.php @@ -0,0 +1,307 @@ + lang constant ## +## first and falls back to the English catalog name. ## +## ## +################################################################################# + +/* ============================================================================ + * GAME CONSTANTS (unchanged from Phase 1; guarded against re-includes) + * movement.sort_type in use: 0,2,3,4,5,6,10,11,12,13 -> 20/21 free + * attacks.attack_type in use: 1,2 -> 9 free + * ndata.ntype mapped up to 25 (settlers) -> 26 free + * ------------------------------------------------------------------------- */ +if (!defined('MOVEMENT_ADVENTURE_OUT')) define('MOVEMENT_ADVENTURE_OUT', 20); +if (!defined('MOVEMENT_ADVENTURE_BACK')) define('MOVEMENT_ADVENTURE_BACK', 21); +if (!defined('ATTACK_TYPE_ADVENTURE')) define('ATTACK_TYPE_ADVENTURE', 9); +if (!defined('NTYPE_ADVENTURE_REPORT')) define('NTYPE_ADVENTURE_REPORT', 26); +if (!defined('NTYPE_AUCTION_REPORT')) define('NTYPE_AUCTION_REPORT', 27); + +/* Equipment slots */ +if (!defined('HSLOT_HELMET')) define('HSLOT_HELMET', 1); +if (!defined('HSLOT_BODY')) define('HSLOT_BODY', 2); +if (!defined('HSLOT_RIGHT')) define('HSLOT_RIGHT', 3); // weapon +if (!defined('HSLOT_LEFT')) define('HSLOT_LEFT', 4); // shield/horn/sack/map/pennant/standard +if (!defined('HSLOT_SHOES')) define('HSLOT_SHOES', 5); // boots + spurs +if (!defined('HSLOT_HORSE')) define('HSLOT_HORSE', 6); +if (!defined('HSLOT_BAG')) define('HSLOT_BAG', 7); // stackable consumables + +/* ============================================================================ + * BONUS TYPES (keys inside $item['bonus']) + * Flat vs percent is documented per constant. Consumers: HeroItems::getBonuses() + * (Phase 2), Battle/Automation/a2b integration (Phase 5). + * ------------------------------------------------------------------------- */ +if (!defined('HB_FIGHT')) define('HB_FIGHT', 'fight_strength'); // flat hero fighting strength +if (!defined('HB_UNIT_BONUS')) define('HB_UNIT_BONUS', 'unit_bonus'); // ['unit'=>uX,'per_unit'=>N] +N off & +N def per unit of that type in the hero's army +if (!defined('HB_VS_NATARS')) define('HB_VS_NATARS', 'vs_natars'); // +% hero strength vs Natars +if (!defined('HB_RAID')) define('HB_RAID', 'raid_percent'); // +% resources stolen on raids +if (!defined('HB_RETURN_SPEED')) define('HB_RETURN_SPEED', 'return_speed'); // +% troop return speed +if (!defined('HB_SPEED_OWN')) define('HB_SPEED_OWN', 'speed_own'); // +% speed between own villages +if (!defined('HB_SPEED_ALLY')) define('HB_SPEED_ALLY', 'speed_ally'); // +% speed between ally villages +if (!defined('HB_XP')) define('HB_XP', 'xp_percent'); // +% hero experience +if (!defined('HB_REGEN_HP')) define('HB_REGEN_HP', 'regen_hp'); // flat +HP per day +if (!defined('HB_CP')) define('HB_CP', 'culture_points'); // flat +culture points per day +if (!defined('HB_TRAIN_CAV')) define('HB_TRAIN_CAV', 'train_cavalry'); // -% cavalry training time +if (!defined('HB_TRAIN_INF')) define('HB_TRAIN_INF', 'train_infantry'); // -% infantry training time +if (!defined('HB_DMG_REDUCE')) define('HB_DMG_REDUCE', 'damage_reduce'); // flat damage reduction per hit +if (!defined('HB_TROOP_SPEED_20')) define('HB_TROOP_SPEED_20', 'troop_speed_20'); // +% troop speed beyond 20 tiles +if (!defined('HB_MOUNT_SPEED')) define('HB_MOUNT_SPEED', 'mount_speed'); // flat +fields/hour, ONLY while a horse is equipped (spurs) +if (!defined('HB_HORSE_SPEED')) define('HB_HORSE_SPEED', 'horse_speed'); // flat hero speed in fields/hour (horse) +/* consumables */ +if (!defined('HB_HEAL_SELF')) define('HB_HEAL_SELF', 'heal_self'); // +1% hero HP per unit (max 99%, hero must be alive) +if (!defined('HB_SCROLL')) define('HB_SCROLL', 'scroll_xp'); // +XP per scroll +if (!defined('HB_BUCKET')) define('HB_BUCKET', 'bucket'); // instant free revive +if (!defined('HB_LOYALTY')) define('HB_LOYALTY', 'loyalty'); // +1% own village loyalty per tablet (max 125%) +if (!defined('HB_RESET')) define('HB_RESET', 'reset_points'); // reset attribute points +if (!defined('HB_ARTWORK')) define('HB_ARTWORK', 'artwork_cp'); // CP = daily production (cap: 5000 normal / 2500 speed) +if (!defined('HB_HEAL_TROOP')) define('HB_HEAL_TROOP', 'heal_troops'); // heals % of the hero's army (bandages) +if (!defined('HB_CAGE')) define('HB_CAGE', 'cage'); // traps 1 oasis animal per cage + +/* ============================================================================ + * ITEM CATALOG + * itemid => [ + * 'name' => English display name, + * 'slot' => HSLOT_*, + * 'tier' => 1..3, + * 'bonus' => [HB_* => value, ...], + * 'unit' => uX (weapons only: hero must BE this unit to equip), + * 'x2_on_speed' => true (value doubles on speed servers, per design sheet: + * "+3 (1x) / +6 (3x)" - runtime multiplies by 2 when SPEED >= 3), + * 'requires_horse' => true (spurs: bonus only counts with a horse equipped) + * ] + * + * ID ranges: 1-15 helmets | 20-31 body | 40-57 left hand | 60-68 shoes + * 70-72 horses | 101-145 weapons | 200-208 consumables + * ------------------------------------------------------------------------- */ +$heroItemCatalog = array( + + /* ---------------- HELMETS (sheet: Coifuri) ---------------- */ + 1 => array('name' => 'Helmet of Awareness', 'slot' => HSLOT_HELMET, 'tier' => 1, 'bonus' => array(HB_XP => 15)), + 2 => array('name' => 'Helmet of Enlightenment', 'slot' => HSLOT_HELMET, 'tier' => 2, 'bonus' => array(HB_XP => 20)), + 3 => array('name' => 'Helmet of Wisdom', 'slot' => HSLOT_HELMET, 'tier' => 3, 'bonus' => array(HB_XP => 25)), + 4 => array('name' => 'Helmet of Regeneration', 'slot' => HSLOT_HELMET, 'tier' => 1, 'bonus' => array(HB_REGEN_HP => 10)), + 5 => array('name' => 'Helmet of Health', 'slot' => HSLOT_HELMET, 'tier' => 2, 'bonus' => array(HB_REGEN_HP => 15)), + 6 => array('name' => 'Helmet of Healing', 'slot' => HSLOT_HELMET, 'tier' => 3, 'bonus' => array(HB_REGEN_HP => 20)), + 7 => array('name' => 'Helmet of the Gladiator', 'slot' => HSLOT_HELMET, 'tier' => 1, 'bonus' => array(HB_CP => 100), 'x2_on_speed' => false), // sheet: 100/day (1x), 50 (3x) -> HALVED on speed; handled below + 8 => array('name' => 'Helmet of the Tribune', 'slot' => HSLOT_HELMET, 'tier' => 2, 'bonus' => array(HB_CP => 400), 'x2_on_speed' => false), + 9 => array('name' => 'Helmet of the Consul', 'slot' => HSLOT_HELMET, 'tier' => 3, 'bonus' => array(HB_CP => 800), 'x2_on_speed' => false), + 10 => array('name' => 'Helmet of the Horseman', 'slot' => HSLOT_HELMET, 'tier' => 1, 'bonus' => array(HB_TRAIN_CAV => 10)), + 11 => array('name' => 'Helmet of the Cavalry', 'slot' => HSLOT_HELMET, 'tier' => 2, 'bonus' => array(HB_TRAIN_CAV => 15)), + 12 => array('name' => 'Helmet of the Heavy Cavalry', 'slot' => HSLOT_HELMET, 'tier' => 3, 'bonus' => array(HB_TRAIN_CAV => 20)), + 13 => array('name' => 'Helmet of the Mercenary', 'slot' => HSLOT_HELMET, 'tier' => 1, 'bonus' => array(HB_TRAIN_INF => 10)), + 14 => array('name' => 'Helmet of the Warrior', 'slot' => HSLOT_HELMET, 'tier' => 2, 'bonus' => array(HB_TRAIN_INF => 15)), + 15 => array('name' => 'Helmet of the Ruler', 'slot' => HSLOT_HELMET, 'tier' => 3, 'bonus' => array(HB_TRAIN_INF => 20)), + + /* ---------------- BODY ARMOUR (sheet: Armuri) ---------------- */ + 20 => array('name' => 'Light Breastplate of Regeneration', 'slot' => HSLOT_BODY, 'tier' => 1, 'bonus' => array(HB_REGEN_HP => 20)), + 21 => array('name' => 'Breastplate of Regeneration', 'slot' => HSLOT_BODY, 'tier' => 2, 'bonus' => array(HB_REGEN_HP => 30)), + 22 => array('name' => 'Heavy Breastplate of Regeneration', 'slot' => HSLOT_BODY, 'tier' => 3, 'bonus' => array(HB_REGEN_HP => 40)), + 23 => array('name' => 'Light Armor', 'slot' => HSLOT_BODY, 'tier' => 1, 'bonus' => array(HB_DMG_REDUCE => 4, HB_REGEN_HP => 10)), + 24 => array('name' => 'Armor', 'slot' => HSLOT_BODY, 'tier' => 2, 'bonus' => array(HB_DMG_REDUCE => 6, HB_REGEN_HP => 15)), + 25 => array('name' => 'Heavy Armor', 'slot' => HSLOT_BODY, 'tier' => 3, 'bonus' => array(HB_DMG_REDUCE => 8, HB_REGEN_HP => 20)), + 26 => array('name' => 'Light Cuirass', 'slot' => HSLOT_BODY, 'tier' => 1, 'bonus' => array(HB_FIGHT => 500)), + 27 => array('name' => 'Cuirass', 'slot' => HSLOT_BODY, 'tier' => 2, 'bonus' => array(HB_FIGHT => 1000)), + 28 => array('name' => 'Heavy Cuirass', 'slot' => HSLOT_BODY, 'tier' => 3, 'bonus' => array(HB_FIGHT => 1500)), + 29 => array('name' => 'Light Segmented Armor', 'slot' => HSLOT_BODY, 'tier' => 1, 'bonus' => array(HB_DMG_REDUCE => 3, HB_FIGHT => 250)), + 30 => array('name' => 'Segmented Armor', 'slot' => HSLOT_BODY, 'tier' => 2, 'bonus' => array(HB_DMG_REDUCE => 4, HB_FIGHT => 500)), + 31 => array('name' => 'Heavy Segmented Armor', 'slot' => HSLOT_BODY, 'tier' => 3, 'bonus' => array(HB_DMG_REDUCE => 5, HB_FIGHT => 750)), + + /* ---------------- LEFT HAND (sheets: Mana_Stanga + Diverse) ---------------- */ + 40 => array('name' => 'Small Shield', 'slot' => HSLOT_LEFT, 'tier' => 1, 'bonus' => array(HB_FIGHT => 500)), + 41 => array('name' => 'Shield', 'slot' => HSLOT_LEFT, 'tier' => 2, 'bonus' => array(HB_FIGHT => 1000)), + 42 => array('name' => 'Great Shield', 'slot' => HSLOT_LEFT, 'tier' => 3, 'bonus' => array(HB_FIGHT => 1500)), + 43 => array('name' => 'Small Hunting Horn', 'slot' => HSLOT_LEFT, 'tier' => 1, 'bonus' => array(HB_VS_NATARS => 20)), + 44 => array('name' => 'Hunting Horn', 'slot' => HSLOT_LEFT, 'tier' => 2, 'bonus' => array(HB_VS_NATARS => 25)), + 45 => array('name' => 'Great Hunting Horn', 'slot' => HSLOT_LEFT, 'tier' => 3, 'bonus' => array(HB_VS_NATARS => 30)), + 46 => array('name' => "Thief's Satchel", 'slot' => HSLOT_LEFT, 'tier' => 1, 'bonus' => array(HB_RAID => 10)), + 47 => array('name' => "Thief's Bag", 'slot' => HSLOT_LEFT, 'tier' => 2, 'bonus' => array(HB_RAID => 15)), + 48 => array('name' => "Thief's Sack", 'slot' => HSLOT_LEFT, 'tier' => 3, 'bonus' => array(HB_RAID => 20)), + 49 => array('name' => 'Small Map', 'slot' => HSLOT_LEFT, 'tier' => 1, 'bonus' => array(HB_RETURN_SPEED => 30)), + 50 => array('name' => 'Map', 'slot' => HSLOT_LEFT, 'tier' => 2, 'bonus' => array(HB_RETURN_SPEED => 40)), + 51 => array('name' => 'Great Map', 'slot' => HSLOT_LEFT, 'tier' => 3, 'bonus' => array(HB_RETURN_SPEED => 50)), + 52 => array('name' => 'Small Pennant', 'slot' => HSLOT_LEFT, 'tier' => 1, 'bonus' => array(HB_SPEED_OWN => 30)), + 53 => array('name' => 'Pennant', 'slot' => HSLOT_LEFT, 'tier' => 2, 'bonus' => array(HB_SPEED_OWN => 40)), + 54 => array('name' => 'Great Pennant', 'slot' => HSLOT_LEFT, 'tier' => 3, 'bonus' => array(HB_SPEED_OWN => 50)), + 55 => array('name' => 'Small Standard', 'slot' => HSLOT_LEFT, 'tier' => 1, 'bonus' => array(HB_SPEED_ALLY => 15)), + 56 => array('name' => 'Standard', 'slot' => HSLOT_LEFT, 'tier' => 2, 'bonus' => array(HB_SPEED_ALLY => 20)), + 57 => array('name' => 'Great Standard', 'slot' => HSLOT_LEFT, 'tier' => 3, 'bonus' => array(HB_SPEED_ALLY => 25)), + + /* ---------------- SHOES (sheet: Incaltaminte_Cai - boots + spurs) ---------------- */ + 60 => array('name' => 'Boots of Regeneration', 'slot' => HSLOT_SHOES, 'tier' => 1, 'bonus' => array(HB_REGEN_HP => 10)), + 61 => array('name' => 'Boots of Recovery', 'slot' => HSLOT_SHOES, 'tier' => 2, 'bonus' => array(HB_REGEN_HP => 15)), + 62 => array('name' => 'Boots of Healing', 'slot' => HSLOT_SHOES, 'tier' => 3, 'bonus' => array(HB_REGEN_HP => 20)), + 63 => array('name' => 'Boots of the Mercenary', 'slot' => HSLOT_SHOES, 'tier' => 1, 'bonus' => array(HB_TROOP_SPEED_20 => 25)), + 64 => array('name' => 'Boots of the Warrior', 'slot' => HSLOT_SHOES, 'tier' => 2, 'bonus' => array(HB_TROOP_SPEED_20 => 50)), + 65 => array('name' => 'Boots of the Ruler', 'slot' => HSLOT_SHOES, 'tier' => 3, 'bonus' => array(HB_TROOP_SPEED_20 => 75)), + // Spurs share the shoes slot (worn on boots) and only apply while a horse is equipped. + // Design sheet: "+3 f/h (1x) / +6 (3x)" -> base value is the 1x one, doubled on speed servers. + 66 => array('name' => 'Small Spurs', 'slot' => HSLOT_SHOES, 'tier' => 1, 'bonus' => array(HB_MOUNT_SPEED => 3), 'x2_on_speed' => true, 'requires_horse' => true), + 67 => array('name' => 'Spurs', 'slot' => HSLOT_SHOES, 'tier' => 2, 'bonus' => array(HB_MOUNT_SPEED => 4), 'x2_on_speed' => true, 'requires_horse' => true), + 68 => array('name' => 'Great Spurs', 'slot' => HSLOT_SHOES, 'tier' => 3, 'bonus' => array(HB_MOUNT_SPEED => 5), 'x2_on_speed' => true, 'requires_horse' => true), + + /* ---------------- HORSES (sheet: Incaltaminte_Cai) ---------------- */ + 70 => array('name' => 'Riding Horse', 'slot' => HSLOT_HORSE, 'tier' => 1, 'bonus' => array(HB_HORSE_SPEED => 14), 'x2_on_speed' => true), + 71 => array('name' => 'Thoroughbred', 'slot' => HSLOT_HORSE, 'tier' => 2, 'bonus' => array(HB_HORSE_SPEED => 17), 'x2_on_speed' => true), + 72 => array('name' => 'Warhorse', 'slot' => HSLOT_HORSE, 'tier' => 3, 'bonus' => array(HB_HORSE_SPEED => 20), 'x2_on_speed' => true), + + /* ---------------- WEAPONS (sheets: Romani / Barbari / Daci) ---------------- + * Weapons are unit-bound: the hero must be trained from that unit. + * Each gives flat fight strength (500/1000/1500 by tier) plus + * +N off AND +N def per unit of that type accompanying the hero. */ + + // Romans - u1 Legionnaire + 101 => array('name' => "Legionnaire's Short Sword", 'slot' => HSLOT_RIGHT, 'tier' => 1, 'unit' => 1, 'bonus' => array(HB_FIGHT => 500, HB_UNIT_BONUS => array('unit' => 1, 'per_unit' => 3))), + 102 => array('name' => "Legionnaire's Sword", 'slot' => HSLOT_RIGHT, 'tier' => 2, 'unit' => 1, 'bonus' => array(HB_FIGHT => 1000, HB_UNIT_BONUS => array('unit' => 1, 'per_unit' => 4))), + 103 => array('name' => "Legionnaire's Long Sword", 'slot' => HSLOT_RIGHT, 'tier' => 3, 'unit' => 1, 'bonus' => array(HB_FIGHT => 1500, HB_UNIT_BONUS => array('unit' => 1, 'per_unit' => 5))), + // Romans - u2 Praetorian + 104 => array('name' => "Praetorian's Short Sword", 'slot' => HSLOT_RIGHT, 'tier' => 1, 'unit' => 2, 'bonus' => array(HB_FIGHT => 500, HB_UNIT_BONUS => array('unit' => 2, 'per_unit' => 3))), + 105 => array('name' => "Praetorian's Sword", 'slot' => HSLOT_RIGHT, 'tier' => 2, 'unit' => 2, 'bonus' => array(HB_FIGHT => 1000, HB_UNIT_BONUS => array('unit' => 2, 'per_unit' => 4))), + 106 => array('name' => "Praetorian's Long Sword", 'slot' => HSLOT_RIGHT, 'tier' => 3, 'unit' => 2, 'bonus' => array(HB_FIGHT => 1500, HB_UNIT_BONUS => array('unit' => 2, 'per_unit' => 5))), + // Romans - u3 Imperian + 107 => array('name' => "Imperian's Short Sword", 'slot' => HSLOT_RIGHT, 'tier' => 1, 'unit' => 3, 'bonus' => array(HB_FIGHT => 500, HB_UNIT_BONUS => array('unit' => 3, 'per_unit' => 3))), + 108 => array('name' => "Imperian's Sword", 'slot' => HSLOT_RIGHT, 'tier' => 2, 'unit' => 3, 'bonus' => array(HB_FIGHT => 1000, HB_UNIT_BONUS => array('unit' => 3, 'per_unit' => 4))), + 109 => array('name' => "Imperian's Long Sword", 'slot' => HSLOT_RIGHT, 'tier' => 3, 'unit' => 3, 'bonus' => array(HB_FIGHT => 1500, HB_UNIT_BONUS => array('unit' => 3, 'per_unit' => 5))), + // Romans - u5 Equites Imperatoris + 110 => array('name' => "Imperatoris' Short Saber", 'slot' => HSLOT_RIGHT, 'tier' => 1, 'unit' => 5, 'bonus' => array(HB_FIGHT => 500, HB_UNIT_BONUS => array('unit' => 5, 'per_unit' => 9))), + 111 => array('name' => "Imperatoris' Saber", 'slot' => HSLOT_RIGHT, 'tier' => 2, 'unit' => 5, 'bonus' => array(HB_FIGHT => 1000, HB_UNIT_BONUS => array('unit' => 5, 'per_unit' => 12))), + 112 => array('name' => "Imperatoris' Long Saber", 'slot' => HSLOT_RIGHT, 'tier' => 3, 'unit' => 5, 'bonus' => array(HB_FIGHT => 1500, HB_UNIT_BONUS => array('unit' => 5, 'per_unit' => 15))), + // Romans - u6 Equites Caesaris + 113 => array('name' => "Caesaris' Light Lance", 'slot' => HSLOT_RIGHT, 'tier' => 1, 'unit' => 6, 'bonus' => array(HB_FIGHT => 500, HB_UNIT_BONUS => array('unit' => 6, 'per_unit' => 12))), + 114 => array('name' => "Caesaris' Lance", 'slot' => HSLOT_RIGHT, 'tier' => 2, 'unit' => 6, 'bonus' => array(HB_FIGHT => 1000, HB_UNIT_BONUS => array('unit' => 6, 'per_unit' => 16))), + 115 => array('name' => "Caesaris' Heavy Lance", 'slot' => HSLOT_RIGHT, 'tier' => 3, 'unit' => 6, 'bonus' => array(HB_FIGHT => 1500, HB_UNIT_BONUS => array('unit' => 6, 'per_unit' => 20))), + + // Teutons - u11 Clubswinger + 116 => array('name' => "Clubswinger's Cudgel", 'slot' => HSLOT_RIGHT, 'tier' => 1, 'unit' => 11, 'bonus' => array(HB_FIGHT => 500, HB_UNIT_BONUS => array('unit' => 11, 'per_unit' => 3))), + 117 => array('name' => "Clubswinger's Club", 'slot' => HSLOT_RIGHT, 'tier' => 2, 'unit' => 11, 'bonus' => array(HB_FIGHT => 1000, HB_UNIT_BONUS => array('unit' => 11, 'per_unit' => 4))), + 118 => array('name' => "Clubswinger's Mace", 'slot' => HSLOT_RIGHT, 'tier' => 3, 'unit' => 11, 'bonus' => array(HB_FIGHT => 1500, HB_UNIT_BONUS => array('unit' => 11, 'per_unit' => 5))), + // Teutons - u12 Spearman + 119 => array('name' => "Spearman's Pitchfork", 'slot' => HSLOT_RIGHT, 'tier' => 1, 'unit' => 12, 'bonus' => array(HB_FIGHT => 500, HB_UNIT_BONUS => array('unit' => 12, 'per_unit' => 3))), + 120 => array('name' => "Spearman's Pike", 'slot' => HSLOT_RIGHT, 'tier' => 2, 'unit' => 12, 'bonus' => array(HB_FIGHT => 1000, HB_UNIT_BONUS => array('unit' => 12, 'per_unit' => 4))), + 121 => array('name' => "Spearman's Spear", 'slot' => HSLOT_RIGHT, 'tier' => 3, 'unit' => 12, 'bonus' => array(HB_FIGHT => 1500, HB_UNIT_BONUS => array('unit' => 12, 'per_unit' => 5))), + // Teutons - u13 Axeman + 122 => array('name' => "Axeman's Hatchet", 'slot' => HSLOT_RIGHT, 'tier' => 1, 'unit' => 13, 'bonus' => array(HB_FIGHT => 500, HB_UNIT_BONUS => array('unit' => 13, 'per_unit' => 3))), + 123 => array('name' => "Axeman's Axe", 'slot' => HSLOT_RIGHT, 'tier' => 2, 'unit' => 13, 'bonus' => array(HB_FIGHT => 1000, HB_UNIT_BONUS => array('unit' => 13, 'per_unit' => 4))), + 124 => array('name' => "Axeman's Battle Axe", 'slot' => HSLOT_RIGHT, 'tier' => 3, 'unit' => 13, 'bonus' => array(HB_FIGHT => 1500, HB_UNIT_BONUS => array('unit' => 13, 'per_unit' => 5))), + // Teutons - u15 Paladin + 125 => array('name' => "Paladin's Small Hammer", 'slot' => HSLOT_RIGHT, 'tier' => 1, 'unit' => 15, 'bonus' => array(HB_FIGHT => 500, HB_UNIT_BONUS => array('unit' => 15, 'per_unit' => 6))), + 126 => array('name' => "Paladin's Hammer", 'slot' => HSLOT_RIGHT, 'tier' => 2, 'unit' => 15, 'bonus' => array(HB_FIGHT => 1000, HB_UNIT_BONUS => array('unit' => 15, 'per_unit' => 8))), + 127 => array('name' => "Paladin's Sledgehammer", 'slot' => HSLOT_RIGHT, 'tier' => 3, 'unit' => 15, 'bonus' => array(HB_FIGHT => 1500, HB_UNIT_BONUS => array('unit' => 15, 'per_unit' => 10))), + // Teutons - u16 Teutonic Knight + 128 => array('name' => "Teutonic Knight's Short Sword", 'slot' => HSLOT_RIGHT, 'tier' => 1, 'unit' => 16, 'bonus' => array(HB_FIGHT => 500, HB_UNIT_BONUS => array('unit' => 16, 'per_unit' => 9))), + 129 => array('name' => "Teutonic Knight's Sword", 'slot' => HSLOT_RIGHT, 'tier' => 2, 'unit' => 16, 'bonus' => array(HB_FIGHT => 1000, HB_UNIT_BONUS => array('unit' => 16, 'per_unit' => 12))), + 130 => array('name' => "Teutonic Knight's Long Sword", 'slot' => HSLOT_RIGHT, 'tier' => 3, 'unit' => 16, 'bonus' => array(HB_FIGHT => 1500, HB_UNIT_BONUS => array('unit' => 16, 'per_unit' => 15))), + + // Gauls - u21 Phalanx (design sheet tribe "Daci": Scutier) + 131 => array('name' => "Phalanx's Pitchfork", 'slot' => HSLOT_RIGHT, 'tier' => 1, 'unit' => 21, 'bonus' => array(HB_FIGHT => 500, HB_UNIT_BONUS => array('unit' => 21, 'per_unit' => 3))), + 132 => array('name' => "Phalanx's Spear", 'slot' => HSLOT_RIGHT, 'tier' => 2, 'unit' => 21, 'bonus' => array(HB_FIGHT => 1000, HB_UNIT_BONUS => array('unit' => 21, 'per_unit' => 4))), + 133 => array('name' => "Phalanx's Lance", 'slot' => HSLOT_RIGHT, 'tier' => 3, 'unit' => 21, 'bonus' => array(HB_FIGHT => 1500, HB_UNIT_BONUS => array('unit' => 21, 'per_unit' => 5))), + // Gauls - u22 Swordsman (Pedestras) + 134 => array('name' => "Swordsman's Short Sword", 'slot' => HSLOT_RIGHT, 'tier' => 1, 'unit' => 22, 'bonus' => array(HB_FIGHT => 500, HB_UNIT_BONUS => array('unit' => 22, 'per_unit' => 3))), + 135 => array('name' => "Swordsman's Sword", 'slot' => HSLOT_RIGHT, 'tier' => 2, 'unit' => 22, 'bonus' => array(HB_FIGHT => 1000, HB_UNIT_BONUS => array('unit' => 22, 'per_unit' => 4))), + 136 => array('name' => "Swordsman's Long Sword", 'slot' => HSLOT_RIGHT, 'tier' => 3, 'unit' => 22, 'bonus' => array(HB_FIGHT => 1500, HB_UNIT_BONUS => array('unit' => 22, 'per_unit' => 5))), + // Gauls - u24 Theutates Thunder (Calaret Fulger) + 137 => array('name' => "Thunder's Short Bow", 'slot' => HSLOT_RIGHT, 'tier' => 1, 'unit' => 24, 'bonus' => array(HB_FIGHT => 500, HB_UNIT_BONUS => array('unit' => 24, 'per_unit' => 6))), + 138 => array('name' => "Thunder's Bow", 'slot' => HSLOT_RIGHT, 'tier' => 2, 'unit' => 24, 'bonus' => array(HB_FIGHT => 1000, HB_UNIT_BONUS => array('unit' => 24, 'per_unit' => 8))), + 139 => array('name' => "Thunder's Long Bow", 'slot' => HSLOT_RIGHT, 'tier' => 3, 'unit' => 24, 'bonus' => array(HB_FIGHT => 1500, HB_UNIT_BONUS => array('unit' => 24, 'per_unit' => 10))), + // Gauls - u25 Druidrider + 140 => array('name' => "Druidrider's Baton", 'slot' => HSLOT_RIGHT, 'tier' => 1, 'unit' => 25, 'bonus' => array(HB_FIGHT => 500, HB_UNIT_BONUS => array('unit' => 25, 'per_unit' => 6))), + 141 => array('name' => "Druidrider's Staff", 'slot' => HSLOT_RIGHT, 'tier' => 2, 'unit' => 25, 'bonus' => array(HB_FIGHT => 1000, HB_UNIT_BONUS => array('unit' => 25, 'per_unit' => 8))), + 142 => array('name' => "Druidrider's Great Staff", 'slot' => HSLOT_RIGHT, 'tier' => 3, 'unit' => 25, 'bonus' => array(HB_FIGHT => 1500, HB_UNIT_BONUS => array('unit' => 25, 'per_unit' => 10))), + // Gauls - u26 Haeduan (Tarabostes) + 143 => array('name' => "Haeduan's Light Lance", 'slot' => HSLOT_RIGHT, 'tier' => 1, 'unit' => 26, 'bonus' => array(HB_FIGHT => 500, HB_UNIT_BONUS => array('unit' => 26, 'per_unit' => 9))), + 144 => array('name' => "Haeduan's Lance", 'slot' => HSLOT_RIGHT, 'tier' => 2, 'unit' => 26, 'bonus' => array(HB_FIGHT => 1000, HB_UNIT_BONUS => array('unit' => 26, 'per_unit' => 12))), + 145 => array('name' => "Haeduan's Heavy Lance", 'slot' => HSLOT_RIGHT, 'tier' => 3, 'unit' => 26, 'bonus' => array(HB_FIGHT => 1500, HB_UNIT_BONUS => array('unit' => 26, 'per_unit' => 15))), + + /* ---------------- CONSUMABLES (sheet: Consumabile; stackable, bag) ---------------- */ + 200 => array('name' => 'Ointment', 'slot' => HSLOT_BAG, 'tier' => 1, 'bonus' => array(HB_HEAL_SELF => 1)), // +1% HP per unit, max 99%, hero must be alive + 201 => array('name' => 'Scroll', 'slot' => HSLOT_BAG, 'tier' => 1, 'bonus' => array(HB_SCROLL => 10)), // +10 XP per scroll + 202 => array('name' => 'Bucket of Water', 'slot' => HSLOT_BAG, 'tier' => 2, 'bonus' => array(HB_BUCKET => 1)), // instant revive, no resource cost + 203 => array('name' => 'Law Tablet', 'slot' => HSLOT_BAG, 'tier' => 2, 'bonus' => array(HB_LOYALTY => 1)), // +1% own village loyalty, max 125% + 204 => array('name' => 'Book of Wisdom', 'slot' => HSLOT_BAG, 'tier' => 3, 'bonus' => array(HB_RESET => 1)), // reset attribute points + 205 => array('name' => 'Artwork', 'slot' => HSLOT_BAG, 'tier' => 3, 'bonus' => array(HB_ARTWORK => 1)), // CP = daily production, cap 5000 (1x) / 2500 (speed) + 206 => array('name' => 'Small Bandage', 'slot' => HSLOT_BAG, 'tier' => 1, 'bonus' => array(HB_HEAL_TROOP => 25)), // heals 25% of hero's army + 207 => array('name' => 'Bandage', 'slot' => HSLOT_BAG, 'tier' => 2, 'bonus' => array(HB_HEAL_TROOP => 33)), // heals 33% of hero's army + 208 => array('name' => 'Cage', 'slot' => HSLOT_BAG, 'tier' => 1, 'bonus' => array(HB_CAGE => 1)), // traps 1 oasis animal per cage +); + +/* Helmets 7-9 (culture) are HALVED on speed servers per the design sheet + * ("+100/day (1x) / +50 (3x)"). Runtime consumers must apply: + * value = (SPEED >= 3) ? value / 2 : value + * whenever 'x2_on_speed' === false is explicitly set (see HeroItems::scaledBonusValue()). */ + +/* ============================================================================ + * ADVENTURE CONFIGURATION (Phase 1, itemids updated to the new catalog; + * equipment drops added per design feedback: adventures are the primary + * T4 source of gear, rarer than consumables and weighted toward low tiers) + * ------------------------------------------------------------------------- */ +$heroAdventureConfig = array( + 'offer_lifetime' => 24 * 3600, + 'max_offers' => 3, + 'refresh_interval' => 8 * 3600, + + // Tier weights for equipment drops (percent, must sum to 100). + 'equip_tier_weights' => array(1 => 70, 2 => 25, 3 => 5), + + 0 => array( // NORMAL + 'label' => 'HERO_ADV_NORMAL', + 'exp' => array(6, 12), + 'silver' => array(0, 40), + 'hp_loss' => array(0, 8), + 'resources' => array(0, 400), + 'equip_chance' => 3, // % chance: 1 random piece of gear + 'item_chance' => 5, // % chance: consumables (rolled only if no gear dropped) + 'consumable' => array(200, 201, 208), // Ointment, Scroll, Cage + ), + 1 => array( // HARD + 'label' => 'HERO_ADV_HARD', + 'exp' => array(18, 30), + 'silver' => array(30, 120), + 'hp_loss' => array(10, 35), + 'resources' => array(200, 1200), + 'equip_chance' => 10, + 'item_chance' => 20, + 'consumable' => array(200, 201, 203, 206, 208), // + Law Tablet, Small Bandage + ), +); + +/* Convenience index: itemids grouped by slot. */ +$heroSlotIndex = array(); +foreach ($heroItemCatalog as $iid => $def) { + $heroSlotIndex[$def['slot']][] = $iid; +} + +/** + * Resolve the display name of an item. Checks for a HERO_ITEM_ lang + * constant first (future localisation hook), falls back to the English + * catalog name, and finally to 'Unknown item' for ids not in the catalog. + */ +if (!function_exists('heroItemName')) { + function heroItemName($itemid) { + global $heroItemCatalog; + $const = 'HERO_ITEM_' . (int) $itemid; + if (defined($const)) { + return constant($const); + } + return isset($heroItemCatalog[$itemid]['name']) ? $heroItemCatalog[$itemid]['name'] : 'Unknown item'; + } +} diff --git a/GameEngine/Database.php b/GameEngine/Database.php index 4e66370e..4904dddc 100755 --- a/GameEngine/Database.php +++ b/GameEngine/Database.php @@ -5863,6 +5863,15 @@ References: User ID/Message ID, Mode case 8: $q = "SELECT * FROM " . TB_PREFIX . "movement, " . TB_PREFIX . "attacks where " . TB_PREFIX . "movement.`" . $where . "` IN(".implode(', ', $village).") and " . TB_PREFIX . "movement.ref = " . TB_PREFIX . "attacks.id and " . TB_PREFIX . "movement.proc = 0 and " . TB_PREFIX . "movement.sort_type = 3 and " . TB_PREFIX . "attacks.attack_type = 1 ORDER BY endtime ASC"; break; + // T4 hero port: hero travelling to an adventure (plain select, + // ref points to hero_adventure - NOT to attacks, so no join). + case 20: + $q = "SELECT * FROM " . TB_PREFIX . "movement where " . TB_PREFIX . "movement.`" . $where . "` IN(".implode(', ', $village).") and sort_type = 20 and proc = 0 ORDER BY endtime ASC"; + break; + // T4 hero port: hero returning from an adventure. + case 21: + $q = "SELECT * FROM " . TB_PREFIX . "movement where " . TB_PREFIX . "movement.`" . $where . "` IN(".implode(', ', $village).") and sort_type = 21 and proc = 0 ORDER BY endtime ASC"; + break; case 34: $q = "SELECT * FROM " . TB_PREFIX . "movement, " . TB_PREFIX . "attacks where " . TB_PREFIX . "movement.`" . $where . "` IN(".implode(', ', $village).") and " . TB_PREFIX . "movement.ref = " . TB_PREFIX . "attacks.id and " . TB_PREFIX . "movement.proc = 0 and " . TB_PREFIX . "movement.sort_type = 3 or " . TB_PREFIX . "movement.`" . $where . "` IN(".implode(', ', $village).") and " . TB_PREFIX . "movement.ref = " . TB_PREFIX . "attacks.id and " . TB_PREFIX . "movement.proc = 0 and " . TB_PREFIX . "movement.sort_type = 4 ORDER BY endtime ASC"; break; diff --git a/GameEngine/HeroAdventure.php b/GameEngine/HeroAdventure.php new file mode 100644 index 00000000..2e84a42f --- /dev/null +++ b/GameEngine/HeroAdventure.php @@ -0,0 +1,643 @@ + running (status 1, movement sort_type 20) ## +## -> arrival processed by Automation (rewards + ntype 26 report, ## +## status 2, return movement sort_type 21 carrying resources) ## +## -> return processed by Automation (hero back in units.hero, ## +## resources credited) ## +## ## +## Death: if HP loss would drop health to 0, the hero dies AT the site ## +## (dead=1, health=0, loot forfeited, no return movement). units.hero was ## +## already decremented at send - identical end state to dying in battle, ## +## so the existing revive flow (37_revive.tpl + handleReviveCompletion) ## +## works unchanged. ## +## ## +## XP: only `experience` is incremented (with the helmet HB_XP% bonus). ## +## Level-up/points are intentionally NOT touched here - Automation's ## +## updateHero()/calculateLevelUp() already handles that every tick. ## +## ## +################################################################################# + +class HeroAdventure +{ + /** Hero base speed on foot, fields/hour (T4 convention). */ + const BASE_HERO_SPEED = 7; + + /** How far from the hero's village adventure sites are picked (tiles). */ + const SITE_MIN_RADIUS = 3; + const SITE_MAX_RADIUS = 25; + + /** Max users to top-up with fresh offers per automation tick (bounded work). */ + const BATCH_LIMIT = 50; + + /** startAdventure() result codes */ + const START_OK = 1; + const START_INVALID = 0; // offer missing/expired/not yours/already running + const START_NO_HERO = -1; // no living hero + const START_HERO_AWAY = -2; // hero not at home village + + /** @var mysqli */ + private $db; + + public function __construct() + { + global $database; + $this->db = $database->dblink; + + global $heroItemCatalog, $heroAdventureConfig; + if (!isset($heroAdventureConfig)) { + include_once __DIR__ . '/Data/hero_items.php'; + } + } + + /* ========================================================================= + * OFFERS + * ===================================================================== */ + + /** Non-expired, still-available offers for a user. */ + public function getOffers($uid) + { + $uid = (int) $uid; $now = time(); + $stmt = $this->db->prepare( + "SELECT id, uid, wref, difficulty, duration, created, expire, status, moveid + FROM " . TB_PREFIX . "hero_adventure + WHERE uid = ? AND status = 0 AND expire > ? + ORDER BY difficulty ASC, duration ASC" + ); + $stmt->bind_param('ii', $uid, $now); + $stmt->execute(); + $res = $stmt->get_result(); + $out = array(); + while ($row = $res->fetch_assoc()) { + $out[] = $row; + } + $stmt->close(); + return $out; + } + + /** The adventure the hero is currently running, or null. */ + public function getRunning($uid) + { + $uid = (int) $uid; + $stmt = $this->db->prepare( + "SELECT a.id, a.wref, a.difficulty, a.moveid, m.endtime, m.sort_type + FROM " . TB_PREFIX . "hero_adventure a + JOIN " . TB_PREFIX . "movement m ON m.moveid = a.moveid + WHERE a.uid = ? AND a.status = 1 AND m.proc = 0 LIMIT 1" + ); + $stmt->bind_param('i', $uid); + $stmt->execute(); + $res = $stmt->get_result(); + $row = $res->fetch_assoc(); + $stmt->close(); + return $row ?: null; + } + + /** + * Top up a single user's offer list (expires stale ones first). + * Respects max_offers and refresh_interval from $heroAdventureConfig. + * Returns the number of offers created. + */ + public function generateOffers($uid) + { + global $database, $heroAdventureConfig; + $uid = (int) $uid; $now = time(); + + // Living hero needed - offers are anchored to the hero's village. + $hero = $this->getLivingHero($uid); + if (!$hero) { + return 0; + } + + // Expire stale offers (kept as rows with status untouched would clutter + // the uid_status index; a hard delete of expired *available* offers is safe). + $stmt = $this->db->prepare( + "DELETE FROM " . TB_PREFIX . "hero_adventure WHERE uid = ? AND status = 0 AND expire <= ?" + ); + $stmt->bind_param('ii', $uid, $now); + $stmt->execute(); + $stmt->close(); + + // Respect refresh interval: don't regenerate too often. + $stmt = $this->db->prepare( + "SELECT COUNT(*) AS cnt, COALESCE(MAX(created), 0) AS latest + FROM " . TB_PREFIX . "hero_adventure WHERE uid = ? AND status = 0" + ); + $stmt->bind_param('i', $uid); + $stmt->execute(); + $res = $stmt->get_result(); + $info = $res->fetch_assoc(); + $stmt->close(); + + $have = (int) $info['cnt']; + $latest = (int) $info['latest']; + $max = (int) $heroAdventureConfig['max_offers']; + + if ($have >= $max) { + return 0; + } + if ($latest > 0 && ($now - $latest) < (int) $heroAdventureConfig['refresh_interval']) { + return 0; + } + + // Pick random unoccupied tiles around the hero's village. + $coor = $database->getCoor($hero['wref']); + if (!$coor) { + return 0; + } + $x = (int) $coor['x']; $y = (int) $coor['y']; $r = self::SITE_MAX_RADIUS; + + $need = $max - $have; + // Min radius enforced in SQL so LIMIT never undercounts (the bounding + // box doesn't wrap around the map seam, so plain coordinate distance + // is exact within it). + $stmt = $this->db->prepare( + "SELECT id, x, y FROM " . TB_PREFIX . "wdata + WHERE occupied = 0 AND id <> ? + AND x BETWEEN ? AND ? AND y BETWEEN ? AND ? + AND (POW(x - ?, 2) + POW(y - ?, 2)) >= ? + ORDER BY RAND() LIMIT ?" + ); + $wref = (int) $hero['wref']; + $x1 = $x - $r; $x2 = $x + $r; $y1 = $y - $r; $y2 = $y + $r; + $minSq = self::SITE_MIN_RADIUS * self::SITE_MIN_RADIUS; + $stmt->bind_param('iiiiiiiii', $wref, $x1, $x2, $y1, $y2, $x, $y, $minSq, $need); + $stmt->execute(); + $res = $stmt->get_result(); + $tiles = array(); + while ($row = $res->fetch_assoc()) { + $tiles[] = $row; + } + $stmt->close(); + + $created = 0; + foreach ($tiles as $tile) { + $dist = $database->getDistance($x, $y, $tile['x'], $tile['y']); + // Hard adventures ~30% of offers. + $difficulty = (mt_rand(1, 100) <= 30) ? 1 : 0; + $duration = $this->travelSeconds($uid, $dist); + $expire = $now + (int) $heroAdventureConfig['offer_lifetime']; + + $stmt = $this->db->prepare( + "INSERT INTO " . TB_PREFIX . "hero_adventure + (uid, wref, difficulty, duration, created, expire, status, moveid) + VALUES (?, ?, ?, ?, ?, ?, 0, 0)" + ); + $tileId = (int) $tile['id']; + $stmt->bind_param('iiiiii', $uid, $tileId, $difficulty, $duration, $now, $expire); + $stmt->execute(); + $stmt->close(); + $created++; + } + return $created; + } + + /** + * Batch top-up used by Automation: refresh offers for users with living, + * at-home heroes that are below max_offers. Work bounded by BATCH_LIMIT. + */ + public function generateOffersBatch() + { + global $heroAdventureConfig; + $now = time(); + $threshold = $now - (int) $heroAdventureConfig['refresh_interval']; + $max = (int) $heroAdventureConfig['max_offers']; + $limit = self::BATCH_LIMIT; + + // Users with a living hero whose available-offer count is below max + // and whose newest offer (if any) is older than the refresh interval. + $stmt = $this->db->prepare( + "SELECT h.uid + FROM " . TB_PREFIX . "hero h + LEFT JOIN " . TB_PREFIX . "hero_adventure a + ON a.uid = h.uid AND a.status = 0 AND a.expire > ? + WHERE h.dead = 0 + GROUP BY h.uid + HAVING COUNT(a.id) < ? AND COALESCE(MAX(a.created), 0) < ? + LIMIT ?" + ); + $stmt->bind_param('iiii', $now, $max, $threshold, $limit); + $stmt->execute(); + $res = $stmt->get_result(); + $uids = array(); + while ($row = $res->fetch_assoc()) { + $uids[] = (int) $row['uid']; + } + $stmt->close(); + + foreach ($uids as $uid) { + $this->generateOffers($uid); + } + return count($uids); + } + + /* ========================================================================= + * START + * ===================================================================== */ + + /** + * Send the hero on an available adventure offer. + * Returns one of the START_* class constants. + */ + public function startAdventure($uid, $adventureId) + { + $uid = (int) $uid; $adventureId = (int) $adventureId; $now = time(); + + $hero = $this->getLivingHero($uid); + if (!$hero) { + return self::START_NO_HERO; + } + // Only one adventure at a time. + if ($this->getRunning($uid)) { + return self::START_INVALID; + } + + // Load the offer (must be this user's, available, not expired). + $stmt = $this->db->prepare( + "SELECT id, wref, difficulty, duration FROM " . TB_PREFIX . "hero_adventure + WHERE id = ? AND uid = ? AND status = 0 AND expire > ? LIMIT 1" + ); + $stmt->bind_param('iii', $adventureId, $uid, $now); + $stmt->execute(); + $res = $stmt->get_result(); + $offer = $res->fetch_assoc(); + $stmt->close(); + if (!$offer) { + return self::START_INVALID; + } + + // Hero must be AT HOME: race-safe conditional decrement of units.hero. + $homeWref = (int) $hero['wref']; + $stmt = $this->db->prepare( + "UPDATE " . TB_PREFIX . "units SET hero = hero - 1 WHERE vref = ? AND hero >= 1 LIMIT 1" + ); + $stmt->bind_param('i', $homeWref); + $stmt->execute(); + $atHome = $stmt->affected_rows > 0; + $stmt->close(); + if (!$atHome) { + return self::START_HERO_AWAY; + } + + // Claim the offer atomically; if someone double-clicked and a parallel + // request claimed it first, put the hero back and bail out. + $stmt = $this->db->prepare( + "UPDATE " . TB_PREFIX . "hero_adventure SET status = 1 WHERE id = ? AND status = 0 LIMIT 1" + ); + $stmt->bind_param('i', $adventureId); + $stmt->execute(); + $claimed = $stmt->affected_rows > 0; + $stmt->close(); + if (!$claimed) { + $stmt = $this->db->prepare( + "UPDATE " . TB_PREFIX . "units SET hero = hero + 1 WHERE vref = ? LIMIT 1" + ); + $stmt->bind_param('i', $homeWref); + $stmt->execute(); + $stmt->close(); + return self::START_INVALID; + } + + // Movement out: from = home village, to = adventure tile, ref = adventure id. + // Recompute duration now (equipment may have changed since the offer was made). + global $database; + $coorHome = $database->getCoor($homeWref); + $coorSite = $database->getCoor((int) $offer['wref']); + $dist = $database->getDistance($coorHome['x'], $coorHome['y'], $coorSite['x'], $coorSite['y']); + $duration = $this->travelSeconds($uid, $dist); + $endtime = $now + $duration; + + $stmt = $this->db->prepare( + "INSERT INTO " . TB_PREFIX . "movement + (sort_type, `from`, `to`, ref, ref2, starttime, endtime, proc, send, wood, clay, iron, crop, marker) + VALUES (?, ?, ?, ?, 0, ?, ?, 0, 1, 0, 0, 0, 0, 0)" + ); + $sortType = MOVEMENT_ADVENTURE_OUT; $siteWref = (int) $offer['wref']; + $stmt->bind_param('iiiiii', $sortType, $homeWref, $siteWref, $adventureId, $now, $endtime); + $stmt->execute(); + $moveid = (int) $stmt->insert_id; + $stmt->close(); + + $stmt = $this->db->prepare( + "UPDATE " . TB_PREFIX . "hero_adventure SET moveid = ? WHERE id = ? LIMIT 1" + ); + $stmt->bind_param('ii', $moveid, $adventureId); + $stmt->execute(); + $stmt->close(); + + return self::START_OK; + } + + /* ========================================================================= + * AUTOMATION PROCESSORS + * ===================================================================== */ + + /** + * Process heroes ARRIVING at adventure sites (sort_type 20). + * Rolls rewards, applies HP loss/XP/silver/items, files the ntype 26 + * report, and creates the return movement carrying the resource loot. + */ + public function processArrivals() + { + global $database, $heroAdventureConfig; + $now = time(); + + $q = "SELECT m.moveid, m.`from`, m.`to`, m.ref, m.endtime, + a.id AS advid, a.uid, a.difficulty + FROM " . TB_PREFIX . "movement m + JOIN " . TB_PREFIX . "hero_adventure a ON a.id = m.ref + WHERE m.sort_type = " . (int) MOVEMENT_ADVENTURE_OUT . " + AND m.proc = 0 AND m.endtime < $now"; + $rows = $database->query_return($q); + if (!$rows || !count($rows)) { + return 0; + } + + $processed = 0; + foreach ($rows as $data) { + if (!$this->claimMovement($data['moveid'])) { + continue; + } + + $uid = (int) $data['uid']; + $hero = $this->getLivingHero($uid); + $cfg = $heroAdventureConfig[(int) $data['difficulty']]; + + // Hero vanished/died meanwhile (edge case): close the adventure quietly. + if (!$hero) { + $this->finishAdventure($data['advid']); + continue; + } + + /* ---- Roll rewards ---- */ + $exp = mt_rand($cfg['exp'][0], $cfg['exp'][1]); + $silver = mt_rand($cfg['silver'][0], $cfg['silver'][1]); + $hpLoss = mt_rand($cfg['hp_loss'][0], $cfg['hp_loss'][1]); + $wood = mt_rand($cfg['resources'][0], $cfg['resources'][1]); + $clay = mt_rand($cfg['resources'][0], $cfg['resources'][1]); + $iron = mt_rand($cfg['resources'][0], $cfg['resources'][1]); + $crop = mt_rand($cfg['resources'][0], $cfg['resources'][1]); + + // Item drop: gear first (rarer, tier-weighted toward T1), and only + // if no gear dropped, roll the consumable pool. One item max. + $itemId = 0; $itemQty = 0; + if (mt_rand(1, 100) <= (int) ($cfg['equip_chance'] ?? 0)) { + $itemId = $this->rollEquipment(); + $itemQty = ($itemId > 0) ? 1 : 0; + } + if ($itemId === 0 && mt_rand(1, 100) <= (int) $cfg['item_chance']) { + $pool = $cfg['consumable']; + $itemId = (int) $pool[array_rand($pool)]; + $itemQty = 1; + } + + $health = (float) $hero['health']; + $newHealth = $health - $hpLoss; + + if ($newHealth <= 0) { + /* ---- Hero dies at the site: loot forfeited, no return trip. ---- */ + $stmt = $this->db->prepare( + "UPDATE " . TB_PREFIX . "hero SET dead = 1, health = 0, lastupdate = ? WHERE heroid = ? LIMIT 1" + ); + $heroid = (int) $hero['heroid']; + $stmt->bind_param('ii', $now, $heroid); + $stmt->execute(); + $stmt->close(); + + $payload = 'difficulty=' . (int) $data['difficulty'] . '&died=1&hp=' . $hpLoss; + $database->addNotice($uid, (int) $data['from'], 0, NTYPE_ADVENTURE_REPORT, + 'Hero fell on an adventure', $payload, $now); + + $this->finishAdventure($data['advid']); + $processed++; + continue; + } + + /* ---- Hero survives: apply rewards ---- */ + $heroItems = new HeroItems(); + + // XP with helmet bonus; level-up handled by Automation::updateHero(). + $bonuses = $heroItems->getBonuses($uid); + $xpGain = (int) floor($exp * (100 + (int) $bonuses[HB_XP]) / 100); + $database->modifyHeroXp('experience', $xpGain, (int) $hero['heroid']); + + // Health loss. + $stmt = $this->db->prepare( + "UPDATE " . TB_PREFIX . "hero SET health = ? WHERE heroid = ? LIMIT 1" + ); + $heroid = (int) $hero['heroid']; + $stmt->bind_param('di', $newHealth, $heroid); + $stmt->execute(); + $stmt->close(); + + // Silver + item drop. + if ($silver > 0) { + $heroItems->addSilver($uid, $silver); + } + if ($itemId > 0) { + $heroItems->addItem($uid, $itemId, $itemQty); + } + + /* ---- Return movement carries the resource loot home ---- */ + // Same travel time back as out: reuse the adventure's stored one-way duration. + $legDuration = $this->travelLegDuration($data); + $stmt = $this->db->prepare( + "INSERT INTO " . TB_PREFIX . "movement + (sort_type, `from`, `to`, ref, ref2, starttime, endtime, proc, send, wood, clay, iron, crop, marker) + VALUES (?, ?, ?, ?, 0, ?, ?, 0, 1, ?, ?, ?, ?, 0)" + ); + $sortType = MOVEMENT_ADVENTURE_BACK; + $fromSite = (int) $data['to']; $toHome = (int) $data['from']; + $advid = (int) $data['advid']; + $end = $now + $legDuration; + $stmt->bind_param('iiiiiiiiii', $sortType, $fromSite, $toHome, $advid, $now, $end, $wood, $clay, $iron, $crop); + $stmt->execute(); + $stmt->close(); + + /* ---- Report (ntype 26) ---- */ + $payload = 'difficulty=' . (int) $data['difficulty'] + . '&exp=' . $xpGain . '&silver=' . $silver . '&hp=' . $hpLoss + . '&wood=' . $wood . '&clay=' . $clay . '&iron=' . $iron . '&crop=' . $crop; + if ($itemId > 0) { + $payload .= '&itemid=' . $itemId . '&itemqty=' . $itemQty; + } + $database->addNotice($uid, $toHome, 0, NTYPE_ADVENTURE_REPORT, + 'Hero returned from an adventure', $payload, $now); + + $this->finishAdventure($data['advid']); + $processed++; + } + return $processed; + } + + /** + * Process heroes RETURNING home (sort_type 21): hero re-enters units.hero, + * resource loot is credited to the village. + */ + public function processReturns() + { + global $database; + $now = time(); + + $q = "SELECT moveid, `from`, `to`, wood, clay, iron, crop + FROM " . TB_PREFIX . "movement + WHERE sort_type = " . (int) MOVEMENT_ADVENTURE_BACK . " + AND proc = 0 AND endtime < $now"; + $rows = $database->query_return($q); + if (!$rows || !count($rows)) { + return 0; + } + + $processed = 0; + foreach ($rows as $data) { + if (!$this->claimMovement($data['moveid'])) { + continue; + } + + $home = (int) $data['to']; + + // Hero is home again. + $stmt = $this->db->prepare( + "UPDATE " . TB_PREFIX . "units SET hero = hero + 1 WHERE vref = ? LIMIT 1" + ); + $stmt->bind_param('i', $home); + $stmt->execute(); + $stmt->close(); + + // Credit the loot (same pattern as returnunitsComplete). + if ($data['wood'] + $data['clay'] + $data['iron'] + $data['crop'] > 0) { + $database->modifyResource($home, $data['wood'], $data['clay'], $data['iron'], $data['crop'], 1); + $database->addStarvationData($home); + } + $processed++; + } + return $processed; + } + + /** + * Roll one random piece of EQUIPMENT (any non-bag catalog item, including + * weapons for other tribes - unusable finds feed the auction house, same + * as T4). Tier picked by 'equip_tier_weights' (T1 common, T3 very rare), + * then a uniform pick inside that tier. Returns an itemid, or 0 if the + * rolled tier happens to be empty (defensive; never true with the + * shipped catalog). + */ + public function rollEquipment() + { + global $heroItemCatalog, $heroAdventureConfig; + + $weights = $heroAdventureConfig['equip_tier_weights']; + $roll = mt_rand(1, 100); $acc = 0; $tier = 1; + foreach ($weights as $t => $w) { + $acc += (int) $w; + if ($roll <= $acc) { $tier = (int) $t; break; } + } + + $pool = array(); + foreach ($heroItemCatalog as $iid => $def) { + if ((int) $def['slot'] !== HSLOT_BAG && (int) $def['tier'] === $tier) { + $pool[] = $iid; + } + } + return count($pool) ? (int) $pool[array_rand($pool)] : 0; + } + + /* ========================================================================= + * INTERNALS + * ===================================================================== */ + + /** + * Travel time in seconds for a given distance, honoring equipment: + * horse replaces the on-foot base speed, spurs add on top (only with a + * horse - already enforced inside HeroItems::getBonuses()). + */ + public function travelSeconds($uid, $distance) + { + $heroItems = new HeroItems(); + $bonuses = $heroItems->getBonuses((int) $uid); + + $speed = ((int) $bonuses[HB_HORSE_SPEED] > 0) + ? (int) $bonuses[HB_HORSE_SPEED] + (int) $bonuses[HB_MOUNT_SPEED] + : self::BASE_HERO_SPEED; + + $increase = defined('INCREASE_SPEED') ? max(1, (int) INCREASE_SPEED) : 1; + return max(60, (int) round($distance / $speed * 3600 / $increase)); + } + + /** Duration of the just-finished leg (endtime - starttime is not selected; use stored offer duration). */ + private function travelLegDuration($data) + { + // The return leg mirrors the outbound one. We recompute from the + // adventure's stored one-way duration to avoid drift when Automation + // runs late (endtime long in the past). + $stmt = $this->db->prepare( + "SELECT duration FROM " . TB_PREFIX . "hero_adventure WHERE id = ? LIMIT 1" + ); + $advid = (int) $data['advid']; + $stmt->bind_param('i', $advid); + $stmt->execute(); + $stmt->bind_result($duration); + $found = $stmt->fetch(); + $stmt->close(); + return $found ? max(60, (int) $duration) : 3600; + } + + /** Same claim pattern as Automation::claimMovementRecord (race-safe). */ + private function claimMovement($moveid) + { + $moveid = (int) $moveid; + if ($moveid <= 0) { + return false; + } + $stmt = $this->db->prepare( + "UPDATE " . TB_PREFIX . "movement SET proc = 1 WHERE moveid = ? AND proc = 0 LIMIT 1" + ); + $stmt->bind_param('i', $moveid); + $stmt->execute(); + $ok = $stmt->affected_rows === 1; + $stmt->close(); + return $ok; + } + + private function finishAdventure($advid) + { + $advid = (int) $advid; + $stmt = $this->db->prepare( + "UPDATE " . TB_PREFIX . "hero_adventure SET status = 2 WHERE id = ? LIMIT 1" + ); + $stmt->bind_param('i', $advid); + $stmt->execute(); + $stmt->close(); + } + + private function getLivingHero($uid) + { + $uid = (int) $uid; + $stmt = $this->db->prepare( + "SELECT heroid, uid, unit, wref, level, experience, health, dead + FROM " . TB_PREFIX . "hero WHERE uid = ? AND dead = 0 LIMIT 1" + ); + $stmt->bind_param('i', $uid); + $stmt->execute(); + $res = $stmt->get_result(); + $row = $res->fetch_assoc(); + $stmt->close(); + return $row ?: null; + } +} diff --git a/GameEngine/HeroAuction.php b/GameEngine/HeroAuction.php new file mode 100644 index 00000000..61816fa1 --- /dev/null +++ b/GameEngine/HeroAuction.php @@ -0,0 +1,533 @@ + at any moment: sum of all player silver + escrowed bid_max of top ## +## bidders is constant (minus the seller fee at finalization). ## +## ## +## SELLER FEE: player sellers receive silver_current minus AUCTION_FEE_PCT ## +## (10%, T4 convention). NPC-seller (seller = 0) proceeds vanish (silver ## +## sink). Unsold player items are returned to the seller's inventory. ## +## ## +## No cancellation once listed (T4 convention) - flagged, not implemented. ## +## ## +################################################################################# + +class HeroAuction +{ + /** Seller fee percent taken from the hammer price (player sellers only). */ + const AUCTION_FEE_PCT = 10; + + /** Minimum bid increment over the visible price. */ + const MIN_INCREMENT = 1; + + /** Allowed listing durations, seconds (4h / 8h / 24h like T4). */ + public static $allowedDurations = array(14400, 28800, 86400); + + /** How many NPC auctions to keep open at once. */ + const NPC_OPEN_TARGET = 10; + + /** NPC listing duration: 8 hours. */ + const NPC_DURATION = 28800; + + /** status values (mirror the auction table docs from Phase 1) */ + const ST_OPEN = 0; + const ST_SOLD = 1; // reserved intermediate state; finalization goes straight to 2 + const ST_PROCESSED = 2; + const ST_EXPIRED = 3; + + /** result codes for placeBid() / createAuction() */ + const BID_OK = 1; + const BID_OUTBID = 2; // accepted, but instantly below the holder's secret max + const BID_INVALID = 0; // bad auction/own auction/too low + const BID_NO_SILVER = -1; + + /** @var mysqli */ + private $db; + + public function __construct() + { + global $database; + $this->db = $database->dblink; + + global $heroItemCatalog; + if (!isset($heroItemCatalog)) { + include_once __DIR__ . '/Data/hero_items.php'; + } + } + + /* ========================================================================= + * READS + * ===================================================================== */ + + /** All open, unexpired auctions (newest ending first optional; default: ending soonest). */ + public function getOpenAuctions($limit = 50) + { + $now = time(); $limit = max(1, (int) $limit); + $stmt = $this->db->prepare( + "SELECT id, seller, itemid, slot, stat_value, quantity, + silver_start, silver_current, bidder, created, time_end, status + FROM " . TB_PREFIX . "auction + WHERE status = " . self::ST_OPEN . " AND time_end > ? + ORDER BY time_end ASC LIMIT ?" + ); + $stmt->bind_param('ii', $now, $limit); + $stmt->execute(); + $res = $stmt->get_result(); + $out = array(); + while ($row = $res->fetch_assoc()) { + $row['name'] = heroItemName($row['itemid']); + // bid_max intentionally NOT selected - it's the bidder's secret. + $out[] = $row; + } + $stmt->close(); + return $out; + } + + /** Auctions where $uid is the current top bidder. */ + public function getMyBids($uid) + { + $uid = (int) $uid; $now = time(); + $stmt = $this->db->prepare( + "SELECT id, seller, itemid, quantity, silver_current, bid_max, time_end + FROM " . TB_PREFIX . "auction + WHERE bidder = ? AND status = " . self::ST_OPEN . " AND time_end > ? + ORDER BY time_end ASC" + ); + $stmt->bind_param('ii', $uid, $now); + $stmt->execute(); + $res = $stmt->get_result(); + $out = array(); + while ($row = $res->fetch_assoc()) { + $row['name'] = heroItemName($row['itemid']); + $out[] = $row; + } + $stmt->close(); + return $out; + } + + /** Open auctions listed by $uid. */ + public function getMySales($uid) + { + $uid = (int) $uid; + $stmt = $this->db->prepare( + "SELECT id, itemid, quantity, silver_start, silver_current, bidder, time_end, status + FROM " . TB_PREFIX . "auction + WHERE seller = ? AND status = " . self::ST_OPEN . " + ORDER BY time_end ASC" + ); + $stmt->bind_param('i', $uid); + $stmt->execute(); + $res = $stmt->get_result(); + $out = array(); + while ($row = $res->fetch_assoc()) { + $row['name'] = heroItemName($row['itemid']); + $out[] = $row; + } + $stmt->close(); + return $out; + } + + /* ========================================================================= + * LISTING + * ===================================================================== */ + + /** + * List an owned, UNEQUIPPED item (or part of a consumable stack). + * The item leaves the inventory immediately (escrowed in the auction row). + * Returns the new auction id, or 0 on failure. + */ + public function createAuction($uid, $inventoryRowId, $quantity, $startPrice, $duration) + { + $uid = (int) $uid; $inventoryRowId = (int) $inventoryRowId; + $quantity = max(1, (int) $quantity); + $startPrice = max(1, (int) $startPrice); + $duration = (int) $duration; + + if (!in_array($duration, self::$allowedDurations, true)) { + return 0; + } + + $heroItems = new HeroItems(); + $row = null; + foreach ($heroItems->getInventory($uid) as $r) { + if ((int) $r['id'] === $inventoryRowId) { $row = $r; break; } + } + // Must exist, be catalog-known, unequipped, and cover the quantity. + if (!$row || $row['orphan'] || (int) $row['equipped'] === 1 || (int) $row['quantity'] < $quantity) { + return 0; + } + // Gear always lists as a single piece. + if ((int) $row['def']['slot'] !== HSLOT_BAG) { + $quantity = 1; + } + + // Take the item out of the inventory first (race-safe decrement inside). + if (!$heroItems->removeItem($uid, $inventoryRowId, $quantity)) { + return 0; + } + + $now = time(); $end = $now + $duration; + $itemid = (int) $row['itemid']; $slot = (int) $row['def']['slot']; + $statValue = (int) $row['stat_value']; + + $stmt = $this->db->prepare( + "INSERT INTO " . TB_PREFIX . "auction + (seller, itemid, slot, stat_value, quantity, + silver_start, silver_current, bidder, bid_max, created, time_end, status) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, " . self::ST_OPEN . ")" + ); + $stmt->bind_param('iiiiiiiii', $uid, $itemid, $slot, $statValue, $quantity, + $startPrice, $startPrice, $now, $end); + $stmt->execute(); + $auctionId = (int) $stmt->insert_id; + $stmt->close(); + + return $auctionId; + } + + /* ========================================================================= + * BIDDING (proxy model) + * ===================================================================== */ + + /** + * Place a maximum bid. Returns a BID_* code. + * + * Escrow choreography (all conditional statements, race-safe): + * 1. take $maxBid silver from the bidder (refused if balance is short); + * 2. try to become top bidder atomically + * (WHERE bid_max < $maxBid guards the proxy comparison); + * 3a. success -> refund the PREVIOUS top bidder's full escrow; + * 3b. failure -> we lost against the holder's secret max: bump the + * visible price to min(holderMax, ourMax + 1) and refund ourselves. + */ + public function placeBid($uid, $auctionId, $maxBid) + { + $uid = (int) $uid; $auctionId = (int) $auctionId; $maxBid = (int) $maxBid; + $now = time(); + + // Snapshot for validation (authoritative checks are in the UPDATEs). + // bid_max is only used for the self-raise delta below - it is the + // caller's OWN secret in that case, never exposed to rivals. + $stmt = $this->db->prepare( + "SELECT seller, silver_current, bidder, bid_max FROM " . TB_PREFIX . "auction + WHERE id = ? AND status = " . self::ST_OPEN . " AND time_end > ? LIMIT 1" + ); + $stmt->bind_param('ii', $auctionId, $now); + $stmt->execute(); + $res = $stmt->get_result(); + $auction = $res->fetch_assoc(); + $stmt->close(); + + if (!$auction || (int) $auction['seller'] === $uid) { + return self::BID_INVALID; + } + // A fresh auction (no bidder) can be won AT the start price; + // an active one needs current + increment. + $minBid = (int) $auction['silver_current'] + + ((int) $auction['bidder'] > 0 ? self::MIN_INCREMENT : 0); + if ($maxBid < $minBid) { + return self::BID_INVALID; + } + // Self-raise: the old escrow (old bid_max) is already held, so only + // the difference is taken; a raise below the current max is pointless. + $selfRaise = ((int) $auction['bidder'] === $uid); + if ($selfRaise && $maxBid <= (int) $auction['bid_max']) { + return self::BID_INVALID; + } + $escrow = $selfRaise ? $maxBid - (int) $auction['bid_max'] : $maxBid; + + $heroItems = new HeroItems(); + + // 1. escrow (conditional - fails on insufficient silver). + if (!$heroItems->spendSilver($uid, $escrow)) { + return self::BID_NO_SILVER; + } + + // 2. capture the current holder as the refund target, then claim. + // Correctness anchor is the conditional UPDATE below - this read + // only records who to refund if we displace them. + $stmt = $this->db->prepare( + "SELECT bidder, bid_max FROM " . TB_PREFIX . "auction WHERE id = ? LIMIT 1" + ); + $stmt->bind_param('i', $auctionId); + $stmt->execute(); + $res = $stmt->get_result(); + $prev = $res->fetch_assoc(); + $stmt->close(); + + if ($selfRaise) { + // Deepening our own secret max never moves the visible price and + // never refunds anyone - the old escrow stays held, only the + // delta was added above. + $stmt = $this->db->prepare( + "UPDATE " . TB_PREFIX . "auction + SET bid_max = ? + WHERE id = ? AND status = " . self::ST_OPEN . " AND time_end > ? + AND bidder = ? AND bid_max < ?" + ); + $stmt->bind_param('iiiii', $maxBid, $auctionId, $now, $uid, $maxBid); + $stmt->execute(); + $won = $stmt->affected_rows > 0; + $stmt->close(); + + if ($won) { + return self::BID_OK; + } + // Race lost: a rival displaced us between snapshot and update, so + // their winning path already refunded our OLD escrow - the delta + // is all we still hold. Return it. + $heroItems->addSilver($uid, $escrow); + return self::BID_OUTBID; + } + + $stmt = $this->db->prepare( + "UPDATE " . TB_PREFIX . "auction + SET silver_current = LEAST(?, GREATEST(silver_current, bid_max + " . self::MIN_INCREMENT . ")), + bidder = ?, bid_max = ? + WHERE id = ? AND status = " . self::ST_OPEN . " AND time_end > ? + AND bid_max < ? AND seller <> ? AND bidder <> ?" + ); + $stmt->bind_param('iiiiiiii', $maxBid, $uid, $maxBid, $auctionId, $now, $maxBid, $uid, $uid); + $stmt->execute(); + $won = $stmt->affected_rows > 0; + $stmt->close(); + + if ($won) { + // 3a. refund the displaced bidder's full escrow. + if ($prev && (int) $prev['bidder'] > 0 && (int) $prev['bidder'] !== $uid && (int) $prev['bid_max'] > 0) { + $heroItems->addSilver((int) $prev['bidder'], (int) $prev['bid_max']); + } + return self::BID_OK; + } + + // 3b. lost against the holder's secret max (or the auction closed in + // the meantime): bump the visible price if still open, refund us. + $stmt = $this->db->prepare( + "UPDATE " . TB_PREFIX . "auction + SET silver_current = LEAST(bid_max, ? + " . self::MIN_INCREMENT . ") + WHERE id = ? AND status = " . self::ST_OPEN . " AND time_end > ? AND bid_max >= ?" + ); + $stmt->bind_param('iiii', $maxBid, $auctionId, $now, $maxBid); + $stmt->execute(); + $stmt->close(); + + $heroItems->addSilver($uid, $escrow); + return self::BID_OUTBID; + } + + /* ========================================================================= + * AUTOMATION PROCESSORS + * ===================================================================== */ + + /** + * Finalize ended auctions: + * - with a bidder: award the item, refund (bid_max - silver_current) to + * the winner, pay the player seller (minus fee), file reports; + * - without: return the item to a player seller, mark expired. + * NPC (seller 0) items simply vanish when unsold; NPC proceeds vanish too. + */ + public function processFinished() + { + global $database; + $now = time(); + + $q = "SELECT id, seller, itemid, stat_value, quantity, + silver_current, bidder, bid_max + FROM " . TB_PREFIX . "auction + WHERE status = " . self::ST_OPEN . " AND time_end <= $now"; + $rows = $database->query_return($q); + if (!$rows || !count($rows)) { + return 0; + } + + $heroItems = new HeroItems(); + $processed = 0; + + foreach ($rows as $a) { + // Claim the row (same race-safe pattern as movement claiming). + $stmt = $this->db->prepare( + "UPDATE " . TB_PREFIX . "auction SET status = " . self::ST_PROCESSED . " + WHERE id = ? AND status = " . self::ST_OPEN . " LIMIT 1" + ); + $aid = (int) $a['id']; + $stmt->bind_param('i', $aid); + $stmt->execute(); + $claimed = $stmt->affected_rows > 0; + $stmt->close(); + if (!$claimed) { + continue; + } + + $bidder = (int) $a['bidder']; + $seller = (int) $a['seller']; + $price = (int) $a['silver_current']; + + if ($bidder > 0) { + /* ---- SOLD ---- */ + $heroItems->addItem($bidder, (int) $a['itemid'], (int) $a['quantity'], (int) $a['stat_value']); + + // Winner escrowed bid_max; they only pay the hammer price. + $refund = (int) $a['bid_max'] - $price; + if ($refund > 0) { + $heroItems->addSilver($bidder, $refund); + } + + // Player seller gets the price minus the fee; NPC proceeds sink. + if ($seller > 0) { + $fee = (int) floor($price * self::AUCTION_FEE_PCT / 100); + $heroItems->addSilver($seller, $price - $fee); + $database->addNotice($seller, 0, 0, NTYPE_AUCTION_REPORT, + 'Auction sold', + 'role=seller&itemid=' . (int) $a['itemid'] . '&qty=' . (int) $a['quantity'] + . '&price=' . $price . '&fee=' . $fee, $now); + } + + $database->addNotice($bidder, 0, 0, NTYPE_AUCTION_REPORT, + 'Auction won', + 'role=winner&itemid=' . (int) $a['itemid'] . '&qty=' . (int) $a['quantity'] + . '&price=' . $price . '&refund=' . max(0, $refund), $now); + } else { + /* ---- UNSOLD ---- */ + if ($seller > 0) { + $heroItems->addItem($seller, (int) $a['itemid'], (int) $a['quantity'], (int) $a['stat_value']); + $database->addNotice($seller, 0, 0, NTYPE_AUCTION_REPORT, + 'Auction expired', + 'role=seller&expired=1&itemid=' . (int) $a['itemid'] . '&qty=' . (int) $a['quantity'], $now); + } + // Correct the claimed status for the unsold case. + $stmt = $this->db->prepare( + "UPDATE " . TB_PREFIX . "auction SET status = " . self::ST_EXPIRED . " WHERE id = ? LIMIT 1" + ); + $stmt->bind_param('i', $aid); + $stmt->execute(); + $stmt->close(); + } + $processed++; + } + return $processed; + } + + /** + * ADMIN-ONLY: cancel an open auction outright (Phase 7 admin panel). + * Player-facing cancellation intentionally does not exist (T4 convention); + * this is the moderation escape hatch. Refunds the top bidder's full + * escrow and returns the item to a player seller (NPC items vanish). + * Returns true when an open auction was cancelled. + */ + public function adminCancel($auctionId) + { + $auctionId = (int) $auctionId; + + // Snapshot before claiming so we know who to refund. + $stmt = $this->db->prepare( + "SELECT seller, itemid, stat_value, quantity, bidder, bid_max + FROM " . TB_PREFIX . "auction + WHERE id = ? AND status = " . self::ST_OPEN . " LIMIT 1" + ); + $stmt->bind_param('i', $auctionId); + $stmt->execute(); + $res = $stmt->get_result(); + $a = $res->fetch_assoc(); + $stmt->close(); + if (!$a) { + return false; + } + + // Race-safe claim straight to EXPIRED so processFinished can't touch it. + $stmt = $this->db->prepare( + "UPDATE " . TB_PREFIX . "auction SET status = " . self::ST_EXPIRED . " + WHERE id = ? AND status = " . self::ST_OPEN . " LIMIT 1" + ); + $stmt->bind_param('i', $auctionId); + $stmt->execute(); + $claimed = $stmt->affected_rows > 0; + $stmt->close(); + if (!$claimed) { + return false; + } + + $heroItems = new HeroItems(); + if ((int) $a['bidder'] > 0 && (int) $a['bid_max'] > 0) { + $heroItems->addSilver((int) $a['bidder'], (int) $a['bid_max']); + } + if ((int) $a['seller'] > 0) { + $heroItems->addItem((int) $a['seller'], (int) $a['itemid'], (int) $a['quantity'], (int) $a['stat_value']); + } + return true; + } + + /** + * Keep ~NPC_OPEN_TARGET NPC-seller auctions open so the market always has + * stock. Random catalog items; consumables list in small stacks. + * Start price scales with tier so tier-3 gear stays expensive. + */ + public function seedNpcAuctions() + { + global $heroItemCatalog; + $now = time(); + + $stmt = $this->db->prepare( + "SELECT COUNT(*) FROM " . TB_PREFIX . "auction + WHERE seller = 0 AND status = " . self::ST_OPEN . " AND time_end > ?" + ); + $stmt->bind_param('i', $now); + $stmt->execute(); + $stmt->bind_result($open); + $stmt->fetch(); + $stmt->close(); + + $need = self::NPC_OPEN_TARGET - (int) $open; + if ($need <= 0) { + return 0; + } + + $ids = array_keys($heroItemCatalog); + $created = 0; + for ($i = 0; $i < $need; $i++) { + $itemid = (int) $ids[array_rand($ids)]; + $def = $heroItemCatalog[$itemid]; + $isBag = ((int) $def['slot'] === HSLOT_BAG); + $qty = $isBag ? mt_rand(3, 15) : 1; + // Start price: tier-scaled for gear, per-piece for consumables. + $start = $isBag ? $qty * mt_rand(2, 5) : (int) $def['tier'] * mt_rand(40, 80); + $end = $now + self::NPC_DURATION; + $slot = (int) $def['slot']; + + $stmt = $this->db->prepare( + "INSERT INTO " . TB_PREFIX . "auction + (seller, itemid, slot, stat_value, quantity, + silver_start, silver_current, bidder, bid_max, created, time_end, status) + VALUES (0, ?, ?, 0, ?, ?, ?, 0, 0, ?, ?, " . self::ST_OPEN . ")" + ); + $stmt->bind_param('iiiiiii', $itemid, $slot, $qty, $start, $start, $now, $end); + $stmt->execute(); + $stmt->close(); + $created++; + } + return $created; + } +} diff --git a/GameEngine/HeroBattleBonus.php b/GameEngine/HeroBattleBonus.php new file mode 100644 index 00000000..e1f18aa6 --- /dev/null +++ b/GameEngine/HeroBattleBonus.php @@ -0,0 +1,529 @@ + statBonus(), attaches uid/unit_bonus/ ## +## vs_natars/dmg_reduce to the hero array ## +## Battle HERO OFFENSE block -> unitOffense(), natarMultiplier() ## +## Battle defender hero block -> unitDefense() ## +## Battle HERO DAMAGE block -> reduceDamage() ## +## Automation bounty callsite -> raidMultiplier() ## +## Automation return callsites -> adjustReturnEndtime() ## +## Units::getWalkingTroopsTime -> adjustTravelTime() ## +## ## +################################################################################# + +class HeroBattleBonus +{ + /** @var array per-request bonus cache: uid => bonuses array */ + private static $cache = array(); + + /** @var array per-request "does uid have a living hero" cache */ + private static $livingCache = array(); + + /** Feature flag gate used by every public entry point. */ + public static function enabled() + { + return defined('NEW_FUNCTIONS_HERO_T4') && NEW_FUNCTIONS_HERO_T4; + } + + /** Cached HeroItems::getBonuses(). Returns null when the feature is off. */ + public static function bonuses($uid) + { + if (!self::enabled()) { + return null; + } + $uid = (int) $uid; + if (!array_key_exists($uid, self::$cache)) { + $heroItems = new HeroItems(); + self::$cache[$uid] = $heroItems->getBonuses($uid); + } + return self::$cache[$uid]; + } + + /* ========================================================================= + * BATTLE MATH + * ===================================================================== */ + + /** + * Flat fighting strength from items (weapons, cuirasses, shields). + * Added to the hero's atk AND di/dc - T4 fight strength is a single stat + * that counts on whichever side the hero fights. + */ + public static function statBonus($uid) + { + $b = self::bonuses($uid); + return $b ? (int) $b[HB_FIGHT] : 0; + } + + /** [unitId => perUnit] map from the equipped weapon (empty when none). */ + public static function unitBonusMap($uid) + { + $b = self::bonuses($uid); + return ($b && !empty($b[HB_UNIT_BONUS])) ? $b[HB_UNIT_BONUS] : array(); + } + + /** + * Extra offense from the weapon's per-unit bonus: + * +N attack per accompanying unit of the weapon's type. + * $isCavalry tells the caller whether to add it to ap or cap. + * Returns [addedInfantryAp, addedCavalryAp]. + */ + public static function unitOffense($uid, array $attackerUnits, array $cavalryLookup) + { + $map = self::unitBonusMap($uid); + if (empty($map)) { + return array(0, 0); + } + $ap = 0; $cap = 0; + foreach ($map as $unitId => $perUnit) { + $count = isset($attackerUnits['u' . $unitId]) ? (int) $attackerUnits['u' . $unitId] : 0; + if ($count <= 0) { + continue; + } + if (isset($cavalryLookup[$unitId])) { + $cap += $perUnit * $count; + } else { + $ap += $perUnit * $count; + } + } + return array($ap, $cap); + } + + /** + * Extra defense from the weapon's per-unit bonus: + * +N defense per accompanying unit of the weapon's type. The design sheet + * grants a single "+N def" figure, so it is added to BOTH the infantry + * (dp) and cavalry (cdp) defense pools. + * Returns the flat amount to add to each pool. + */ + public static function unitDefense($uid, array $defenderUnits) + { + $map = self::unitBonusMap($uid); + if (empty($map)) { + return 0; + } + $add = 0; + foreach ($map as $unitId => $perUnit) { + $count = isset($defenderUnits['u' . $unitId]) ? (int) $defenderUnits['u' . $unitId] : 0; + if ($count > 0) { + $add += $perUnit * $count; + } + } + return $add; + } + + /** + * Hunting horn: multiplier applied to the HERO's own attack contribution + * when the defender is the Natars (uid 3, see Artifacts::NATARS_UID). + */ + public static function natarMultiplier($uid, $defenderUid) + { + if ((int) $defenderUid !== 3) { + return 1.0; + } + $b = self::bonuses($uid); + if (!$b || (int) $b[HB_VS_NATARS] <= 0) { + return 1.0; + } + return 1 + ((int) $b[HB_VS_NATARS] / 100); + } + + /** + * Armor damage reduction: flat points subtracted from the health damage + * the hero takes in one battle (never below 0). + */ + public static function reduceDamage($uid, $damage) + { + $b = self::bonuses($uid); + if (!$b || (int) $b[HB_DMG_REDUCE] <= 0) { + return (int) $damage; + } + return max(0, (int) $damage - (int) $b[HB_DMG_REDUCE]); + } + + /* ========================================================================= + * RAID BOUNTY (thief sacks) + * ===================================================================== */ + + /** + * Sacks increase the resources stolen. Applies only when the attacker's + * living hero actually took part in the strike ($heroSent > 0). The hero + * that died in this very battle was already flagged dead by Battle.php + * before the bounty step runs, so livingHero() naturally excludes them. + */ + public static function raidMultiplier($uid, $heroSent) + { + if ((int) $heroSent <= 0 || !self::livingHero($uid)) { + return 1.0; + } + $b = self::bonuses($uid); + if (!$b || (int) $b[HB_RAID] <= 0) { + return 1.0; + } + return 1 + ((int) $b[HB_RAID] / 100); + } + + /* ========================================================================= + * TRAVEL TIME (boots / pennant / standard / map) + * ===================================================================== */ + + /** + * Adjust an OUTBOUND troop-travel time (seconds) computed by + * Generator::procDistanceTime(). Only applies when the hero rides along. + * + * - Pennant (HB_SPEED_OWN): +% speed when the target village belongs + * to the same player; + * - Standard (HB_SPEED_ALLY): +% speed when the target belongs to an + * alliance mate (pennant wins if both match); + * - Boots (HB_TROOP_SPEED_20): +% speed for the part of the journey + * beyond 20 tiles, same piecewise idea as the Tournament Square + * (issue #304). NOTE: applied proportionally on the final time, which + * is exact on its own; when a Tournament Square is also active the two + * boosts compose multiplicatively on the >20 segment - a documented, + * deliberate approximation. + */ + public static function adjustTravelTime($ownerUid, $heroInArmy, $fromWref, $toWref, $seconds) + { + global $database; + + if (!self::enabled() || (int) $heroInArmy <= 0 || $seconds <= 0) { + return (int) $seconds; + } + $b = self::bonuses($ownerUid); + if (!$b) { + return (int) $seconds; + } + + $seconds = (float) $seconds; + + // --- pennant / standard --- + // Pennant: BOTH endpoints must belong to the player (journeys between + // own villages). Standard: both endpoints inside the same alliance. + // Destination-only checks would wrongly buff every return leg home. + $pct = 0; + $fromOwner = (int) $database->getVillageField((int) $fromWref, 'owner'); + $targetOwner = (int) $database->getVillageField((int) $toWref, 'owner'); + if ($fromOwner > 0 && $targetOwner > 0) { + if ($fromOwner === (int) $ownerUid && $targetOwner === (int) $ownerUid) { + $pct = (int) $b[HB_SPEED_OWN]; + } elseif ((int) $b[HB_SPEED_ALLY] > 0) { + $ownAlly = (int) $database->getUserField($fromOwner, 'alliance', 0); + $targetAlly = (int) $database->getUserField($targetOwner, 'alliance', 0); + if ($ownAlly > 0 && $ownAlly === $targetAlly) { + $pct = (int) $b[HB_SPEED_ALLY]; + } + } + } + if ($pct > 0) { + $seconds = $seconds / (1 + $pct / 100); + } + + // --- boots: only the beyond-20-tiles share of the journey --- + $bootsPct = (int) $b[HB_TROOP_SPEED_20]; + if ($bootsPct > 0) { + $fromCoor = $database->getCoor((int) $fromWref); + $toCoor = $database->getCoor((int) $toWref); + if ($fromCoor && $toCoor) { + $distance = (float) $database->getDistance($fromCoor['x'], $fromCoor['y'], $toCoor['x'], $toCoor['y']); + if ($distance > 20) { + $shareBeyond = ($distance - 20) / $distance; // time share past 20 tiles + $timeBeyond = $seconds * $shareBeyond; + $seconds = ($seconds - $timeBeyond) + $timeBeyond / (1 + $bootsPct / 100); + } + } + } + + return max(1, (int) round($seconds)); + } + + /** + * Map: shorten the RETURN leg after a battle. Receives the arrival time + * and the planned return endtime; compresses the leg duration by the + * map's percentage when the (still living) hero is coming home. + */ + public static function adjustReturnEndtime($ownerUid, $heroSent, $arrivalTime, $endtime) + { + if (!self::enabled() || (int) $heroSent <= 0 || !self::livingHero($ownerUid)) { + return $endtime; + } + $b = self::bonuses($ownerUid); + if (!$b || (int) $b[HB_RETURN_SPEED] <= 0) { + return $endtime; + } + $leg = (float) $endtime - (float) $arrivalTime; + if ($leg <= 0) { + return $endtime; + } + return $arrivalTime + $leg / (1 + (int) $b[HB_RETURN_SPEED] / 100); + } + + /* ========================================================================= + * BANDAGES (post-battle troop healing) + * ===================================================================== */ + + /** + * Heal part of the attacker's fallen troops after a battle (Phase 6): + * up to pct% of each unit type's losses return home, consuming ONE + * bandage per healed unit. Better bandages (33%, itemid 207) are used + * before small ones (25%, itemid 206). Applies only when the hero came + * home alive ($returning['t11'] > 0). The hero itself is never healable. + * + * NOTE: hero XP was already granted on the full casualty count before + * this runs - healed troops still award XP (deliberate, documented). + * + * @param int $uid Attacking player. + * @param array $returning ['t1'..'t11' => surviving counts] (modified copy returned). + * @param array $dead [1..11 => losses per unit index]. + * @param int $attackRef attacks.id of this strike. returnunitsComplete + * reads the homecoming troop counts from the + * attacks row (movement.ref join), NOT from the + * in-memory array - healed units MUST be written + * back there or they would vanish on arrival. + * @return array The adjusted returning-troops array. + */ + public static function applyBandages($uid, array $returning, array $dead, $attackRef = 0) + { + global $database; + + if (!self::enabled() || (int) ($returning['t11'] ?? 0) <= 0) { + return $returning; + } + + $heroItems = new HeroItems(); + // Bandage stacks, strongest first: [rowId, remaining, pct] + $stacks = array(); + foreach ($heroItems->getInventory((int) $uid) as $row) { + if ($row['orphan'] || $row['equipped'] == 1) { + continue; + } + if ((int) $row['itemid'] === 207) { + $stacks[0] = array('id' => (int) $row['id'], 'qty' => (int) $row['quantity'], 'pct' => 33); + } elseif ((int) $row['itemid'] === 206) { + $stacks[1] = array('id' => (int) $row['id'], 'qty' => (int) $row['quantity'], 'pct' => 25); + } + } + if (empty($stacks)) { + return $returning; + } + ksort($stacks); + + $healedPerUnit = array(); + + foreach ($stacks as $stack) { + if ($stack['qty'] <= 0) { + continue; + } + $budget = $stack['qty']; + $used = 0; + + for ($i = 1; $i <= 10 && $budget > 0; $i++) { + $lost = isset($dead[$i]) ? (int) $dead[$i] : 0; + if ($lost <= 0) { + continue; + } + $healable = (int) floor($lost * $stack['pct'] / 100); + $healed = min($healable, $budget); + if ($healed <= 0) { + continue; + } + $returning['t' . $i] = (int) ($returning['t' . $i] ?? 0) + $healed; + $dead[$i] -= $healed; // a unit is only healed once across stacks + $budget -= $healed; + $used += $healed; + $healedPerUnit[$i] = ($healedPerUnit[$i] ?? 0) + $healed; + } + + if ($used > 0) { + $heroItems->removeItem((int) $uid, $stack['id'], $used); + } + } + + // Persist the revived units into the attacks row: it is the source of + // truth returnunitsComplete hands back to the village on arrival. + if (!empty($healedPerUnit) && (int) $attackRef > 0) { + $sets = array(); + foreach ($healedPerUnit as $i => $healed) { + $sets[] = 't' . (int) $i . ' = t' . (int) $i . ' + ' . (int) $healed; + } + mysqli_query( + $database->dblink, + "UPDATE " . TB_PREFIX . "attacks SET " . implode(', ', $sets) + . " WHERE id = " . (int) $attackRef . " LIMIT 1" + ); + } + + return $returning; + } + + /* ========================================================================= + * CAGES (oasis animal capture) + * ===================================================================== */ + + /** + * Cages (itemid 208): when the hero attacks an UNOCCUPIED oasis, up to + * one animal per cage is captured BEFORE the fight - captured animals do + * not defend. They are stationed as defensive reinforcements in the + * attack's home village (enforcement row vref=home, from=home; nature + * units u31-u40 count in defense through the normal enforcement path). + * + * Deliberate, documented simplification vs T4: the animals appear at + * home the moment the battle resolves instead of travelling back with + * the hero (the attacks row can only carry the attacker's own tribe + * units, and a schema change for this isn't worth it). + * + * Removal from the oasis is a CONDITIONAL decrement per unit type + * (u31 >= N guards), so a parallel process can never drive counts + * negative; on a lost race the capture simply shrinks. + * + * @param int $uid Attacking player. + * @param int $homeWref Village the attack was sent from. + * @param int $oasisWref The unoccupied oasis under attack. + * @param array $Defender Defender unit array (modified copy returned). + * @return array The Defender array minus whatever was captured. + */ + public static function applyCages($uid, $homeWref, $oasisWref, array $Defender) + { + global $database; + + if (!self::enabled()) { + return $Defender; + } + + // Animals present? + $animals = 0; + for ($i = 31; $i <= 40; $i++) { + $animals += max(0, (int) ($Defender['u' . $i] ?? 0)); + } + if ($animals <= 0) { + return $Defender; + } + + // Cage stack (unequipped, itemid 208). + $heroItems = new HeroItems(); + $cageRow = null; + foreach ($heroItems->getInventory((int) $uid) as $row) { + if (!$row['orphan'] && (int) $row['itemid'] === 208 && $row['equipped'] == 0) { + $cageRow = $row; + break; + } + } + if (!$cageRow || (int) $cageRow['quantity'] <= 0) { + return $Defender; + } + + $budget = (int) $cageRow['quantity']; + $captured = array(); + + for ($i = 31; $i <= 40 && $budget > 0; $i++) { + $have = max(0, (int) ($Defender['u' . $i] ?? 0)); + if ($have <= 0) { + continue; + } + $take = min($have, $budget); + + // Conditional removal from the oasis - authoritative count. + $stmt = $database->dblink->prepare( + "UPDATE " . TB_PREFIX . "units + SET u{$i} = u{$i} - ? + WHERE vref = ? AND u{$i} >= ? LIMIT 1" + ); + $stmt->bind_param('iii', $take, $oasisWref, $take); + $stmt->execute(); + $removed = $stmt->affected_rows > 0; + $stmt->close(); + if (!$removed) { + continue; // lost a race; leave these animals to fight + } + + $Defender['u' . $i] = $have - $take; + $captured[$i] = $take; + $budget -= $take; + } + + if (empty($captured)) { + return $Defender; + } + + $used = array_sum($captured); + $heroItems->removeItem((int) $uid, (int) $cageRow['id'], $used); + + // Station the animals at home: reuse the self-enforcement row if one + // exists (vref = from = home), otherwise create it. + $homeWref = (int) $homeWref; + $res = mysqli_query( + $database->dblink, + "SELECT id FROM " . TB_PREFIX . "enforcement + WHERE vref = $homeWref AND `from` = $homeWref LIMIT 1" + ); + $rowE = $res ? mysqli_fetch_assoc($res) : null; + if (!$rowE) { + mysqli_query( + $database->dblink, + "INSERT INTO " . TB_PREFIX . "enforcement (vref, `from`) VALUES ($homeWref, $homeWref)" + ); + $enfId = (int) mysqli_insert_id($database->dblink); + } else { + $enfId = (int) $rowE['id']; + } + + $sets = array(); + foreach ($captured as $i => $n) { + $sets[] = 'u' . (int) $i . ' = u' . (int) $i . ' + ' . (int) $n; + } + mysqli_query( + $database->dblink, + "UPDATE " . TB_PREFIX . "enforcement SET " . implode(', ', $sets) + . " WHERE id = $enfId LIMIT 1" + ); + + return $Defender; + } + + /* ========================================================================= + * INTERNALS + * ===================================================================== */ + + /** Cached "uid has a living hero" check. */ + private static function livingHero($uid) + { + global $database; + $uid = (int) $uid; + if (!array_key_exists($uid, self::$livingCache)) { + $res = mysqli_query( + $database->dblink, + "SELECT heroid FROM " . TB_PREFIX . "hero WHERE uid = $uid AND dead = 0 LIMIT 1" + ); + self::$livingCache[$uid] = ($res && mysqli_fetch_row($res)) ? true : false; + } + return self::$livingCache[$uid]; + } + + /** Test helper: clear per-request caches (mirrors HeroItems invalidation). */ + public static function flushCache() + { + self::$cache = array(); + self::$livingCache = array(); + } +} diff --git a/GameEngine/HeroItems.php b/GameEngine/HeroItems.php new file mode 100644 index 00000000..8a0b9264 --- /dev/null +++ b/GameEngine/HeroItems.php @@ -0,0 +1,632 @@ +db = $database->dblink; + + // Catalog is idempotent to include (guarded defines). + global $heroItemCatalog, $heroAdventureConfig, $heroSlotIndex; + if (!isset($heroItemCatalog)) { + include_once __DIR__ . '/Data/hero_items.php'; + } + } + + /* ========================================================================= + * READS + * ===================================================================== */ + + /** + * Full inventory of a user (equipped + bag), catalog-joined. + * Rows whose itemid vanished from the catalog are returned flagged with + * 'orphan' => true instead of being hidden - anomaly surfaced, not altered. + */ + public function getInventory($uid) + { + global $heroItemCatalog; + $uid = (int) $uid; + + if (isset(self::$inventoryCache[$uid])) { + return self::$inventoryCache[$uid]; + } + + $stmt = $this->db->prepare( + "SELECT id, uid, heroid, itemid, slot, stat_value, quantity, equipped, tstamp + FROM " . TB_PREFIX . "hero_items + WHERE uid = ? ORDER BY equipped DESC, slot ASC, itemid ASC" + ); + $stmt->bind_param('i', $uid); + $stmt->execute(); + $res = $stmt->get_result(); + $rows = array(); + while ($row = $res->fetch_assoc()) { + if (isset($heroItemCatalog[$row['itemid']])) { + $row['def'] = $heroItemCatalog[$row['itemid']]; + $row['name'] = heroItemName($row['itemid']); + $row['orphan'] = false; + } else { + $row['def'] = null; + $row['name'] = 'Unknown item #' . $row['itemid']; + $row['orphan'] = true; + } + $rows[] = $row; + } + $stmt->close(); + + self::$inventoryCache[$uid] = $rows; + return $rows; + } + + /** Equipped items only, indexed by slot (one item per slot). */ + public function getEquipped($uid) + { + $equipped = array(); + foreach ($this->getInventory($uid) as $row) { + if ($row['equipped'] == 1 && !$row['orphan']) { + $equipped[(int) $row['slot']] = $row; + } + } + return $equipped; + } + + /** Silver balance (0 if the user has no hero row). */ + public function getSilver($uid) + { + $uid = (int) $uid; + $stmt = $this->db->prepare("SELECT silver FROM " . TB_PREFIX . "hero WHERE uid = ? LIMIT 1"); + $stmt->bind_param('i', $uid); + $stmt->execute(); + $stmt->bind_result($silver); + $found = $stmt->fetch(); + $stmt->close(); + return $found ? (int) $silver : 0; + } + + /* ========================================================================= + * BONUS AGGREGATION + * Returns a normalized array all later phases read from: + * [ + * 'fight_strength' => int, 'damage_reduce' => int, + * 'regen_hp' => int, 'xp_percent' => int, + * 'culture_points' => int, 'train_cavalry' => int, 'train_infantry' => int, + * 'vs_natars' => int, 'raid_percent' => int, + * 'return_speed' => int, 'speed_own' => int, 'speed_ally' => int, + * 'troop_speed_20' => int, 'horse_speed' => int, 'mount_speed' => int, + * 'unit_bonus' => [ unitId => perUnitValue, ... ] + * ] + * ===================================================================== */ + public function getBonuses($uid) + { + $uid = (int) $uid; + if (isset(self::$bonusCache[$uid])) { + return self::$bonusCache[$uid]; + } + + $bonuses = array( + HB_FIGHT => 0, HB_DMG_REDUCE => 0, HB_REGEN_HP => 0, HB_XP => 0, + HB_CP => 0, HB_TRAIN_CAV => 0, HB_TRAIN_INF => 0, HB_VS_NATARS => 0, + HB_RAID => 0, HB_RETURN_SPEED => 0, HB_SPEED_OWN => 0, HB_SPEED_ALLY => 0, + HB_TROOP_SPEED_20 => 0, HB_HORSE_SPEED => 0, HB_MOUNT_SPEED => 0, + HB_UNIT_BONUS => array(), + ); + + $equipped = $this->getEquipped($uid); + $hasHorse = isset($equipped[HSLOT_HORSE]); + + foreach ($equipped as $row) { + $def = $row['def']; + + // Spurs count only while a horse is equipped. + if (!empty($def['requires_horse']) && !$hasHorse) { + continue; + } + + foreach ($def['bonus'] as $type => $value) { + if ($type === HB_UNIT_BONUS) { + $u = (int) $value['unit']; + if (!isset($bonuses[HB_UNIT_BONUS][$u])) { + $bonuses[HB_UNIT_BONUS][$u] = 0; + } + $bonuses[HB_UNIT_BONUS][$u] += (int) $value['per_unit']; + continue; + } + if (isset($bonuses[$type])) { + $bonuses[$type] += $this->scaledBonusValue($def, (int) $value); + } + } + } + + self::$bonusCache[$uid] = $bonuses; + return $bonuses; + } + + /** + * Apply the server-speed scaling rules from the design sheet: + * 'x2_on_speed' => true -> value * 2 on speed servers (spurs, horses) + * 'x2_on_speed' => false -> value / 2 on speed servers (culture helmets) + * key absent -> value unchanged + * "Speed server" = SPEED >= 3 (per the sheet's "(1x) / (3x)" notation). + */ + public function scaledBonusValue(array $def, $value) + { + if (!array_key_exists('x2_on_speed', $def)) { + return $value; + } + $isSpeed = defined('SPEED') && SPEED >= 3; + if (!$isSpeed) { + return $value; + } + return $def['x2_on_speed'] ? $value * 2 : (int) floor($value / 2); + } + + /* ========================================================================= + * WRITES + * ===================================================================== */ + + /** + * Grant an item to a user (adventure loot, auction win, admin). + * Consumables (bag slot) stack onto an existing row; gear inserts a new row. + * Returns the hero_items.id of the affected row, or 0 on failure. + */ + public function addItem($uid, $itemid, $quantity = 1, $statValue = 0) + { + global $heroItemCatalog; + $uid = (int) $uid; $itemid = (int) $itemid; + $quantity = max(1, (int) $quantity); $statValue = (int) $statValue; + + if (!isset($heroItemCatalog[$itemid])) { + return 0; + } + $def = $heroItemCatalog[$itemid]; + $slot = (int) $def['slot']; + $now = time(); + + if ($slot === HSLOT_BAG) { + // Stack consumables. + $stmt = $this->db->prepare( + "UPDATE " . TB_PREFIX . "hero_items + SET quantity = quantity + ?, tstamp = ? + WHERE uid = ? AND itemid = ? AND equipped = 0 LIMIT 1" + ); + $stmt->bind_param('iiii', $quantity, $now, $uid, $itemid); + $stmt->execute(); + $affected = $stmt->affected_rows; + $stmt->close(); + + if ($affected > 0) { + $this->invalidateCaches($uid); + $row = $this->findRow($uid, $itemid); + return $row ? (int) $row['id'] : 0; + } + } + + $equipped = 0; + $stmt = $this->db->prepare( + "INSERT INTO " . TB_PREFIX . "hero_items + (uid, heroid, itemid, slot, stat_value, quantity, equipped, tstamp) + VALUES (?, 0, ?, ?, ?, ?, ?, ?)" + ); + $stmt->bind_param('iiiiiii', $uid, $itemid, $slot, $statValue, $quantity, $equipped, $now); + $stmt->execute(); + $newId = (int) $stmt->insert_id; + $stmt->close(); + + $this->invalidateCaches($uid); + return $newId; + } + + /** + * Equip an owned item into its slot. Returns true on success. + * Rules enforced: + * - item must belong to $uid and not be a consumable + * - weapons ('unit' key) require the LIVING hero to be that exact unit + * - anything already in that slot is unequipped first (atomic enough for + * a per-user action; both statements touch only this user's rows) + */ + public function equipItem($uid, $rowId) + { + global $database; + $uid = (int) $uid; $rowId = (int) $rowId; + + $row = $this->findRowById($uid, $rowId); + if (!$row || $row['orphan'] || (int) $row['def']['slot'] === HSLOT_BAG) { + return false; + } + + // Weapon unit restriction. + if (isset($row['def']['unit'])) { + $hero = $database->getHero($uid); + $heroUnit = 0; + if (is_array($hero)) { + foreach ($hero as $h) { + if ($h['dead'] != 1) { $heroUnit = (int) $h['unit']; break; } + } + } + if ($heroUnit !== (int) $row['def']['unit']) { + return false; + } + } + + $slot = (int) $row['def']['slot']; + + // Vacate the slot. + $stmt = $this->db->prepare( + "UPDATE " . TB_PREFIX . "hero_items SET equipped = 0 WHERE uid = ? AND slot = ? AND equipped = 1" + ); + $stmt->bind_param('ii', $uid, $slot); + $stmt->execute(); + $stmt->close(); + + // Equip the requested row. + $stmt = $this->db->prepare( + "UPDATE " . TB_PREFIX . "hero_items SET equipped = 1 WHERE uid = ? AND id = ? LIMIT 1" + ); + $stmt->bind_param('ii', $uid, $rowId); + $stmt->execute(); + $ok = $stmt->affected_rows > 0; + $stmt->close(); + + $this->invalidateCaches($uid); + return $ok; + } + + /** Unequip a specific owned row. Returns true on success. */ + public function unequipItem($uid, $rowId) + { + $uid = (int) $uid; $rowId = (int) $rowId; + $stmt = $this->db->prepare( + "UPDATE " . TB_PREFIX . "hero_items SET equipped = 0 WHERE uid = ? AND id = ? AND equipped = 1 LIMIT 1" + ); + $stmt->bind_param('ii', $uid, $rowId); + $stmt->execute(); + $ok = $stmt->affected_rows > 0; + $stmt->close(); + + $this->invalidateCaches($uid); + return $ok; + } + + /** + * Remove quantity from an owned row (used by useItem, auction listing). + * Deletes the row when the stack reaches zero. Returns true on success. + */ + public function removeItem($uid, $rowId, $quantity = 1) + { + $uid = (int) $uid; $rowId = (int) $rowId; $quantity = max(1, (int) $quantity); + + // Conditional decrement - never goes below zero even under races. + $stmt = $this->db->prepare( + "UPDATE " . TB_PREFIX . "hero_items + SET quantity = quantity - ? + WHERE uid = ? AND id = ? AND quantity >= ? LIMIT 1" + ); + $stmt->bind_param('iiii', $quantity, $uid, $rowId, $quantity); + $stmt->execute(); + $ok = $stmt->affected_rows > 0; + $stmt->close(); + + if ($ok) { + $stmt = $this->db->prepare( + "DELETE FROM " . TB_PREFIX . "hero_items WHERE uid = ? AND id = ? AND quantity <= 0 LIMIT 1" + ); + $stmt->bind_param('ii', $uid, $rowId); + $stmt->execute(); + $stmt->close(); + } + + $this->invalidateCaches($uid); + return $ok; + } + + /** + * Use a consumable. Applies immediate effects (ointment, scroll, bucket, + * book of wisdom, law tablets, artwork); returns USE_DEFERRED for + * consumables whose mechanic is wired into battle processing (bandages, + * cages - Phase 6) WITHOUT consuming them. + * $vid: target village, required for law tablets (loyalty target). + * Returns one of the USE_* class constants. + */ + public function useItem($uid, $rowId, $quantity = 1, $vid = 0) + { + global $database; + $uid = (int) $uid; $rowId = (int) $rowId; $quantity = max(1, (int) $quantity); + + $row = $this->findRowById($uid, $rowId); + if (!$row || $row['orphan'] || (int) $row['def']['slot'] !== HSLOT_BAG) { + return self::USE_INVALID; + } + if ((int) $row['quantity'] < $quantity) { + return self::USE_INVALID; + } + + $bonus = $row['def']['bonus']; + + /* ---- Ointment: +1% HP per unit, cap 99, hero must be alive ---- */ + if (isset($bonus[HB_HEAL_SELF])) { + $hero = $this->getLivingHeroRow($uid); + if (!$hero) { + return self::USE_INVALID; + } + $health = (float) $hero['health']; + if ($health >= 99) { + return self::USE_INVALID; + } + // Only consume as many as actually heal (no waste past the 99 cap). + $usable = min($quantity, (int) ceil(99 - $health)); + $newHealth = min(99, $health + $usable * (int) $bonus[HB_HEAL_SELF]); + + $stmt = $this->db->prepare( + "UPDATE " . TB_PREFIX . "hero SET health = ? WHERE heroid = ? LIMIT 1" + ); + $heroid = (int) $hero['heroid']; + $stmt->bind_param('di', $newHealth, $heroid); + $stmt->execute(); + $stmt->close(); + + $this->removeItem($uid, $rowId, $usable); + return self::USE_OK; + } + + /* ---- Scroll: +10 XP each ---- */ + if (isset($bonus[HB_SCROLL])) { + $hero = $this->getLivingHeroRow($uid); + if (!$hero) { + return self::USE_INVALID; + } + $xp = (int) $bonus[HB_SCROLL] * $quantity; + $stmt = $this->db->prepare( + "UPDATE " . TB_PREFIX . "hero SET experience = experience + ? WHERE heroid = ? LIMIT 1" + ); + $heroid = (int) $hero['heroid']; + $stmt->bind_param('ii', $xp, $heroid); + $stmt->execute(); + $stmt->close(); + + $this->removeItem($uid, $rowId, $quantity); + return self::USE_OK; + } + + /* ---- Bucket of Water: instant free revive of the dead hero ---- */ + if (isset($bonus[HB_BUCKET])) { + $stmt = $this->db->prepare( + "UPDATE " . TB_PREFIX . "hero + SET dead = 0, health = 100, inrevive = 0, lastupdate = ? + WHERE uid = ? AND dead = 1 LIMIT 1" + ); + $now = time(); + $stmt->bind_param('ii', $now, $uid); + $stmt->execute(); + $revived = $stmt->affected_rows > 0; + $stmt->close(); + + if (!$revived) { + return self::USE_INVALID; // no dead hero to revive + } + $this->removeItem($uid, $rowId, 1); // one bucket per revive + return self::USE_OK; + } + + /* ---- Book of Wisdom: reset attribute points ---- */ + if (isset($bonus[HB_RESET])) { + $hero = $this->getLivingHeroRow($uid); + if (!$hero) { + return self::USE_INVALID; + } + // Refund all spent points: 5 free points per level gained (same + // convention as Automation::calculateLevelUp). Attributes return to 0. + $level = (int) $hero['level']; + $points = $level * 5; + $stmt = $this->db->prepare( + "UPDATE " . TB_PREFIX . "hero + SET points = ?, attack = 0, defence = 0, attackbonus = 0, + defencebonus = 0, regeneration = 0 + WHERE heroid = ? LIMIT 1" + ); + $heroid = (int) $hero['heroid']; + $stmt->bind_param('ii', $points, $heroid); + $stmt->execute(); + $stmt->close(); + + $this->removeItem($uid, $rowId, 1); + return self::USE_OK; + } + + /* ---- Law Tablet: +1% loyalty per tablet on an OWN village, max 125% ---- */ + if (isset($bonus[HB_LOYALTY])) { + $vid = (int) $vid; + if ($vid <= 0) { + return self::USE_INVALID; + } + // Village must belong to the user; cap enforced in SQL so + // concurrent uses can never overshoot 125. + $stmt = $this->db->prepare( + "UPDATE " . TB_PREFIX . "vdata + SET loyalty = LEAST(125, loyalty + ?) + WHERE wref = ? AND owner = ? AND loyalty < 125 LIMIT 1" + ); + $gain = (int) $bonus[HB_LOYALTY] * $quantity; + $stmt->bind_param('iii', $gain, $vid, $uid); + $stmt->execute(); + $ok = $stmt->affected_rows > 0; + $stmt->close(); + + if (!$ok) { + return self::USE_INVALID; // not owner, or already at cap + } + $this->removeItem($uid, $rowId, $quantity); + return self::USE_OK; + } + + /* ---- Artwork: culture points equal to the account's daily CP + * production, capped at 5000 (normal) / 2500 (speed >= 3). + * users.cp is the accumulator culturePoints() ticks into. ---- */ + if (isset($bonus[HB_ARTWORK])) { + $stmt = $this->db->prepare( + "SELECT COALESCE(SUM(cp), 0) FROM " . TB_PREFIX . "vdata WHERE owner = ?" + ); + $stmt->bind_param('i', $uid); + $stmt->execute(); + $stmt->bind_result($dailyCp); + $stmt->fetch(); + $stmt->close(); + + $cap = (defined('SPEED') && SPEED >= 3) ? 2500 : 5000; + $gain = min($cap, (int) $dailyCp) * $quantity; + if ($gain <= 0) { + return self::USE_INVALID; + } + + $stmt = $this->db->prepare( + "UPDATE " . TB_PREFIX . "users SET cp = cp + ? WHERE id = ? LIMIT 1" + ); + $stmt->bind_param('ii', $gain, $uid); + $stmt->execute(); + $ok = $stmt->affected_rows > 0; + $stmt->close(); + + if (!$ok) { + return self::USE_INVALID; + } + $this->removeItem($uid, $rowId, $quantity); + return self::USE_OK; + } + + /* ---- Deferred consumables (mechanic lands in Phase 6 with the UI: + * bandages consume on post-battle healing, cages on oasis + * attacks). NOT consumed here. ---- */ + if (isset($bonus[HB_HEAL_TROOP]) || isset($bonus[HB_CAGE])) { + return self::USE_DEFERRED; + } + + return self::USE_INVALID; + } + + /* ========================================================================= + * SILVER (race-safe conditional updates) + * ===================================================================== */ + + /** Add silver to the user's hero row. Returns true if a hero row exists. */ + public function addSilver($uid, $amount) + { + $uid = (int) $uid; $amount = max(0, (int) $amount); + if ($amount === 0) { + return true; + } + $stmt = $this->db->prepare( + "UPDATE " . TB_PREFIX . "hero SET silver = silver + ? WHERE uid = ? LIMIT 1" + ); + $stmt->bind_param('ii', $amount, $uid); + $stmt->execute(); + $ok = $stmt->affected_rows > 0; + $stmt->close(); + return $ok; + } + + /** + * Spend silver. The WHERE silver >= ? guard makes this race-safe: + * two concurrent spends can never take the balance negative. + */ + public function spendSilver($uid, $amount) + { + $uid = (int) $uid; $amount = max(0, (int) $amount); + if ($amount === 0) { + return true; + } + $stmt = $this->db->prepare( + "UPDATE " . TB_PREFIX . "hero + SET silver = silver - ? + WHERE uid = ? AND silver >= ? LIMIT 1" + ); + $stmt->bind_param('iii', $amount, $uid, $amount); + $stmt->execute(); + $ok = $stmt->affected_rows > 0; + $stmt->close(); + return $ok; + } + + /* ========================================================================= + * INTERNALS + * ===================================================================== */ + + private function invalidateCaches($uid) + { + unset(self::$inventoryCache[(int) $uid], self::$bonusCache[(int) $uid]); + } + + private function findRow($uid, $itemid) + { + foreach ($this->getInventory($uid) as $row) { + if ((int) $row['itemid'] === (int) $itemid && $row['equipped'] == 0) { + return $row; + } + } + return null; + } + + private function findRowById($uid, $rowId) + { + foreach ($this->getInventory($uid) as $row) { + if ((int) $row['id'] === (int) $rowId) { + return $row; + } + } + return null; + } + + /** Fetch the raw LIVING hero row directly (health/level/points included). */ + private function getLivingHeroRow($uid) + { + $uid = (int) $uid; + $stmt = $this->db->prepare( + "SELECT heroid, uid, unit, level, points, experience, health, dead + FROM " . TB_PREFIX . "hero WHERE uid = ? AND dead = 0 LIMIT 1" + ); + $stmt->bind_param('i', $uid); + $stmt->execute(); + $res = $stmt->get_result(); + $row = $res->fetch_assoc(); + $stmt->close(); + return $row ?: null; + } +} diff --git a/GameEngine/Lang/en.php b/GameEngine/Lang/en.php index 93489cb8..e434baf6 100755 --- a/GameEngine/Lang/en.php +++ b/GameEngine/Lang/en.php @@ -2678,7 +2678,114 @@ if (!function_exists('tz_loc_topic')) { 'Unoccupied Oasis' => TZ_RT_UNOCC_OASIS, 'New village founded' => TZ_RT_NEW_VILLAGE, 'Settlers returned - valley occupied' => TZ_RT_VALLEY_OCCUPIED, + // T4 hero port (Phase 6) + 'Hero returned from an adventure' => TZ_RT_ADV_RETURNED, + 'Hero fell on an adventure' => TZ_RT_ADV_FELL, + 'Auction won' => TZ_RT_AUC_WON, + 'Auction sold' => TZ_RT_AUC_SOLD, + 'Auction expired' => TZ_RT_AUC_EXPIRED, ); return strtr($s, $map); } } + +/* ============================================================================= + * T4 HERO PORT (Phase 6) - items / adventures / auction house + * ========================================================================== */ +tz_def('HERO_T4_TAB_HERO', 'Hero'); +tz_def('HERO_T4_TAB_ITEMS', 'Inventory'); +tz_def('HERO_T4_TAB_ADVENTURES', 'Adventures'); +tz_def('HERO_T4_TAB_AUCTION', 'Auctions'); + +tz_def('HERO_SILVER', 'Silver'); +tz_def('HERO_EXPERIENCE', 'Experience'); +tz_def('RESOURCES', 'Resources'); + +/* Equipment slots */ +tz_def('HERO_SLOT_1', 'Helmet'); +tz_def('HERO_SLOT_2', 'Body'); +tz_def('HERO_SLOT_3', 'Right hand'); +tz_def('HERO_SLOT_4', 'Left hand'); +tz_def('HERO_SLOT_5', 'Shoes'); +tz_def('HERO_SLOT_6', 'Horse'); +tz_def('HERO_SLOT_7', 'Bag'); + +/* Inventory actions */ +tz_def('HERO_ITEMS_EQUIPPED', 'Equipped'); +tz_def('HERO_ITEMS_BAG', 'Inventory'); +tz_def('HERO_ITEMS_EMPTY', 'Your hero owns no items yet. Adventures and the auction house are good places to find some.'); +tz_def('HERO_EQUIP', 'Equip'); +tz_def('HERO_UNEQUIP', 'Take off'); +tz_def('HERO_USE_ITEM', 'Use'); +tz_def('HERO_QUANTITY', 'Amount'); +tz_def('HERO_ITEM_USED_OK', 'The item has been used.'); +tz_def('HERO_ITEM_USE_FAIL', 'This item cannot be used right now.'); +tz_def('HERO_ITEM_USE_BATTLE', 'This item is used automatically (bandages heal returning troops after battles).'); +tz_def('HERO_EQUIP_OK', 'Item equipped.'); +tz_def('HERO_EQUIP_FAIL', 'This item cannot be equipped (wrong hero unit or item type).'); +tz_def('HERO_UNEQUIP_OK', 'Item taken off.'); + +/* Adventures */ +tz_def('HERO_ADV_NORMAL', 'a normal adventure'); +tz_def('HERO_ADV_HARD', 'a hard adventure'); +tz_def('HERO_ADV_LIST', 'Available adventures'); +tz_def('HERO_ADV_NONE', 'No adventures available right now. New ones appear over time.'); +tz_def('HERO_ADV_DIFFICULTY', 'Difficulty'); +tz_def('HERO_ADV_DIFF_NORMAL', 'Normal'); +tz_def('HERO_ADV_DIFF_HARD', 'Hard'); +tz_def('HERO_ADV_DURATION', 'Travel time (one way)'); +tz_def('HERO_ADV_EXPIRES', 'Expires in'); +tz_def('HERO_ADV_GO', 'Start adventure'); +tz_def('HERO_ADV_RUNNING', 'Your hero is on an adventure and will arrive in'); +tz_def('HERO_ADV_START_OK', 'Your hero has set off on the adventure.'); +tz_def('HERO_ADV_START_NOHERO', 'You need a living hero to start an adventure.'); +tz_def('HERO_ADV_START_AWAY', 'Your hero is not at home.'); +tz_def('HERO_ADV_START_FAIL', 'This adventure is no longer available.'); +tz_def('HERO_ADV_RETURNED', 'Your hero has returned from'); +tz_def('HERO_ADV_REWARD', 'Reward'); +tz_def('HERO_ADV_AMOUNT', 'Amount'); +tz_def('HERO_ADV_ITEM_FOUND', 'Item found'); +tz_def('HERO_ADV_HP_LOST', 'Health lost'); +tz_def('HERO_ADV_DIED', 'Your hero fell on'); +tz_def('HERO_ADV_DIED_INFO', "All loot was lost. The hero can be revived at the Hero's Mansion."); + +/* Auction house */ +tz_def('HERO_AUC_OPEN', 'Open auctions'); +tz_def('HERO_AUC_NONE', 'There are no open auctions at the moment.'); +tz_def('HERO_AUC_ITEM', 'Item'); +tz_def('HERO_AUC_PRICE', 'Current price'); +tz_def('HERO_AUC_FINAL_PRICE', 'Final price'); +tz_def('HERO_AUC_TIME_LEFT', 'Ends in'); +tz_def('HERO_AUC_YOUR_MAX', 'Your maximum'); +tz_def('HERO_AUC_BID', 'Bid'); +tz_def('HERO_AUC_BID_OK', 'Your bid was placed. You are the highest bidder.'); +tz_def('HERO_AUC_BID_OUTBID', 'Your bid was immediately outbid by a higher maximum.'); +tz_def('HERO_AUC_BID_FAIL', 'Your bid was not accepted.'); +tz_def('HERO_AUC_BID_NOSILVER', 'You do not have enough silver for this bid.'); +tz_def('HERO_AUC_MY_BIDS', 'My bids'); +tz_def('HERO_AUC_MY_SALES', 'My sales'); +tz_def('HERO_AUC_SELL', 'Sell an item'); +tz_def('HERO_AUC_SELL_OK', 'Your item has been listed.'); +tz_def('HERO_AUC_SELL_FAIL', 'This item cannot be listed (equipped items must be taken off first).'); +tz_def('HERO_AUC_START_PRICE', 'Starting price'); +tz_def('HERO_AUC_DURATION', 'Duration'); +tz_def('HERO_AUC_LIST', 'List item'); +tz_def('HERO_AUC_SELLER_NPC', 'Merchant'); +tz_def('HERO_AUC_WON', 'You won the auction for'); +tz_def('HERO_AUC_SOLD', 'Your auction sold:'); +tz_def('HERO_AUC_REFUND', 'Refunded from your maximum bid'); +tz_def('HERO_AUC_FEE', 'Auction fee'); +tz_def('HERO_AUC_PAYOUT', 'Payout'); +tz_def('HERO_AUC_EXPIRED', 'Your auction ended without bids. The item was returned to your inventory:'); + +/* Report topics (raw English strings live in ndata.topic) */ +tz_def('TZ_RT_ADV_RETURNED', 'Hero returned from an adventure'); +tz_def('TZ_RT_ADV_FELL', 'Hero fell on an adventure'); +tz_def('TZ_RT_AUC_WON', 'Auction won'); +tz_def('TZ_RT_AUC_SOLD', 'Auction sold'); +tz_def('TZ_RT_AUC_EXPIRED', 'Auction expired'); + +/* T4 hero port - movement display (dorf1 + rally point) */ +tz_def('HERO_ADV_MOV_OUT', 'Hero on an adventure'); +tz_def('HERO_ADV_MOV_BACK', 'Hero returning from an adventure'); +tz_def('HERO_ADV_MOV_SHORT', 'Adventure'); diff --git a/GameEngine/Message.php b/GameEngine/Message.php index bad34158..8bfcd276 100755 --- a/GameEngine/Message.php +++ b/GameEngine/Message.php @@ -452,6 +452,14 @@ class Message case 24: case 25: return 24; + + // Hero adventure report (T4 hero port) + case 26: + return 26; + + // Hero auction report (T4 hero port) + case 27: + return 27; } return $type; } diff --git a/GameEngine/Units.php b/GameEngine/Units.php index 84402413..1ef20ea0 100755 --- a/GameEngine/Units.php +++ b/GameEngine/Units.php @@ -796,7 +796,12 @@ class Units { $speeds[] = $GLOBALS['u'.$heroUnit]['speed']; } - return $generator->procDistanceTime($fromCor, $toCor, min($speeds), $mode, $from); + $walkTime = $generator->procDistanceTime($fromCor, $toCor, min($speeds), $mode, $from); + + // T4 hero port (Phase 5): boots (>20 tiles), pennant (own villages) + // and standard (ally villages) shorten the trip when the hero rides + // along. Returns the input unchanged when NEW_FUNCTIONS_HERO_T4 is off. + return HeroBattleBonus::adjustTravelTime($owner, $unitArray[10] ?? 0, $from, $to, $walkTime); } public function startRaidList($post){ diff --git a/Templates/Build/16.tpl b/Templates/Build/16.tpl index cef6f5e3..a421fb48 100644 --- a/Templates/Build/16.tpl +++ b/Templates/Build/16.tpl @@ -207,6 +207,11 @@ $hasRally = $village->resarray['f39'] > 0; $set = $database->getMovement(5,$village->wid,0); $cnt = count($set); foreach($out as $u) if($u['vref']==$village->wid) $cnt++; + // T4 hero port: adventure legs count as troops on their way too. + if (defined('NEW_FUNCTIONS_HERO_T4') && NEW_FUNCTIONS_HERO_T4) { + $cnt += count($database->getMovement(20,$village->wid,0)); + $cnt += count($database->getMovement(21,$village->wid,1)); + } ?> =1): ?>

diff --git a/Templates/Build/16_walking.tpl b/Templates/Build/16_walking.tpl index 1082fed1..7f5d366b 100644 --- a/Templates/Build/16_walking.tpl +++ b/Templates/Build/16_walking.tpl @@ -20,6 +20,41 @@ ################################################################################# $outgoing = $database->getMovement(3, $village->wid, 0); + +// T4 hero port: hero adventure legs (out = from this village, back = to it). +$advWalking = []; +if (defined('NEW_FUNCTIONS_HERO_T4') && NEW_FUNCTIONS_HERO_T4) { + foreach ($database->getMovement(20, $village->wid, 0) as $m) { $m['t4adv'] = 'out'; $advWalking[] = $m; } + foreach ($database->getMovement(21, $village->wid, 1) as $m) { $m['t4adv'] = 'back'; $advWalking[] = $m; } +} +?> + +timer++; + $action = $m['t4adv'] === 'out' + ? (defined('HERO_ADV_MOV_OUT') ? HERO_ADV_MOV_OUT : 'Hero on an adventure') + : (defined('HERO_ADV_MOV_BACK') ? HERO_ADV_MOV_BACK : 'Hero returning from an adventure'); + $dt = $generator->procMtime($m['endtime']); +?> + + + + + + + + + + + + + +
vname?>
  
1 
+
getTimeFormat($m['endtime']-time())?> h
+
+
+ + resarray['f' . $id] >= 1) { include_once("37_land.tpl"); + } else if (defined('NEW_FUNCTIONS_HERO_T4') && NEW_FUNCTIONS_HERO_T4 + && $village->resarray['f' . $id] >= 1 + && isset($_GET['t4tab']) + && in_array($_GET['t4tab'], ['items', 'adventures', 'auction'], true)) { + + // T4 hero port (Phase 6): items / adventures / auction tabs. + // The classic hero flow below stays byte-identical when the + // feature flag is off or no tab is selected. + $t4tab = $_GET['t4tab']; + include_once("37_t4nav.tpl"); + include_once("37_" . $t4tab . ".tpl"); + } else if ($village->resarray['f' . $id] >= 1) { + if (defined('NEW_FUNCTIONS_HERO_T4') && NEW_FUNCTIONS_HERO_T4) { + $t4tab = 'hero'; + include_once("37_t4nav.tpl"); + } $include_training = true; $include_revive = false; if (isset($heroes) && is_array($heroes) && count($heroes)) { diff --git a/Templates/Build/37_adventures.tpl b/Templates/Build/37_adventures.tpl new file mode 100644 index 00000000..a68130db --- /dev/null +++ b/Templates/Build/37_adventures.tpl @@ -0,0 +1,78 @@ +timer JS convention (same as 37.tpl's training timer). # +################################################################################# + +$t4Adventures = new HeroAdventure(); +$t4Msg = ''; + +if (isset($_POST['t4action'], $_POST['advid']) && $_POST['t4action'] === 'startadv') { + $t4Result = $t4Adventures->startAdventure($session->uid, (int) $_POST['advid']); + if ($t4Result === HeroAdventure::START_OK) { + $t4Msg = HERO_ADV_START_OK; + } elseif ($t4Result === HeroAdventure::START_NO_HERO) { + $t4Msg = HERO_ADV_START_NOHERO; + } elseif ($t4Result === HeroAdventure::START_HERO_AWAY) { + $t4Msg = HERO_ADV_START_AWAY; + } else { + $t4Msg = HERO_ADV_START_FAIL; + } +} + +// Top up the list opportunistically (respects max/refresh limits internally). +$t4Adventures->generateOffers($session->uid); + +$t4Offers = $t4Adventures->getOffers($session->uid); +$t4Running = $t4Adventures->getRunning($session->uid); +$t4Now = time(); +?> + + +

+ + + + + + + +
+ getTimeFormat(max(0, $t4Running['endtime'] - $t4Now)); ?> +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
getTimeFormat((int) $t4Offer['duration']); ?>getTimeFormat(max(0, $t4Offer['expire'] - $t4Now)); ?> + +
+ + + +
+ - +
diff --git a/Templates/Build/37_auction.tpl b/Templates/Build/37_auction.tpl new file mode 100644 index 00000000..8a7f2071 --- /dev/null +++ b/Templates/Build/37_auction.tpl @@ -0,0 +1,160 @@ +placeBid($session->uid, (int) $_POST['aucid'], (int) $_POST['maxbid']); + if ($t4Result === HeroAuction::BID_OK) { + $t4Msg = HERO_AUC_BID_OK; + } elseif ($t4Result === HeroAuction::BID_OUTBID) { + $t4Msg = HERO_AUC_BID_OUTBID; + } elseif ($t4Result === HeroAuction::BID_NO_SILVER) { + $t4Msg = HERO_AUC_BID_NOSILVER; + } else { + $t4Msg = HERO_AUC_BID_FAIL; + } + } elseif ($_POST['t4action'] === 'sell' + && isset($_POST['rowid'], $_POST['qty'], $_POST['price'], $_POST['duration'])) { + $t4Result = $t4Auction->createAuction( + $session->uid, (int) $_POST['rowid'], + (int) $_POST['qty'], (int) $_POST['price'], (int) $_POST['duration'] + ); + $t4Msg = ($t4Result > 0) ? HERO_AUC_SELL_OK : HERO_AUC_SELL_FAIL; + } +} + +$t4Open = $t4Auction->getOpenAuctions(); +$t4MyBids = $t4Auction->getMyBids($session->uid); +$t4MySales = $t4Auction->getMySales($session->uid); +$t4Now = time(); + +// Unequipped items sellable from the inventory. +$t4Sellable = array(); +foreach ($t4HeroItems->getInventory($session->uid) as $t4Row) { + if ($t4Row['equipped'] == 0 && !$t4Row['orphan']) { + $t4Sellable[] = $t4Row; + } +} +?> + + +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ () + getTimeFormat(max(0, $t4A['time_end'] - $t4Now)); ?> + uid) { ?> +
+ + + + +
+ - +
+ + + + + + + + + + + + + + + + +
(x): : getTimeFormat(max(0, $t4A['time_end'] - $t4Now)); ?>
+ + + + + + + + + + + + + + + + +
(x): getTimeFormat(max(0, $t4A['time_end'] - $t4Now)); ?>
+ + + + + + + + + + + + + + + + + + + +
+ + : : : + +
+ diff --git a/Templates/Build/37_items.tpl b/Templates/Build/37_items.tpl new file mode 100644 index 00000000..f55b3326 --- /dev/null +++ b/Templates/Build/37_items.tpl @@ -0,0 +1,112 @@ +wid). # +################################################################################# + +$t4Msg = ''; + +if (isset($_POST['t4action'], $_POST['rowid'])) { + $t4RowId = (int) $_POST['rowid']; + + switch ($_POST['t4action']) { + case 'equip': + $t4Msg = $t4HeroItems->equipItem($session->uid, $t4RowId) + ? HERO_EQUIP_OK : HERO_EQUIP_FAIL; + break; + + case 'unequip': + $t4Msg = $t4HeroItems->unequipItem($session->uid, $t4RowId) + ? HERO_UNEQUIP_OK : HERO_ITEM_USE_FAIL; + break; + + case 'useitem': + $t4Qty = max(1, (int) ($_POST['qty'] ?? 1)); + $t4Result = $t4HeroItems->useItem($session->uid, $t4RowId, $t4Qty, $village->wid); + if ($t4Result === HeroItems::USE_OK) { + $t4Msg = HERO_ITEM_USED_OK; + } elseif ($t4Result === HeroItems::USE_DEFERRED) { + $t4Msg = HERO_ITEM_USE_BATTLE; + } else { + $t4Msg = HERO_ITEM_USE_FAIL; + } + break; + } +} + +$t4Inventory = $t4HeroItems->getInventory($session->uid); +$t4Equipped = $t4HeroItems->getEquipped($session->uid); +?> + + +

+ + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+
-
+ + + + + + + + + + + + + + + + + + +
+ + +
+ + + + + + + + + +
+
diff --git a/Templates/Build/37_t4nav.tpl b/Templates/Build/37_t4nav.tpl new file mode 100644 index 00000000..136c068c --- /dev/null +++ b/Templates/Build/37_t4nav.tpl @@ -0,0 +1,28 @@ +getSilver($session->uid); +$t4Tabs = [ + 'hero' => ['label' => HERO_T4_TAB_HERO, 'url' => 'build.php?id=' . $id], + 'items' => ['label' => HERO_T4_TAB_ITEMS, 'url' => 'build.php?id=' . $id . '&t4tab=items'], + 'adventures' => ['label' => HERO_T4_TAB_ADVENTURES, 'url' => 'build.php?id=' . $id . '&t4tab=adventures'], + 'auction' => ['label' => HERO_T4_TAB_AUCTION, 'url' => 'build.php?id=' . $id . '&t4tab=auction'], +]; +?> + +
+ $tab) { ?> + + + + + + + : +
+
diff --git a/Templates/Notice/26.tpl b/Templates/Notice/26.tpl new file mode 100644 index 00000000..b38f80f0 --- /dev/null +++ b/Templates/Notice/26.tpl @@ -0,0 +1,120 @@ +readingNotice['data'] ?? ''), $adv); + +$difficulty = (int)($adv['difficulty'] ?? 0); +$died = (int)($adv['died'] ?? 0); +$exp = (int)($adv['exp'] ?? 0); +$silver = (int)($adv['silver'] ?? 0); +$hp = (int)($adv['hp'] ?? 0); +$wood = (int)($adv['wood'] ?? 0); +$clay = (int)($adv['clay'] ?? 0); +$iron = (int)($adv['iron'] ?? 0); +$crop = (int)($adv['crop'] ?? 0); +$itemId = (int)($adv['itemid'] ?? 0); +$itemQty = (int)($adv['itemqty'] ?? 0); + +// Resolve the item name from the catalog (heroItemName falls back safely). +// berichte.php runs from the web root, so the relative path is stable. +if ($itemId > 0 && !function_exists('heroItemName')) { + include_once 'GameEngine/Data/hero_items.php'; +} + +$diffLabel = $difficulty === 1 + ? tz_hero_adv('HERO_ADV_HARD', 'a hard adventure') + : tz_hero_adv('HERO_ADV_NORMAL', 'a normal adventure'); +?> + + + + + + + + + + + procMtime($message->readingNotice['time']); ?> + + + + + + + + + + +
:readingNotice['topic']); ?>
+ + + +

.

+

+ + + +

.

+ + + + + + + + + + + 0) { ?> + + + + + + + + + + + + 0 && $itemQty > 0) { ?> + + + + + + 0) { ?> + + + + + +
+
+
+ + + + +
×
-%
+ + + +
diff --git a/Templates/Notice/27.tpl b/Templates/Notice/27.tpl new file mode 100644 index 00000000..e0ded769 --- /dev/null +++ b/Templates/Notice/27.tpl @@ -0,0 +1,95 @@ +readingNotice['data'] ?? ''), $auc); + +$role = (string)($auc['role'] ?? ''); +$expired = (int)($auc['expired'] ?? 0); +$itemId = (int)($auc['itemid'] ?? 0); +$qty = (int)($auc['qty'] ?? 0); +$price = (int)($auc['price'] ?? 0); +$refund = (int)($auc['refund'] ?? 0); +$fee = (int)($auc['fee'] ?? 0); + +if ($itemId > 0 && !function_exists('heroItemName')) { + include_once 'GameEngine/Data/hero_items.php'; +} +$itemLabel = $qty . '× ' . ($itemId > 0 ? heroItemName($itemId) : '?'); +?> + + + + + + + + + + + procMtime($message->readingNotice['time']); ?> + + + + + + + + + + +
:readingNotice['topic']); ?>
+ + + +

.

+ + + + + + 0) { ?> + + + + + +
+
+ + + +

.

+ + + + + + + + + + + + + +
-
+
+ + + +

.

+ + + +
diff --git a/Templates/movement.tpl b/Templates/movement.tpl index d675044a..415244a0 100644 --- a/Templates/movement.tpl +++ b/Templates/movement.tpl @@ -112,6 +112,12 @@ $movement3_0 = $database->getMovement(3, $village->wid, 0); $movement5_0 = $database->getMovement(5, $village->wid, 0); $movement8_1 = $database->getMovement(8, $village->wid, 1); $movement9_0 = $database->getMovement(9, $village->wid, 0); +// T4 hero port: hero adventure movements are only relevant for the hero's +// home village and only when the feature is on. +$movement20_0 = (defined('NEW_FUNCTIONS_HERO_T4') && NEW_FUNCTIONS_HERO_T4) + ? $database->getMovement(20, $village->wid, 0) : []; +$movement21_1 = (defined('NEW_FUNCTIONS_HERO_T4') && NEW_FUNCTIONS_HERO_T4) + ? $database->getMovement(21, $village->wid, 1) : []; /** * --------------------------------------------------------- @@ -285,6 +291,42 @@ renderMovementRow( $session ); +/** + * ========================================================= + * HERO ADVENTURE (T4 hero port): outbound + returning + * ========================================================= + */ + +$advOutArrival = array(); +foreach ($movement20_0 as $receive) { + $advOutArrival[] = $receive['endtime']; +} +renderMovementRow( + 'att2', + 'a2', + defined('HERO_ADV_MOV_OUT') ? HERO_ADV_MOV_OUT : 'Hero on an adventure', + defined('HERO_ADV_MOV_SHORT') ? HERO_ADV_MOV_SHORT : 'Adventure', + count($movement20_0), + !empty($advOutArrival) ? min($advOutArrival) : 0, + $generator, + $session +); + +$advBackArrival = array(); +foreach ($movement21_1 as $receive) { + $advBackArrival[] = $receive['endtime']; +} +renderMovementRow( + 'def1', + 'd1', + defined('HERO_ADV_MOV_BACK') ? HERO_ADV_MOV_BACK : 'Hero returning from an adventure', + defined('HERO_ADV_MOV_SHORT') ? HERO_ADV_MOV_SHORT : 'Adventure', + count($movement21_1), + !empty($advBackArrival) ? min($advBackArrival) : 0, + $generator, + $session +); + /** * ========================================================= * FOUNDING NEW VILLAGE diff --git a/activate.php b/activate.php index 5ed4b35a..ae376d90 100644 --- a/activate.php +++ b/activate.php @@ -3,15 +3,21 @@ ################################################################################# ## -= YOU MAY NOT REMOVE OR CHANGE THIS NOTICE =- ## ## --------------------------------------------------------------------------- ## -## Project: TravianZ ## -## Filename activate.php ## -## Developed by: Dixie ## -## Developed by: Dzoki ## -## License: TravianZ Project ## -## Copyright: TravianZ (c) 2010-2026. All rights reserved. ## -## URLs: http://travian.shadowss.ro ## -## Source code: https://github.com/Shadowss/TravianZ ## -## ## +## Filename : activate.php ## +## Type : In Game Activate File ## +## --------------------------------------------------------------------------- ## +## Developed by : Dzoki & Advocaite & Donnchadh ## +## Refactored by : Shadow ## +## Redesign by : Shadow ## +## --------------------------------------------------------------------------- ## +## Contact : cata7007@gmail.com ## +## Project : TravianZ ## +## URLs: : https://travianz.org ## +## GitHub : https://github.com/Shadowss/TravianZ ## +## --------------------------------------------------------------------------- ## +## License : TravianZ Project ## +## Copyright : TravianZ (c) 2010-2026. All rights reserved. ## +## --------------------------------------------------------------------------- ## ################################################################################# use App\Utils\AccessLogger; diff --git a/ajax.php b/ajax.php index a0e38230..c1543abf 100644 --- a/ajax.php +++ b/ajax.php @@ -1,14 +1,23 @@ - <?php echo SERVER_NAME ?> - Mass Message + <?php echo SERVER_NAME ?> - Build Croppers diff --git a/css/hero_items.css b/css/hero_items.css new file mode 100644 index 00000000..7c5b6ca5 --- /dev/null +++ b/css/hero_items.css @@ -0,0 +1,116 @@ +/* AUTO-GENERATED from GameEngine/Data/hero_items.php - regenerate via var/tools/generate_hero_sprites.php + Placeholder art: replace img/hero/items.png with real 48x48 sprites on the SAME grid + (16 columns, catalog order) and this CSS keeps working unchanged. */ +.heroT4Item{display:inline-block;width:48px;height:48px;vertical-align:middle; + background-image:url('../img/hero/items.png');background-repeat:no-repeat;} +.heroT4Item.item1{background-position:-0px -0px;} /* Helmet of Awareness */ +.heroT4Item.item2{background-position:-48px -0px;} /* Helmet of Enlightenment */ +.heroT4Item.item3{background-position:-96px -0px;} /* Helmet of Wisdom */ +.heroT4Item.item4{background-position:-144px -0px;} /* Helmet of Regeneration */ +.heroT4Item.item5{background-position:-192px -0px;} /* Helmet of Health */ +.heroT4Item.item6{background-position:-240px -0px;} /* Helmet of Healing */ +.heroT4Item.item7{background-position:-288px -0px;} /* Helmet of the Gladiator */ +.heroT4Item.item8{background-position:-336px -0px;} /* Helmet of the Tribune */ +.heroT4Item.item9{background-position:-384px -0px;} /* Helmet of the Consul */ +.heroT4Item.item10{background-position:-432px -0px;} /* Helmet of the Horseman */ +.heroT4Item.item11{background-position:-480px -0px;} /* Helmet of the Cavalry */ +.heroT4Item.item12{background-position:-528px -0px;} /* Helmet of the Heavy Cavalry */ +.heroT4Item.item13{background-position:-576px -0px;} /* Helmet of the Mercenary */ +.heroT4Item.item14{background-position:-624px -0px;} /* Helmet of the Warrior */ +.heroT4Item.item15{background-position:-672px -0px;} /* Helmet of the Ruler */ +.heroT4Item.item20{background-position:-720px -0px;} /* Light Breastplate of Regeneration */ +.heroT4Item.item21{background-position:-0px -48px;} /* Breastplate of Regeneration */ +.heroT4Item.item22{background-position:-48px -48px;} /* Heavy Breastplate of Regeneration */ +.heroT4Item.item23{background-position:-96px -48px;} /* Light Armor */ +.heroT4Item.item24{background-position:-144px -48px;} /* Armor */ +.heroT4Item.item25{background-position:-192px -48px;} /* Heavy Armor */ +.heroT4Item.item26{background-position:-240px -48px;} /* Light Cuirass */ +.heroT4Item.item27{background-position:-288px -48px;} /* Cuirass */ +.heroT4Item.item28{background-position:-336px -48px;} /* Heavy Cuirass */ +.heroT4Item.item29{background-position:-384px -48px;} /* Light Segmented Armor */ +.heroT4Item.item30{background-position:-432px -48px;} /* Segmented Armor */ +.heroT4Item.item31{background-position:-480px -48px;} /* Heavy Segmented Armor */ +.heroT4Item.item40{background-position:-528px -48px;} /* Small Shield */ +.heroT4Item.item41{background-position:-576px -48px;} /* Shield */ +.heroT4Item.item42{background-position:-624px -48px;} /* Great Shield */ +.heroT4Item.item43{background-position:-672px -48px;} /* Small Hunting Horn */ +.heroT4Item.item44{background-position:-720px -48px;} /* Hunting Horn */ +.heroT4Item.item45{background-position:-0px -96px;} /* Great Hunting Horn */ +.heroT4Item.item46{background-position:-48px -96px;} /* Thief's Satchel */ +.heroT4Item.item47{background-position:-96px -96px;} /* Thief's Bag */ +.heroT4Item.item48{background-position:-144px -96px;} /* Thief's Sack */ +.heroT4Item.item49{background-position:-192px -96px;} /* Small Map */ +.heroT4Item.item50{background-position:-240px -96px;} /* Map */ +.heroT4Item.item51{background-position:-288px -96px;} /* Great Map */ +.heroT4Item.item52{background-position:-336px -96px;} /* Small Pennant */ +.heroT4Item.item53{background-position:-384px -96px;} /* Pennant */ +.heroT4Item.item54{background-position:-432px -96px;} /* Great Pennant */ +.heroT4Item.item55{background-position:-480px -96px;} /* Small Standard */ +.heroT4Item.item56{background-position:-528px -96px;} /* Standard */ +.heroT4Item.item57{background-position:-576px -96px;} /* Great Standard */ +.heroT4Item.item60{background-position:-624px -96px;} /* Boots of Regeneration */ +.heroT4Item.item61{background-position:-672px -96px;} /* Boots of Recovery */ +.heroT4Item.item62{background-position:-720px -96px;} /* Boots of Healing */ +.heroT4Item.item63{background-position:-0px -144px;} /* Boots of the Mercenary */ +.heroT4Item.item64{background-position:-48px -144px;} /* Boots of the Warrior */ +.heroT4Item.item65{background-position:-96px -144px;} /* Boots of the Ruler */ +.heroT4Item.item66{background-position:-144px -144px;} /* Small Spurs */ +.heroT4Item.item67{background-position:-192px -144px;} /* Spurs */ +.heroT4Item.item68{background-position:-240px -144px;} /* Great Spurs */ +.heroT4Item.item70{background-position:-288px -144px;} /* Riding Horse */ +.heroT4Item.item71{background-position:-336px -144px;} /* Thoroughbred */ +.heroT4Item.item72{background-position:-384px -144px;} /* Warhorse */ +.heroT4Item.item101{background-position:-432px -144px;} /* Legionnaire's Short Sword */ +.heroT4Item.item102{background-position:-480px -144px;} /* Legionnaire's Sword */ +.heroT4Item.item103{background-position:-528px -144px;} /* Legionnaire's Long Sword */ +.heroT4Item.item104{background-position:-576px -144px;} /* Praetorian's Short Sword */ +.heroT4Item.item105{background-position:-624px -144px;} /* Praetorian's Sword */ +.heroT4Item.item106{background-position:-672px -144px;} /* Praetorian's Long Sword */ +.heroT4Item.item107{background-position:-720px -144px;} /* Imperian's Short Sword */ +.heroT4Item.item108{background-position:-0px -192px;} /* Imperian's Sword */ +.heroT4Item.item109{background-position:-48px -192px;} /* Imperian's Long Sword */ +.heroT4Item.item110{background-position:-96px -192px;} /* Imperatoris' Short Saber */ +.heroT4Item.item111{background-position:-144px -192px;} /* Imperatoris' Saber */ +.heroT4Item.item112{background-position:-192px -192px;} /* Imperatoris' Long Saber */ +.heroT4Item.item113{background-position:-240px -192px;} /* Caesaris' Light Lance */ +.heroT4Item.item114{background-position:-288px -192px;} /* Caesaris' Lance */ +.heroT4Item.item115{background-position:-336px -192px;} /* Caesaris' Heavy Lance */ +.heroT4Item.item116{background-position:-384px -192px;} /* Clubswinger's Cudgel */ +.heroT4Item.item117{background-position:-432px -192px;} /* Clubswinger's Club */ +.heroT4Item.item118{background-position:-480px -192px;} /* Clubswinger's Mace */ +.heroT4Item.item119{background-position:-528px -192px;} /* Spearman's Pitchfork */ +.heroT4Item.item120{background-position:-576px -192px;} /* Spearman's Pike */ +.heroT4Item.item121{background-position:-624px -192px;} /* Spearman's Spear */ +.heroT4Item.item122{background-position:-672px -192px;} /* Axeman's Hatchet */ +.heroT4Item.item123{background-position:-720px -192px;} /* Axeman's Axe */ +.heroT4Item.item124{background-position:-0px -240px;} /* Axeman's Battle Axe */ +.heroT4Item.item125{background-position:-48px -240px;} /* Paladin's Small Hammer */ +.heroT4Item.item126{background-position:-96px -240px;} /* Paladin's Hammer */ +.heroT4Item.item127{background-position:-144px -240px;} /* Paladin's Sledgehammer */ +.heroT4Item.item128{background-position:-192px -240px;} /* Teutonic Knight's Short Sword */ +.heroT4Item.item129{background-position:-240px -240px;} /* Teutonic Knight's Sword */ +.heroT4Item.item130{background-position:-288px -240px;} /* Teutonic Knight's Long Sword */ +.heroT4Item.item131{background-position:-336px -240px;} /* Phalanx's Pitchfork */ +.heroT4Item.item132{background-position:-384px -240px;} /* Phalanx's Spear */ +.heroT4Item.item133{background-position:-432px -240px;} /* Phalanx's Lance */ +.heroT4Item.item134{background-position:-480px -240px;} /* Swordsman's Short Sword */ +.heroT4Item.item135{background-position:-528px -240px;} /* Swordsman's Sword */ +.heroT4Item.item136{background-position:-576px -240px;} /* Swordsman's Long Sword */ +.heroT4Item.item137{background-position:-624px -240px;} /* Thunder's Short Bow */ +.heroT4Item.item138{background-position:-672px -240px;} /* Thunder's Bow */ +.heroT4Item.item139{background-position:-720px -240px;} /* Thunder's Long Bow */ +.heroT4Item.item140{background-position:-0px -288px;} /* Druidrider's Baton */ +.heroT4Item.item141{background-position:-48px -288px;} /* Druidrider's Staff */ +.heroT4Item.item142{background-position:-96px -288px;} /* Druidrider's Great Staff */ +.heroT4Item.item143{background-position:-144px -288px;} /* Haeduan's Light Lance */ +.heroT4Item.item144{background-position:-192px -288px;} /* Haeduan's Lance */ +.heroT4Item.item145{background-position:-240px -288px;} /* Haeduan's Heavy Lance */ +.heroT4Item.item200{background-position:-288px -288px;} /* Ointment */ +.heroT4Item.item201{background-position:-336px -288px;} /* Scroll */ +.heroT4Item.item202{background-position:-384px -288px;} /* Bucket of Water */ +.heroT4Item.item203{background-position:-432px -288px;} /* Law Tablet */ +.heroT4Item.item204{background-position:-480px -288px;} /* Book of Wisdom */ +.heroT4Item.item205{background-position:-528px -288px;} /* Artwork */ +.heroT4Item.item206{background-position:-576px -288px;} /* Small Bandage */ +.heroT4Item.item207{background-position:-624px -288px;} /* Bandage */ +.heroT4Item.item208{background-position:-672px -288px;} /* Cage */ diff --git a/img/hero/items.png b/img/hero/items.png new file mode 100644 index 00000000..36b8b3da Binary files /dev/null and b/img/hero/items.png differ diff --git a/img/hero/mapping.json b/img/hero/mapping.json new file mode 100644 index 00000000..aa87e302 --- /dev/null +++ b/img/hero/mapping.json @@ -0,0 +1,199 @@ +{ + "helmet 1.gif": [ + 1, + 2, + 3 + ], + "helmet 2.gif": [ + 4, + 5, + 6 + ], + "helmet 3.gif": [ + 7, + 8, + 9 + ], + "helmet 4.gif": [ + 10, + 11, + 12 + ], + "helmet 5.gif": [ + 13, + 14, + 15 + ], + "armour 1.gif": [ + 20, + 21, + 22 + ], + "armour 2.gif": [ + 23, + 24, + 25 + ], + "armour 3.gif": [ + 26, + 27, + 28 + ], + "armour 4.gif": [ + 29, + 30, + 31 + ], + "scout.gif": [ + 40, + 41, + 42 + ], + "horn.gif": [ + 43, + 44, + 45 + ], + "bag.gif": [ + 46, + 47, + 48 + ], + "map.gif": [ + 49, + 50, + 51 + ], + "item64.gif": [ + 52, + 53, + 54 + ], + "item67.gif": [ + 55, + 56, + 57 + ], + "shoes 1.gif": [ + 60, + 61, + 62 + ], + "shoes 2.gif": [ + 63, + 64, + 65 + ], + "shoes 3.gif": [ + 66, + 67, + 68 + ], + "horse.gif": [ + 70, + 71, + 72 + ], + "sabie 1 roman legionar.gif": [ + 101, + 102, + 103 + ], + "sabie 2 roman praetorian.gif": [ + 104, + 105, + 106 + ], + "sabie 3 roman imperian.gif": [ + 107, + 108, + 109 + ], + "sabie 4 romani imperatoris.gif": [ + 110, + 111, + 112 + ], + "sabie 4 romani caesaris.gif": [ + 113, + 114, + 115 + ], + "sabie 1 teutoni clubswinger.gif": [ + 116, + 117, + 118 + ], + "sabie 2 teutoni spearman.gif": [ + 119, + 120, + 121 + ], + "sabie 2 teutoni axeman.gif": [ + 122, + 123, + 124 + ], + "Paladin teutons.gif": [ + 125, + 126, + 127 + ], + "Teutonic Knight teutons.gif": [ + 128, + 129, + 130 + ], + "Phalanx gauls.gif": [ + 131, + 132, + 133 + ], + "Swordsman gauls.gif": [ + 134, + 135, + 136 + ], + "Theutates Thunder gauls.gif": [ + 137, + 138, + 139 + ], + "Druidrider gauls.gif": [ + 140, + 141, + 142 + ], + "Haeduan teutons.gif": [ + 143, + 144, + 145 + ], + "ointment.gif": [ + 200 + ], + "scroll.gif": [ + 201 + ], + "water.gif": [ + 202 + ], + "tablet.gif": [ + 203 + ], + "book.gif": [ + 204 + ], + "artwork.gif": [ + 205 + ], + "bandage25.gif": [ + 206 + ], + "bandage33.gif": [ + 207 + ], + "cage.gif": [ + 208 + ] +} \ No newline at end of file diff --git a/install/data/constant_format.tpl b/install/data/constant_format.tpl index 51057835..86ec98ca 100644 --- a/install/data/constant_format.tpl +++ b/install/data/constant_format.tpl @@ -335,6 +335,7 @@ define("NEW_FUNCTIONS_MEDAL_10YEAR", %NEW_FUNCTIONS_MEDAL_10YEAR%); define("NEW_FUNCTIONS_SPECIAL_MEDALS_SYSTEM", %NEW_FUNCTIONS_SPECIAL_MEDALS_SYSTEM%); define("NEW_FUNCTIONS_MILESTONES", %NEW_FUNCTIONS_MILESTONES%); define("NEW_FUNCTIONS_MEDAL_RESET", %NEW_FUNCTIONS_MEDAL_RESET%); +define("NEW_FUNCTIONS_HERO_T4", %NEW_FUNCTIONS_HERO_T4%); ////////////////////////////////////////// // **** DO NOT EDIT SETTINGS **** // diff --git a/install/process.php b/install/process.php index 595524df..a2e6a798 100644 --- a/install/process.php +++ b/install/process.php @@ -171,6 +171,7 @@ class Process { $findReplace["%NEW_FUNCTIONS_SPECIAL_MEDALS_SYSTEM%"] = $_POST['new_functions_special_medals_system']; $findReplace["%NEW_FUNCTIONS_MILESTONES%"] = $_POST['new_functions_milestones']; $findReplace["%NEW_FUNCTIONS_MEDAL_RESET%"] = $_POST['new_functions_medal_reset']; + $findReplace["%NEW_FUNCTIONS_HERO_T4%"] = $_POST['new_functions_hero_t4']; fwrite($gameConfig, str_replace(array_keys($findReplace), array_values($findReplace), $text)); diff --git a/install/templates/config.tpl b/install/templates/config.tpl index 706971a9..d2ba7bd4 100644 --- a/install/templates/config.tpl +++ b/install/templates/config.tpl @@ -207,7 +207,8 @@ $mechs = [ 'new_functions_medal_10year' => 'Medal 10y', 'new_functions_special_medals_system' => 'Special Medals System', 'new_functions_medal_reset' => 'Medal Reset', - 'new_functions_milestones' => 'Server Milestones' + 'new_functions_milestones' => 'Server Milestones', + 'new_functions_hero_t4' => 'T4 Hero (items, adventures, auction)' ]; foreach($mechs as $k => $l){ diff --git a/var/db/struct.sql b/var/db/struct.sql index bd7cdf1e..44e16b5b 100644 --- a/var/db/struct.sql +++ b/var/db/struct.sql @@ -951,6 +951,8 @@ CREATE TABLE IF NOT EXISTS `%PREFIX%hero` ( `level` tinyint(3) NULL, `points` int(3) NULL, `experience` int(11) NULL, + `silver` int(11) NOT NULL DEFAULT 0, + `production` tinyint(1) NOT NULL DEFAULT 0, `dead` tinyint(1) NULL, `health` float(12,9) NULL, `attack` tinyint(3) NULL, @@ -976,6 +978,76 @@ CREATE TABLE IF NOT EXISTS `%PREFIX%hero` ( +-- -------------------------------------------------------- + +-- +-- Table structure for table `%prefix%hero_items` (T4 hero port) +-- + +CREATE TABLE IF NOT EXISTS `%PREFIX%hero_items` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `uid` int(11) NOT NULL, + `heroid` int(11) NOT NULL DEFAULT 0, + `itemid` smallint(5) NOT NULL, + `slot` tinyint(2) NOT NULL DEFAULT 0, + `stat_value` int(11) NOT NULL DEFAULT 0, + `quantity` int(11) NOT NULL DEFAULT 1, + `equipped` tinyint(1) NOT NULL DEFAULT 0, + `tstamp` int(11) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `uid_equipped` (`uid`,`equipped`), + KEY `uid_slot` (`uid`,`slot`), + KEY `itemid` (`itemid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `%prefix%hero_adventure` (T4 hero port) +-- + +CREATE TABLE IF NOT EXISTS `%PREFIX%hero_adventure` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `uid` int(11) NOT NULL, + `wref` int(11) NOT NULL DEFAULT 0, + `difficulty` tinyint(1) NOT NULL DEFAULT 0, + `duration` int(11) NOT NULL DEFAULT 0, + `created` int(11) NOT NULL DEFAULT 0, + `expire` int(11) NOT NULL DEFAULT 0, + `status` tinyint(1) NOT NULL DEFAULT 0, + `moveid` int(11) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `uid_status` (`uid`,`status`), + KEY `expire` (`expire`), + KEY `moveid` (`moveid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `%prefix%auction` (T4 hero port) +-- + +CREATE TABLE IF NOT EXISTS `%PREFIX%auction` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `seller` int(11) NOT NULL DEFAULT 0, + `itemid` smallint(5) NOT NULL, + `slot` tinyint(2) NOT NULL DEFAULT 0, + `stat_value` int(11) NOT NULL DEFAULT 0, + `quantity` int(11) NOT NULL DEFAULT 1, + `silver_start` int(11) NOT NULL DEFAULT 0, + `silver_current` int(11) NOT NULL DEFAULT 0, + `bidder` int(11) NOT NULL DEFAULT 0, + `bid_max` int(11) NOT NULL DEFAULT 0, + `created` int(11) NOT NULL DEFAULT 0, + `time_end` int(11) NOT NULL DEFAULT 0, + `status` tinyint(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `status_end` (`status`,`time_end`), + KEY `bidder` (`bidder`), + KEY `seller` (`seller`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + -- -------------------------------------------------------- -- diff --git a/var/tools/generate_hero_sprites.php b/var/tools/generate_hero_sprites.php new file mode 100644 index 00000000..95877bc1 --- /dev/null +++ b/var/tools/generate_hero_sprites.php @@ -0,0 +1,27 @@ + $def) { + $x = ($idx % $cols) * $cell; + $y = intdiv($idx, $cols) * $cell; + $css .= ".heroT4Item.item{$iid}{background-position:-{$x}px -{$y}px;} /* {$def['name']} */\n"; + $idx++; +} +file_put_contents(__DIR__ . '/../../css/hero_items.css', $css); +echo "css/hero_items.css written: $idx items\n";