Update Battle.php

Fix hero bug
This commit is contained in:
Catalin Novgorodschi
2026-08-25 07:38:36 +03:00
committed by GitHub
parent 0f58d238f1
commit dda489b26f
+108 -49
View File
@@ -259,7 +259,7 @@ class Battle {
* cast (int) + clamp de interval. Pentru valori VALIDE rezultatul * cast (int) + clamp de interval. Pentru valori VALIDE rezultatul
* este identic cu originalul (verificat cu harness-ul de * este identic cu originalul (verificat cu harness-ul de
* echivalenta); clamp-urile schimba comportamentul doar pe input * echivalenta); clamp-urile schimba comportamentul doar pe input
* invalid fiecare e semnalat individual mai jos. * invalid — fiecare e semnalat individual mai jos.
******************************************************************/ ******************************************************************/
$attTribe = (int)$post['a1_v']; // validat 1..9 in procSim() $attTribe = (int)$post['a1_v']; // validat 1..9 in procSim()
@@ -421,7 +421,7 @@ class Battle {
// The building TYPE is stored in the 'f<n>t' columns of fdata (e.g. the // The building TYPE is stored in the 'f<n>t' columns of fdata (e.g. the
// Brewery on slot 20 is 'f20t' == 35); the matching level lives in 'f<n>'. // Brewery on slot 20 is 'f20t' == 35); the matching level lives in 'f<n>'.
// Match on the 't' SUFFIX, not the first character a previous rewrite // Match on the 't' SUFFIX, not the first character — a previous rewrite
// tested $key[0] === 't', which never matched 'f<n>t' and made this // tested $key[0] === 't', which never matched 'f<n>t' and made this
// return 0 for every building (issue #294: Brewery bonus never applied). // return 0 for every building (issue #294: Brewery bonus never applied).
// Mirrors Building::getTypeLevel()/Automation::getTypeLevel(). // Mirrors Building::getTypeLevel()/Automation::getTypeLevel().
@@ -482,14 +482,14 @@ class Battle {
/***************************************** /*****************************************
Function to process Calculate Battle Function to process Calculate Battle
(Phase 2: orchestrator fiecare sectiune (Phase 2: orchestrator — fiecare sectiune
a devenit un helper privat, mai jos) a devenit un helper privat, mai jos)
$previousHeroOutcome: result of an earlier $previousHeroOutcome: result of an earlier
pass over the SAME battle (rams changed the pass over the SAME battle (rams changed the
wall level, so it is replayed). When set, wall level, so it is replayed). When set,
the hero damage is not applied a second the hero damage is not applied a second
time the first verdict is reused instead time — the first verdict is reused instead
(issue #372). (issue #372).
*****************************************/ *****************************************/
@@ -552,7 +552,7 @@ class Battle {
} }
/****************************************************************** /******************************************************************
* DEFENDER FORCES (BASE + REINFORCEMENTS) citeste DB, nu scrie * DEFENDER FORCES (BASE + REINFORCEMENTS) — citeste DB, nu scrie
******************************************************************/ ******************************************************************/
$defForces = $this->computeDefenderForces( $defForces = $this->computeDefenderForces(
$Defender, $def_ab, $type, $Defender, $def_ab, $type,
@@ -636,7 +636,7 @@ class Battle {
$Mfactor = $this->computeMfactor($involve, $type); $Mfactor = $this->computeMfactor($involve, $type);
/****************************************************************** /******************************************************************
* LOSSES (tip 1/3/4 izoleaza singurul rand() din fisier) * LOSSES (tip 1/3/4 — izoleaza singurul rand() din fisier)
******************************************************************/ ******************************************************************/
$losses = $this->computeLossRatios( $losses = $this->computeLossRatios(
$type, $winner, $att_tribe, $detected, $Attacker, $type, $winner, $att_tribe, $detected, $Attacker,
@@ -701,43 +701,55 @@ class Battle {
); );
/****************************************************************** /******************************************************************
* HERO DAMAGE carry over on a recalculation * HERO DAMAGE — per-hero carry over on a recalculation
* *
* applyHeroBattleDamage() WRITES to the hero table, so it must run * applyHeroBattleDamage() WRITES to the hero table. When rams change
* exactly once per battle. When rams change the wall level, the caller * the wall level, the caller replays the whole battle
* replays the whole battle (AutomationBattleResolution::applyRamDamage()). * (AutomationBattleResolution::applyRamDamage()), passing the FIRST
* On that second pass the "WHERE dead = 0" lookup no longer finds a hero * pass's result back in as $previousHeroOutcome.
* who just died on the first pass: applyHeroBattleDamage() returns null
* and the hero casualty silently disappears from the result. The hero was
* then dead in the hero table but reported alive, t11 was never
* decremented and returnunitsComplete() put him back in the village
* (issue #372). Defender heroes were also charged their health damage
* twice.
* *
* The caller passes the first pass result in $previousHeroOutcome so the * issue #372 (original bug, both halves already fixed once here):
* verdict is carried over instead of being recomputed. * (a) on a naive second pass the "WHERE dead = 0" lookup no longer finds
* a hero who just died on the first pass, so his casualty silently
* disappeared (dead in the hero table, reported alive, t11 never
* decremented, returnunitsComplete() put him back in the village);
* (b) a hero who survived the first pass got his health damage
* subtracted a SECOND time on the naive second pass.
*
* BUG GASIT IN AUDIT (fixed here): the previous fix for (a)/(b) skipped
* the ENTIRE hero-damage block whenever a recalculation happened,
* freezing the hero's fate as of the FIRST pass. But the first pass
* runs with the OLD (pre-ram) wall level, which still counts its full
* defense bonus - so a defender hero judged "alive" on that pass could
* in reality belong to an army that the recalculated, post-ram-damage
* pass now shows as 100% wiped out (applyOwnDefenceCasualties()
* downstream uses the RECALCULATED result[2] for the actual troop
* kills). The hero's own verdict never got re-checked against that same
* recalculated ratio, so he could survive on record despite his whole
* army dying with the wall.
*
* Fix: track each hero's PREVIOUS verdict individually (1 = already
* dead -> short-circuit, exactly as before; 0 = survived pass 1 -> must
* be re-run against the current ratio; null = first pass, unchanged)
* instead of skipping the block wholesale. See applyHeroBattleDamage()
* for how each case is handled without double-charging health.
******************************************************************/ ******************************************************************/
$heroDamageAlreadyApplied = ($previousHeroOutcome !== null); $prevAtkVerdict = ($previousHeroOutcome !== null && array_key_exists('deadheroatt', $previousHeroOutcome))
? $previousHeroOutcome['deadheroatt'] : null;
if ($heroDamageAlreadyApplied) { $prevDefVerdict = ($previousHeroOutcome !== null && array_key_exists('deadherodef', $previousHeroOutcome))
? $previousHeroOutcome['deadherodef'] : null;
if (isset($previousHeroOutcome['casualties_attacker'][11])) { if ($previousHeroOutcome !== null && isset($previousHeroOutcome['casualties_attacker'][11])) {
$result['casualties_attacker'][11] = $previousHeroOutcome['casualties_attacker'][11]; // A first-pass death must never be lost, even before the attacker
} // block below gets a chance to re-derive it.
$result['casualties_attacker'][11] = $previousHeroOutcome['casualties_attacker'][11];
if (isset($previousHeroOutcome['deadherodef'])) {
$result['deadherodef'] = $previousHeroOutcome['deadherodef'];
}
if (isset($previousHeroOutcome['deadheroref'])) {
$result['deadheroref'] = $previousHeroOutcome['deadheroref'];
}
} }
/****************************************************************** /******************************************************************
* HERO DAMAGE (ATTACKER) * HERO DAMAGE (ATTACKER)
******************************************************************/ ******************************************************************/
if (!$heroDamageAlreadyApplied && !empty($units['Att_unit']['hero']) && !empty($atkhero['heroid'])) { if ($prevAtkVerdict !== 1 && !empty($units['Att_unit']['hero']) && !empty($atkhero['heroid'])) {
/** /**
* A fost armata NIMICITA? * A fost armata NIMICITA?
@@ -773,30 +785,42 @@ class Battle {
// T4 hero port (Phase 5): doar eroul ATACATOR beneficiaza de // T4 hero port (Phase 5): doar eroul ATACATOR beneficiaza de
// reducerea de daune din armuri (comportament original pastrat) // reducerea de daune din armuri (comportament original pastrat)
$atkhero['uid'] ?? 0, $atkhero['uid'] ?? 0,
$armyWipedOut $armyWipedOut,
$prevAtkVerdict
); );
if ($dead !== null) {
$result['deadheroatt'] = $dead;
}
if ($dead === 1) { if ($dead === 1) {
$result['casualties_attacker'][11] = 1; $result['casualties_attacker'][11] = 1;
} }
} elseif ($prevAtkVerdict !== null) {
$result['deadheroatt'] = $prevAtkVerdict;
} }
/****************************************************************** /******************************************************************
* HERO DAMAGE (DEFENDER) * HERO DAMAGE (DEFENDER)
******************************************************************/ ******************************************************************/
if (!$heroDamageAlreadyApplied && !empty($units['Def_unit']['hero']) && !empty($defenderhero['heroid'])) { if ($prevDefVerdict !== 1 && !empty($units['Def_unit']['hero']) && !empty($defenderhero['heroid'])) {
$dead = $this->applyHeroBattleDamage($defenderhero['heroid'], $result[2]); $dead = $this->applyHeroBattleDamage($defenderhero['heroid'], $result[2], null, false, $prevDefVerdict);
if ($dead !== null) { if ($dead !== null) {
$result['deadherodef'] = $dead; $result['deadherodef'] = $dead;
} }
} elseif ($prevDefVerdict !== null) {
$result['deadherodef'] = $prevDefVerdict;
} }
/****************************************************************** /******************************************************************
* HERO DAMAGE (DEFENDER + REINFORCEMENTS) * HERO DAMAGE (DEFENDER + REINFORCEMENTS)
******************************************************************/ ******************************************************************/
if (!$heroDamageAlreadyApplied && !empty($DefendersAll)) { if (!empty($DefendersAll)) {
$prevRefVerdicts = ($previousHeroOutcome !== null && isset($previousHeroOutcome['deadheroref']))
? $previousHeroOutcome['deadheroref'] : [];
$battleHeroesCache = []; $battleHeroesCache = [];
$villageOwnerCache = []; $villageOwnerCache = [];
@@ -807,6 +831,13 @@ class Battle {
continue; continue;
} }
$prevRefVerdict = isset($prevRefVerdicts[$defenders['id']]) ? $prevRefVerdicts[$defenders['id']] : null;
if ($prevRefVerdict === 1) {
$result['deadheroref'][$defenders['id']] = 1;
continue;
}
$fromVillage = (int)$defenders['from']; $fromVillage = (int)$defenders['from'];
if (!isset($villageOwnerCache[$fromVillage])) { if (!isset($villageOwnerCache[$fromVillage])) {
@@ -825,12 +856,14 @@ class Battle {
continue; continue;
} }
$dead = $this->applyHeroBattleDamage($heroarraydefender['heroid'], $result[2]); $dead = $this->applyHeroBattleDamage($heroarraydefender['heroid'], $result[2], null, false, $prevRefVerdict);
if ($dead !== null) { if ($dead !== null) {
$result['deadheroref'][$defenders['id']] = $dead; $result['deadheroref'][$defenders['id']] = $dead;
} }
} }
} elseif ($previousHeroOutcome !== null && isset($previousHeroOutcome['deadheroref'])) {
$result['deadheroref'] = $previousHeroOutcome['deadheroref'];
} }
/****************************************************************** /******************************************************************
@@ -1076,7 +1109,7 @@ class Battle {
} }
// NOTA: artefactul ofensiv al atacatorului se aplica DOAR pe ramura // NOTA: artefactul ofensiv al atacatorului se aplica DOAR pe ramura
// de spionaj (tip 1), nu si la atacurile normale comportament // de spionaj (tip 1), nu si la atacurile normale — comportament
// original pastrat 1:1; de verificat separat daca e intentionat // original pastrat 1:1; de verificat separat daca e intentionat
$ap *= $attacker_artefact; $ap *= $attacker_artefact;
@@ -1342,7 +1375,7 @@ class Battle {
/***************************************** /*****************************************
Phase 2 helper: bonusul de atac Brewery Phase 2 helper: bonusul de atac Brewery
(citeste DB; extras ca sa ramana (citeste DB; extras ca sa ramana
computeTotalPoints() pur vezi tabelul computeTotalPoints() pur — vezi tabelul
de mapare din livrare) de mapare din livrare)
*****************************************/ *****************************************/
@@ -1354,8 +1387,8 @@ class Battle {
// Brewery (35) Mead-Festival attack bonus: Teuton-only, capital-only but // Brewery (35) Mead-Festival attack bonus: Teuton-only, capital-only but
// empire-wide, and active ONLY while a festival is running (72h). It must be // empire-wide, and active ONLY while a festival is running (72h). It must be
// read from the attacker's CAPITAL $AttackerWref is the launching village, // read from the attacker's CAPITAL — $AttackerWref is the launching village,
// which usually has no Brewery and gated on the festival being active, // which usually has no Brewery — and gated on the festival being active,
// otherwise the bonus is permanent and never reacts to the festival being // otherwise the bonus is permanent and never reacts to the festival being
// started/expired (issue #294). This mirrors the catapult-randomization gate // started/expired (issue #294). This mirrors the catapult-randomization gate
// in Units.php and the chief-penalty gate in Automation.php. The simulator // in Units.php and the chief-penalty gate in Automation.php. The simulator
@@ -1454,7 +1487,7 @@ class Battle {
/***************************************** /*****************************************
Phase 2 helper: ratele de pierderi pentru Phase 2 helper: ratele de pierderi pentru
tipurile 1/3/4 izoleaza singurul rand() tipurile 1/3/4 — izoleaza singurul rand()
din fisier (hero_fealthy); pentru alte din fisier (hero_fealthy); pentru alte
tipuri nu seteaza cheile 1/2, exact ca tipuri nu seteaza cheile 1/2, exact ca
originalul originalul
@@ -1493,7 +1526,7 @@ class Battle {
// NOTA: $result[1] e o fractie 0..1, deci "/ 100" face scaderea // NOTA: $result[1] e o fractie 0..1, deci "/ 100" face scaderea
// aproape mereu 0 (berbecii/catapultele trag cu efectivul de // aproape mereu 0 (berbecii/catapultele trag cu efectivul de
// dinainte de pierderi) comportament original pastrat 1:1 // dinainte de pierderi) — comportament original pastrat 1:1
$ram -= round($ram * $result[1] / 100); $ram -= round($ram * $result[1] / 100);
$catp -= round($catp * $result[1] / 100); $catp -= round($catp * $result[1] / 100);
@@ -1531,7 +1564,7 @@ class Battle {
// NOTA: cast-ul (int) trunchiaza fractia la 0, deci $aviables == // NOTA: cast-ul (int) trunchiaza fractia la 0, deci $aviables ==
// $kings pentru orice pierdere partiala (doar pierderea totala, // $kings pentru orice pierdere partiala (doar pierderea totala,
// result[1] == 1, ii scade) comportament original pastrat 1:1 // result[1] == 1, ii scade) — comportament original pastrat 1:1
$aviables = $kings - round($kings * (int)$result[1]); $aviables = $kings - round($kings * (int)$result[1]);
if ($aviables > 0) { if ($aviables > 0) {
@@ -1547,7 +1580,7 @@ class Battle {
$result['hero_fealthy'] = $fealthy; $result['hero_fealthy'] = $fealthy;
} }
// NOTA: acelasi "/ 100" pe fractie ca la tipul 4 pastrat 1:1 // NOTA: acelasi "/ 100" pe fractie ca la tipul 4 — pastrat 1:1
$ram -= ($winner) $ram -= ($winner)
? round($ram * $result[1] / 100) ? round($ram * $result[1] / 100)
: round($ram * $result[2] / 100); : round($ram * $result[2] / 100);
@@ -1685,21 +1718,36 @@ class Battle {
/***************************************** /*****************************************
Phase 2 helper: daunele de sanatate ale Phase 2 helper: daunele de sanatate ale
unui erou dupa lupta unifica cele 3 unui erou dupa lupta — unifica cele 3
blocuri DB duplicate (atacator, aparator, blocuri DB duplicate (atacator, aparator,
intariri); SINGURUL helper care scrie in intariri); SINGURUL helper care scrie in
DB. Intoarce: null = eroul nu exista / DB. Intoarce: null = eroul nu exista /
era deja mort (nicio scriere), 1 = a era deja mort (nicio scriere), 1 = a
murit acum, 0 = a supravietuit (health murit acum, 0 = a supravietuit (health
scazut cu daunele) scazut cu daunele)
$previousVerdict (recalculare dupa daune de berbec, issue #372 +
BUG GASIT in audit): 1 = eroul era deja mort pe primul pass -> iesim
imediat, fara query, fara a doua scriere (asa se evita dubla scadere
de health din bug-ul original #372). 0 = eroul supravietuise primul
pass (si isi luase deja daunele acelui pass) - reverificam impotriva
raportului RECALCULAT (zid daramat de berbec = aparare mai mica =
$lossRatio real, mai mare) si il omoram daca acum trece pragul, dar
NU ii mai scadem health inca o data daca tot supravietuieste (health-ul
lui reflecta deja daunele din primul pass). null = pass normal / prim
pass, comportament neschimbat.
*****************************************/ *****************************************/
private function applyHeroBattleDamage($hero_id, $lossRatio, $reduceForUid = null, $armyWipedOut = false) { private function applyHeroBattleDamage($hero_id, $lossRatio, $reduceForUid = null, $armyWipedOut = false, $previousVerdict = null) {
global $database; global $database;
$hero_id = (int)$hero_id; $hero_id = (int)$hero_id;
if ($previousVerdict === 1) {
return 1;
}
$_result = mysqli_query( $_result = mysqli_query(
$database->dblink, $database->dblink,
"SELECT heroid, health "SELECT heroid, health
@@ -1730,7 +1778,7 @@ class Battle {
* *
* BUG REPARAT: armurile scad daunele cu o valoare fixa * BUG REPARAT: armurile scad daunele cu o valoare fixa
* (HeroBattleBonus::reduceDamage), asa ca la o infrangere totala eroul * (HeroBattleBonus::reduceDamage), asa ca la o infrangere totala eroul
* putea coborî sub prag si supravietuia - se intorcea singur in sat, desi * putea coborî sub prag si supravietuia - se intorcea singur in sat, desi
* toata armata murise. * toata armata murise.
* *
* Reducerea din armuri ramane valabila pentru lupte partiale, dar cand * Reducerea din armuri ramane valabila pentru lupte partiale, dar cand
@@ -1778,6 +1826,17 @@ class Battle {
return 1; return 1;
} }
/**
* BUG GASIT in audit: eroul supravietuise deja primul pass (pre-berbec)
* si isi luase daunele ACELUIA. Raportul recalculat aici confirma ca tot
* supravietuieste, deci nu se mai scrie nimic - altfel s-ar scadea health
* de doua ori pentru aceeasi lupta (exact dubla-scadere din issue #372
* pe care fix-ul original o repara pentru alte ramuri).
*/
if ($previousVerdict === 0) {
return 0;
}
mysqli_query( mysqli_query(
$database->dblink, $database->dblink,
"UPDATE " . TB_PREFIX . "hero "UPDATE " . TB_PREFIX . "hero