mirror of
https://github.com/Shadowss/TravianZ.git
synced 2026-07-23 13:06:18 +00:00
1e19f117ca
/* |-------------------------------------------------------------------------- | FIX List |-------------------------------------------------------------------------- | | 1. Hero Attribute Points | - When adding attribute points to the hero, the page currently refreshes | after every click on the "+" button. | - Implement a faster allocation method (AJAX or similar) so multiple | points can be assigned without a full page refresh. | - The implementation must remain secure and not be exploitable | (validate requests server-side, prevent double submissions, | verify available points, etc.). | | 2. Hero Equipment Restrictions (Original Travian Behavior) | - Prevent equipping or unequipping hero items while the hero is: | • On an adventure. | • Attacking (alone or with troops). | • Away as reinforcements. | | 3. Oasis Navigation | - When opening an oasis (37_land.tpl), the 37_t4nav.tpl navigation | disappears. | - Include the navigation menu on the oasis page as well. | | 4. Auction House Redesign | - Redesign 37_auction.tpl to match the Merchant.png mockup. | - Keep the existing auction functionality exactly as it is. | - Only improve the UI/UX. | - You may crop and reuse graphics from Merchant.png. | - Add Gold ⇄ Silver exchange functionality. | | 5. Hero Item Bonuses (Highest Priority) | - Verify that hero equipment bonuses are applied correctly. | - Example: | • Helmet of the Ruler should reduce Barracks training time by 20%. | • Currently, training time remains unchanged whether the helmet | is equipped or not. | - Also verify: | • Stable training speed bonuses. | • Boots bonuses. | • Any other related hero equipment effects. | | 6. Adventures Page Redesign | - Redesign 37_adventures.tpl to match the Adventure.png mockup. | - Keep the "Expires in" column intact. | */
727 lines
27 KiB
PHP
727 lines
27 KiB
PHP
<?php
|
|
|
|
#################################################################################
|
|
## -= YOU MAY NOT REMOVE OR CHANGE THIS NOTICE =- ##
|
|
## --------------------------------------------------------------------------- ##
|
|
## Filename : HeroItems.php ##
|
|
## Type : T4 Hero port - items backend (Phase 2) ##
|
|
## --------------------------------------------------------------------------- ##
|
|
## Developed 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)
|
|
*/
|
|
/* =========================================================================
|
|
* DISPONIBILITATEA EROULUI PENTRU SCHIMBAREA ECHIPAMENTULUI
|
|
* ===================================================================== */
|
|
|
|
/** @var array cache per request: uid => motiv ('' = eroul e acasa) */
|
|
private static $awayCache = array();
|
|
|
|
/**
|
|
* Motivul pentru care eroul NU poate schimba echipamentul acum.
|
|
*
|
|
* Intoarce '' cand eroul e acasa si poate fi echipat, sau una dintre:
|
|
* 'adventure' - plecat intr-o aventura
|
|
* 'attack' - plecat intr-un atac (singur sau cu trupe), dus sau intors
|
|
* 'reinforcement' - trimis ca intarire in alt sat
|
|
*
|
|
* Comportament ca in Travian original: echipamentul se schimba doar cat timp
|
|
* eroul e in sat. Eroul MORT nu e blocat - e "acasa", doar fara viata, si in
|
|
* T4 iti poti pregati echipamentul cat timp astepti invierea.
|
|
*/
|
|
public function heroAwayReason($uid)
|
|
{
|
|
$uid = (int) $uid;
|
|
|
|
if (isset(self::$awayCache[$uid])) {
|
|
return self::$awayCache[$uid];
|
|
}
|
|
|
|
$p = TB_PREFIX;
|
|
|
|
// ATENTIE: aliasul pentru miscarea de intoarcere NU poate fi "returning" -
|
|
// e cuvant rezervat in MariaDB (clauza RETURNING) si strica query-ul.
|
|
// Un singur query, cu motive separate, ca interfata sa poata afisa
|
|
// exact de ce e blocat. Coloanele `from`/`to` sunt cuvinte rezervate,
|
|
// de aceea sunt in backticks.
|
|
$q = "SELECT
|
|
(SELECT COUNT(*) FROM {$p}hero
|
|
WHERE uid = ? AND COALESCE(intraining, 0) = 0) AS hero_exists,
|
|
(SELECT COUNT(*) FROM {$p}hero_adventure
|
|
WHERE uid = ? AND status = 1) AS adventure,
|
|
(SELECT IFNULL(SUM(e.hero), 0) FROM {$p}enforcement e
|
|
INNER JOIN {$p}vdata v ON v.wref = e.`from`
|
|
WHERE v.owner = ?) AS reinforcement,
|
|
(SELECT IFNULL(SUM(a.t11), 0) FROM {$p}movement m
|
|
INNER JOIN {$p}attacks a ON a.id = m.ref
|
|
INNER JOIN {$p}vdata v ON v.wref = m.`from`
|
|
WHERE v.owner = ? AND m.proc = 0 AND m.sort_type = 3) AS outgoing,
|
|
(SELECT IFNULL(SUM(a.t11), 0) FROM {$p}movement m
|
|
INNER JOIN {$p}attacks a ON a.id = m.ref
|
|
INNER JOIN {$p}vdata v ON v.wref = m.`to`
|
|
WHERE v.owner = ? AND m.proc = 0 AND m.sort_type = 4) AS atk_return";
|
|
|
|
$stmt = $this->db->prepare($q);
|
|
|
|
if (!$stmt) {
|
|
// daca query-ul nu se poate pregati, nu blocam jucatorul
|
|
return self::$awayCache[$uid] = '';
|
|
}
|
|
|
|
$stmt->bind_param('iiiii', $uid, $uid, $uid, $uid, $uid);
|
|
$stmt->execute();
|
|
$stmt->bind_result($heroExists, $adventure, $reinforcement, $outgoing, $atkReturn);
|
|
$stmt->fetch();
|
|
$stmt->close();
|
|
|
|
$reason = '';
|
|
|
|
// Fara erou antrenat nu exista pe cine echipa. Se numara si eroii morti
|
|
// (randul exista, doar dead = 1): in T4 iti poti pregati echipamentul cat
|
|
// astepti invierea. Eroii aflati INCA in antrenament (intraining = 1) nu
|
|
// conteaza - inca nu exista cu adevarat.
|
|
if ((int) $heroExists === 0) {
|
|
$reason = 'nohero';
|
|
} elseif ((int) $adventure > 0) {
|
|
$reason = 'adventure';
|
|
} elseif (((int) $outgoing + (int) $atkReturn) > 0) {
|
|
$reason = 'attack';
|
|
} elseif ((int) $reinforcement > 0) {
|
|
$reason = 'reinforcement';
|
|
}
|
|
|
|
return self::$awayCache[$uid] = $reason;
|
|
}
|
|
|
|
public function equipItem($uid, $rowId)
|
|
{
|
|
global $database;
|
|
$uid = (int) $uid; $rowId = (int) $rowId;
|
|
|
|
// Eroul trebuie sa fie in sat. Verificarea e AICI, nu doar in interfata,
|
|
// ca un POST trimis manual sa nu poata ocoli regula.
|
|
if ($this->heroAwayReason($uid) !== '') {
|
|
return false;
|
|
}
|
|
|
|
$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)
|
|
{
|
|
// Acelasi gard ca la echipare (vezi heroAwayReason).
|
|
if ($this->heroAwayReason($uid) !== '') {
|
|
return false;
|
|
}
|
|
|
|
$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;
|
|
}
|
|
}
|