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(); } }