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
* este identic cu originalul (verificat cu harness-ul de
* 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()
@@ -421,7 +421,7 @@ class Battle {
// 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>'.
// 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
// return 0 for every building (issue #294: Brewery bonus never applied).
// Mirrors Building::getTypeLevel()/Automation::getTypeLevel().
@@ -482,14 +482,14 @@ class Battle {
/*****************************************
Function to process Calculate Battle
(Phase 2: orchestrator fiecare sectiune
(Phase 2: orchestrator — fiecare sectiune
a devenit un helper privat, mai jos)
$previousHeroOutcome: result of an earlier
pass over the SAME battle (rams changed the
wall level, so it is replayed). When set,
the hero damage is not applied a second
time the first verdict is reused instead
time — the first verdict is reused instead
(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(
$Defender, $def_ab, $type,
@@ -636,7 +636,7 @@ class Battle {
$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(
$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
* exactly once per battle. When rams change the wall level, the caller
* replays the whole battle (AutomationBattleResolution::applyRamDamage()).
* On that second pass the "WHERE dead = 0" lookup no longer finds a hero
* 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.
* applyHeroBattleDamage() WRITES to the hero table. When rams change
* the wall level, the caller replays the whole battle
* (AutomationBattleResolution::applyRamDamage()), passing the FIRST
* pass's result back in as $previousHeroOutcome.
*
* The caller passes the first pass result in $previousHeroOutcome so the
* verdict is carried over instead of being recomputed.
* issue #372 (original bug, both halves already fixed once here):
* (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])) {
$result['casualties_attacker'][11] = $previousHeroOutcome['casualties_attacker'][11];
}
if (isset($previousHeroOutcome['deadherodef'])) {
$result['deadherodef'] = $previousHeroOutcome['deadherodef'];
}
if (isset($previousHeroOutcome['deadheroref'])) {
$result['deadheroref'] = $previousHeroOutcome['deadheroref'];
}
if ($previousHeroOutcome !== null && isset($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];
}
/******************************************************************
* 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?
@@ -773,30 +785,42 @@ class Battle {
// T4 hero port (Phase 5): doar eroul ATACATOR beneficiaza de
// reducerea de daune din armuri (comportament original pastrat)
$atkhero['uid'] ?? 0,
$armyWipedOut
$armyWipedOut,
$prevAtkVerdict
);
if ($dead !== null) {
$result['deadheroatt'] = $dead;
}
if ($dead === 1) {
$result['casualties_attacker'][11] = 1;
}
} elseif ($prevAtkVerdict !== null) {
$result['deadheroatt'] = $prevAtkVerdict;
}
/******************************************************************
* 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) {
$result['deadherodef'] = $dead;
}
} elseif ($prevDefVerdict !== null) {
$result['deadherodef'] = $prevDefVerdict;
}
/******************************************************************
* HERO DAMAGE (DEFENDER + REINFORCEMENTS)
******************************************************************/
if (!$heroDamageAlreadyApplied && !empty($DefendersAll)) {
if (!empty($DefendersAll)) {
$prevRefVerdicts = ($previousHeroOutcome !== null && isset($previousHeroOutcome['deadheroref']))
? $previousHeroOutcome['deadheroref'] : [];
$battleHeroesCache = [];
$villageOwnerCache = [];
@@ -807,6 +831,13 @@ class Battle {
continue;
}
$prevRefVerdict = isset($prevRefVerdicts[$defenders['id']]) ? $prevRefVerdicts[$defenders['id']] : null;
if ($prevRefVerdict === 1) {
$result['deadheroref'][$defenders['id']] = 1;
continue;
}
$fromVillage = (int)$defenders['from'];
if (!isset($villageOwnerCache[$fromVillage])) {
@@ -825,12 +856,14 @@ class Battle {
continue;
}
$dead = $this->applyHeroBattleDamage($heroarraydefender['heroid'], $result[2]);
$dead = $this->applyHeroBattleDamage($heroarraydefender['heroid'], $result[2], null, false, $prevRefVerdict);
if ($dead !== null) {
$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
// 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
$ap *= $attacker_artefact;
@@ -1342,7 +1375,7 @@ class Battle {
/*****************************************
Phase 2 helper: bonusul de atac Brewery
(citeste DB; extras ca sa ramana
computeTotalPoints() pur vezi tabelul
computeTotalPoints() pur — vezi tabelul
de mapare din livrare)
*****************************************/
@@ -1354,8 +1387,8 @@ class Battle {
// 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
// read from the attacker's CAPITAL $AttackerWref is the launching village,
// which usually has no Brewery and gated on the festival being active,
// read from the attacker's CAPITAL — $AttackerWref is the launching village,
// which usually has no Brewery — and gated on the festival being active,
// otherwise the bonus is permanent and never reacts to the festival being
// started/expired (issue #294). This mirrors the catapult-randomization gate
// 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
tipurile 1/3/4 izoleaza singurul rand()
tipurile 1/3/4 — izoleaza singurul rand()
din fisier (hero_fealthy); pentru alte
tipuri nu seteaza cheile 1/2, exact ca
originalul
@@ -1493,7 +1526,7 @@ class Battle {
// NOTA: $result[1] e o fractie 0..1, deci "/ 100" face scaderea
// 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);
$catp -= round($catp * $result[1] / 100);
@@ -1531,7 +1564,7 @@ class Battle {
// NOTA: cast-ul (int) trunchiaza fractia la 0, deci $aviables ==
// $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]);
if ($aviables > 0) {
@@ -1547,7 +1580,7 @@ class Battle {
$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)
? round($ram * $result[1] / 100)
: round($ram * $result[2] / 100);
@@ -1685,21 +1718,36 @@ class Battle {
/*****************************************
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,
intariri); SINGURUL helper care scrie in
DB. Intoarce: null = eroul nu exista /
era deja mort (nicio scriere), 1 = a
murit acum, 0 = a supravietuit (health
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;
$hero_id = (int)$hero_id;
if ($previousVerdict === 1) {
return 1;
}
$_result = mysqli_query(
$database->dblink,
"SELECT heroid, health
@@ -1730,7 +1778,7 @@ class Battle {
*
* BUG REPARAT: armurile scad daunele cu o valoare fixa
* (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.
*
* Reducerea din armuri ramane valabila pentru lupte partiale, dar cand
@@ -1778,6 +1826,17 @@ class Battle {
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(
$database->dblink,
"UPDATE " . TB_PREFIX . "hero