Add T4 Hero System (configurable on install)

Add T4 Hero System (configurable on install with TRUE/FALS) part of #285

-Adventures
-Merchants
-Equipment
This commit is contained in:
novgorodschi catalin
2026-07-10 09:15:42 +03:00
parent ed325b6d99
commit 3c99623a33
46 changed files with 4367 additions and 32 deletions
+2
View File
@@ -73,6 +73,7 @@ $fh = fopen($myFile, 'w') or die("<br/><br/><br/>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("<br/><br/><br/>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);
+131
View File
@@ -0,0 +1,131 @@
<?php
#################################################################################
## -= YOU MAY NOT REMOVE OR CHANGE THIS NOTICE =- ##
## --------------------------------------------------------------------------- ##
## Filename : editHeroT4.php ##
## Type : BACKEND (Admin Mod) - T4 hero port admin controls ##
## --------------------------------------------------------------------------- ##
## Refactored by : Shadow ##
## Project : TravianZ ##
## GitHub : https://github.com/Shadowss/TravianZ ##
## --------------------------------------------------------------------------- ##
## License : TravianZ Project ##
## Copyright : TravianZ (c) 2010-2026. All rights reserved. ##
## --------------------------------------------------------------------------- ##
#################################################################################
## ##
## POST actions (all CSRF-verified, admin access >= 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;
+2
View File
@@ -69,6 +69,7 @@ $fh = fopen($myFile, 'w') or die("<br/><br/><br/>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("<br/><br/><br/>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);
@@ -131,6 +131,7 @@ $fh = fopen($myFile, 'w') or die("<br/><br/><br/>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);
+2
View File
@@ -75,6 +75,7 @@ $fh = fopen($myFile, 'w') or die("<br/><br/><br/>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("<br/><br/><br/>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);
+2
View File
@@ -57,6 +57,7 @@ $fh = fopen($myFile, 'w') or die("<br/><br/><br/>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("<br/><br/><br/>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);
+2
View File
@@ -68,6 +68,7 @@ $fh = fopen($myFile, 'w') or die("<br/><br/><br/>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("<br/><br/><br/>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);
+48 -2
View File
@@ -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;
+32 -1
View File
@@ -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;
+307
View File
@@ -0,0 +1,307 @@
<?php
#################################################################################
## -= YOU MAY NOT REMOVE OR CHANGE THIS NOTICE =- ##
## --------------------------------------------------------------------------- ##
## Filename : hero_items.php ##
## Type : Static Data / Catalog for T4 Hero port ##
## --------------------------------------------------------------------------- ##
## Refactored by : Shadow ##
## Project : TravianZ ##
## GitHub : https://github.com/Shadowss/TravianZ ##
## --------------------------------------------------------------------------- ##
## License : TravianZ Project ##
## Copyright : TravianZ (c) 2010-2026. All rights reserved. ##
## --------------------------------------------------------------------------- ##
#################################################################################
## ##
## SINGLE SOURCE OF TRUTH for the T4 hero port (Phase 2 - full catalog, ##
## built from the design spreadsheet "Obiecte_Erou_Travian.xlsx"). ##
## Pure data + guarded constants, no side effects - safe to include anywhere. ##
## ##
## Display names are stored as English strings in the catalog. For future ##
## localisation, heroItemName() checks for a HERO_ITEM_<id> 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_<id> 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';
}
}
+9
View File
@@ -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;
+643
View File
@@ -0,0 +1,643 @@
<?php
#################################################################################
## -= YOU MAY NOT REMOVE OR CHANGE THIS NOTICE =- ##
## --------------------------------------------------------------------------- ##
## Filename : HeroAdventure.php ##
## Type : T4 Hero port - adventures backend (Phase 3) ##
## --------------------------------------------------------------------------- ##
## Refactored by : Shadow ##
## Project : TravianZ ##
## GitHub : https://github.com/Shadowss/TravianZ ##
## --------------------------------------------------------------------------- ##
## License : TravianZ Project ##
## Copyright : TravianZ (c) 2010-2026. All rights reserved. ##
## --------------------------------------------------------------------------- ##
#################################################################################
## ##
## Adventure lifecycle: ##
## offer (status 0) -> 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;
}
}
+533
View File
@@ -0,0 +1,533 @@
<?php
#################################################################################
## -= YOU MAY NOT REMOVE OR CHANGE THIS NOTICE =- ##
## --------------------------------------------------------------------------- ##
## Filename : HeroAuction.php ##
## Type : T4 Hero port - auction house backend (Phase 4) ##
## --------------------------------------------------------------------------- ##
## Refactored by : Shadow ##
## Project : TravianZ ##
## GitHub : https://github.com/Shadowss/TravianZ ##
## --------------------------------------------------------------------------- ##
## License : TravianZ Project ##
## Copyright : TravianZ (c) 2010-2026. All rights reserved. ##
## --------------------------------------------------------------------------- ##
#################################################################################
## ##
## T4-style auction house with PROXY BIDDING (eBay model): ##
## - a bid is a secret MAXIMUM (bid_max); the visible price ##
## (silver_current) only rises to (loser's max + 1), capped at the ##
## winner's max. ##
## ##
## SILVER ESCROW MODEL (documented invariant): ##
## - when a bidder becomes top bidder, their FULL bid_max is deducted; ##
## - when outbid, their full bid_max is refunded immediately; ##
## - at finalization the winner is refunded (bid_max - silver_current), ##
## so they only ever pay the visible price. ##
## => 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;
}
}
+529
View File
@@ -0,0 +1,529 @@
<?php
#################################################################################
## -= YOU MAY NOT REMOVE OR CHANGE THIS NOTICE =- ##
## --------------------------------------------------------------------------- ##
## Filename : HeroBattleBonus.php ##
## Type : T4 Hero port - battle & speed integration (Phase 5) ##
## --------------------------------------------------------------------------- ##
## Refactored by : Shadow ##
## Project : TravianZ ##
## GitHub : https://github.com/Shadowss/TravianZ ##
## --------------------------------------------------------------------------- ##
## License : TravianZ Project ##
## Copyright : TravianZ (c) 2010-2026. All rights reserved. ##
## --------------------------------------------------------------------------- ##
#################################################################################
## ##
## Thin, static bridge between the hero item system and the combat/speed ##
## engine. Every entry point: ##
## - is a NO-OP returning neutral values when NEW_FUNCTIONS_HERO_T4 is ##
## off, so vanilla battle math is bit-for-bit unchanged; ##
## - memoizes per uid for the duration of the request (Automation ticks ##
## process many battles; bonuses must not re-query per battle). ##
## ##
## Consumers (surgical hooks): ##
## Battle::getBattleHero() -> 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();
}
}
+632
View File
@@ -0,0 +1,632 @@
<?php
#################################################################################
## -= YOU MAY NOT REMOVE OR CHANGE THIS NOTICE =- ##
## --------------------------------------------------------------------------- ##
## Filename : HeroItems.php ##
## Type : T4 Hero port - items backend (Phase 2) ##
## --------------------------------------------------------------------------- ##
## Refactored by : Shadow ##
## Project : TravianZ ##
## GitHub : https://github.com/Shadowss/TravianZ ##
## --------------------------------------------------------------------------- ##
## License : TravianZ Project ##
## Copyright : TravianZ (c) 2010-2026. All rights reserved. ##
## --------------------------------------------------------------------------- ##
#################################################################################
## ##
## Backend for hero inventory / equipment / consumables / silver. ##
## - All statements are prepared (mysqli), all ids cast to int. ##
## - Per-request memoization caches (invalidated on every write), matching ##
## the Database.php cache convention. ##
## - Consumables whose game mechanic lands in a later phase (bandages in ##
## battle, cages in oasis attacks, law tablets in loyalty, artwork CP) ##
## return HeroItems::USE_DEFERRED so the caller knows the item exists ##
## but its effect is wired elsewhere. They are NOT silently consumed. ##
## ##
#################################################################################
class HeroItems
{
/** useItem() result codes */
const USE_OK = 1; // consumed, effect applied here
const USE_DEFERRED = 2; // valid item, but its effect is applied by another subsystem (Phase 3/5)
const USE_INVALID = 0; // unknown item / not owned / not usable now
/** @var array per-request caches, keyed by uid */
private static $inventoryCache = array();
private static $bonusCache = array();
/** @var mysqli */
private $db;
public function __construct()
{
global $database;
// Reuse the game's single mysqli link.
$this->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;
}
}
+107
View File
@@ -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');
+8
View File
@@ -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;
}
+6 -1
View File
@@ -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){