From f9b1c668d5980cc32d49b88d6925e00d8f3046cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo?= Date: Sun, 5 Jul 2026 15:35:22 -0300 Subject: [PATCH 1/3] feat: Open PvP system This PR is for retail 2014 rules (PvP situations, expert modes, skull system, per-viewer fields/walls, frag share) related with issue #810 Co-Authored-By: Jattartajjar <207260357+Jattartajjar@users.noreply.github.com> --- data-global/scripts/lib/register_actions.lua | 5 + data/migrations/64.lua | 7 + data/scripts/runes/magic_wall.lua | 7 + data/scripts/runes/wild_growth.lua | 7 + .../scripts/talkactions/god/pvp_mark_test.lua | 31 +++ schema.sql | 3 +- src/creatures/combat/combat.cpp | 107 +++++++- src/creatures/combat/combat.hpp | 4 + src/creatures/combat/spells.cpp | 8 + src/creatures/creatures_definitions.hpp | 6 +- src/creatures/players/player.cpp | 237 +++++++++++++++--- src/creatures/players/player.hpp | 27 ++ src/game/game.cpp | 3 +- src/game/game.hpp | 2 +- src/io/functions/iologindata_load_player.cpp | 9 +- src/io/functions/iologindata_save_player.cpp | 6 +- src/items/tile.cpp | 13 +- .../creatures/player/player_functions.cpp | 45 +++- .../creatures/player/player_functions.hpp | 2 + src/server/network/protocol/protocolgame.cpp | 61 ++++- src/utils/utils_definitions.hpp | 8 + 21 files changed, 549 insertions(+), 49 deletions(-) create mode 100644 data/migrations/64.lua create mode 100644 data/scripts/talkactions/god/pvp_mark_test.lua diff --git a/data-global/scripts/lib/register_actions.lua b/data-global/scripts/lib/register_actions.lua index 416046e9e..725cb2392 100644 --- a/data-global/scripts/lib/register_actions.lua +++ b/data-global/scripts/lib/register_actions.lua @@ -853,6 +853,11 @@ function onUseMachete(player, item, fromPosition, target, toPosition, isHotkey) end if table.contains(wildGrowth, target.itemid) then + -- Open PvP (2014 rules): a rune-cast wild growth can only be cut by its caster + if target:hasOwner() and not target:isOwner(player) then + player:sendCancelMessage(RETURNVALUE_NOTPOSSIBLE) + return true + end toPosition:sendMagicEffect(CONST_ME_POFF) target:remove() return true diff --git a/data/migrations/64.lua b/data/migrations/64.lua new file mode 100644 index 000000000..2d1cf8e28 --- /dev/null +++ b/data/migrations/64.lua @@ -0,0 +1,7 @@ +function onUpdateDatabase() + logger.info("Updating database to version 64 (open pvp: frag share weight columns)") + + if not db.query("ALTER TABLE `player_kills` ADD COLUMN `weight` smallint(5) UNSIGNED NOT NULL DEFAULT 100") then + logger.warn("Column weight may already exist on player_kills.") + end +end diff --git a/data/scripts/runes/magic_wall.lua b/data/scripts/runes/magic_wall.lua index 9ff3b9c99..ba41e29fe 100644 --- a/data/scripts/runes/magic_wall.lua +++ b/data/scripts/runes/magic_wall.lua @@ -23,6 +23,13 @@ function onCreateMagicWall(creature, position) if item then item:setDuration(16, 24) item:setAttribute(ITEM_ATTRIBUTE_DESCRIPTION, string.format("Casted by: %s", creature:getName())) + -- Open PvP (2014 rules): ownership decides who the wall blocks and who walks through + item:setOwner(creature) + -- Cast outside a PvP situation -> PvE wall: blocks only monsters, every player + -- (including the caster) walks through it + if creature:isPlayer() and not creature:hasActivePvpSituation() then + item:setCustomAttribute("pveWall", true) + end end end diff --git a/data/scripts/runes/wild_growth.lua b/data/scripts/runes/wild_growth.lua index 5b65b9eb0..ac43d2cff 100644 --- a/data/scripts/runes/wild_growth.lua +++ b/data/scripts/runes/wild_growth.lua @@ -23,6 +23,13 @@ function onCreateWildGrowth(creature, position) if item then item:setDuration(30) item:setAttribute(ITEM_ATTRIBUTE_DESCRIPTION, string.format("Casted by: %s", creature:getName())) + -- Open PvP (2014 rules): ownership decides who the growth blocks, who passes and who may cut it + item:setOwner(creature) + -- Cast outside a PvP situation -> PvE growth: blocks only monsters, every player + -- (including the caster) walks through it + if creature:isPlayer() and not creature:hasActivePvpSituation() then + item:setCustomAttribute("pveWall", true) + end end end diff --git a/data/scripts/talkactions/god/pvp_mark_test.lua b/data/scripts/talkactions/god/pvp_mark_test.lua new file mode 100644 index 000000000..2e5ed7293 --- /dev/null +++ b/data/scripts/talkactions/god/pvp_mark_test.lua @@ -0,0 +1,31 @@ +-- PvP-box protocol RE helper (opcode 0x93 CreatureMark, client 15.25). +-- /pvpmark [,] +-- weaponType omitted / 255 -> legacy border mark [0x93][id][0x01][markType] (markType = color byte) +-- weaponType set -> new-style mark [0x93][id][markType][weaponType] +-- Sent about your current target (or yourself if no target). Watch which value renders the +-- yellow / orange / brown PvP situation box, then wire it into the real PvP-situation code. +local pvpMark = TalkAction("/pvpmark") + +function pvpMark.onSay(player, words, param) + if param == "" then + player:sendCancelMessage("Usage: /pvpmark [,] — e.g. /pvpmark 2 or /pvpmark 1,3") + return true + end + + local split = param:split(",") + local markType = tonumber(split[1]) + local weaponType = tonumber(split[2]) or 255 + if not markType then + player:sendCancelMessage("markType must be a number (0-255).") + return true + end + + local target = player:getTarget() or player + player:sendCreatureSquare(target, markType, weaponType) + player:sendTextMessage(MESSAGE_EVENT_ADVANCE, string.format("[pvpmark] target=%s markType=%d weaponType=%d (%s)", target:getName(), markType, weaponType, weaponType == 255 and "legacy border" or "new-style mark")) + return true +end + +pvpMark:separator(" ") +pvpMark:groupType("god") +pvpMark:register() diff --git a/schema.sql b/schema.sql index ff340045c..f45eafd74 100644 --- a/schema.sql +++ b/schema.sql @@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS `server_config` ( CONSTRAINT `server_config_pk` PRIMARY KEY (`config`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; -INSERT INTO `server_config` (`config`, `value`) VALUES ('db_version', '63'), ('motd_hash', ''), ('motd_num', '0'), ('players_record', '0'); +INSERT INTO `server_config` (`config`, `value`) VALUES ('db_version', '64'), ('motd_hash', ''), ('motd_num', '0'), ('players_record', '0'); -- Table structure `accounts` CREATE TABLE IF NOT EXISTS `accounts` ( @@ -703,6 +703,7 @@ CREATE TABLE IF NOT EXISTS `player_kills` ( `time` bigint(20) UNSIGNED NOT NULL DEFAULT '0', `target` int(11) NOT NULL, `unavenged` tinyint(1) NOT NULL DEFAULT '0', + `weight` smallint(5) UNSIGNED NOT NULL DEFAULT '100', CONSTRAINT `player_kills_players_fk` FOREIGN KEY (`player_id`) REFERENCES `players` (`id`) ON DELETE CASCADE diff --git a/src/creatures/combat/combat.cpp b/src/creatures/combat/combat.cpp index a02b5babb..d0ad927e4 100644 --- a/src/creatures/combat/combat.cpp +++ b/src/creatures/combat/combat.cpp @@ -404,7 +404,13 @@ ReturnValue Combat::canTargetCreature(const std::shared_ptr &player, con return RETURNVALUE_YOUMAYNOTATTACKTHISPLAYER; } - if (player->hasSecureMode() && !Combat::isInPvpZone(player, target) && player->getSkullClient(target->getPlayer()) == SKULL_NONE) { + if (g_game().getWorldType() == WORLDTYPE_OPEN) { + // Open PvP: the expert PvP mode governs who may be targeted (supersedes secure mode) + const ReturnValue modeRet = checkExpertPvpMode(player, target->getPlayer()); + if (modeRet != RETURNVALUE_NOERROR) { + return modeRet; + } + } else if (player->hasSecureMode() && !Combat::isInPvpZone(player, target) && player->getSkullClient(target->getPlayer()) == SKULL_NONE) { return RETURNVALUE_TURNSECUREMODETOATTACKUNMARKEDPLAYERS; } } @@ -467,6 +473,73 @@ bool Combat::isProtected(const std::shared_ptr &attacker, const std::sha return false; } +ReturnValue Combat::checkExpertPvpMode(const std::shared_ptr &attacker, const std::shared_ptr &target) { + if (!attacker || !target || attacker == target || attacker->isAccessPlayer()) { + return RETURNVALUE_NOERROR; + } + + if (g_game().getWorldType() != WORLDTYPE_OPEN || Combat::isInPvpZone(attacker, target) || attacker->isInWar(target)) { + return RETURNVALUE_NOERROR; + } + + const PvpMode_t mode = attacker->getPvpMode(); + if (mode == PVP_MODE_RED_FIST) { + // red fist: attack anyone EXCEPT party/guild members + if (attacker->isPartner(target) || attacker->isGuildMate(target)) { + return RETURNVALUE_YOUMAYNOTATTACKTHISPLAYER; + } + return RETURNVALUE_NOERROR; + } + + // every mode allows self-defense: the target attacked us or we are already in a mutual PvP situation + if (target->hasAttacked(attacker) || attacker->isInPvpSituationWith(target)) { + return RETURNVALUE_NOERROR; + } + + if (mode == PVP_MODE_WHITE_HAND || mode == PVP_MODE_YELLOW_HAND) { + // white hand: defend party/guild — the target has attacked one of our party/guild members + if (target->hasAttackedAllyOf(attacker)) { + return RETURNVALUE_NOERROR; + } + } + + if (mode == PVP_MODE_YELLOW_HAND) { + // yellow hand: skulled players are fair game + if (attacker->getSkullClient(target) != SKULL_NONE) { + return RETURNVALUE_NOERROR; + } + } + + return RETURNVALUE_YOUMAYNOTATTACKTHISPLAYER; +} + +bool Combat::isOwnedFieldBystander(const std::shared_ptr &player, const std::shared_ptr &field) { + if (!player || !field || g_game().getWorldType() != WORLDTYPE_OPEN) { + return false; + } + + const auto ownerId = field->getOwnerId(); + if (ownerId == 0) { + return false; // map/GM/monster fields concern everyone + } + + auto ownerPlayer = g_game().getPlayerByGUID(ownerId); + if (!ownerPlayer) { + if (const auto &ownerCreature = g_game().getCreatureByID(ownerId)) { + if (ownerCreature->isSummon() && ownerCreature->getMaster()) { + ownerPlayer = ownerCreature->getMaster()->getPlayer(); + } + } + } + + // owner and everyone in a PvP situation with him are involved; the rest are bystanders + return ownerPlayer && ownerPlayer != player && !ownerPlayer->isInPvpSituationWith(player); +} + +bool Combat::isPveWall(const std::shared_ptr &item) { + return item && item->getCustomAttribute("pveWall") != nullptr; +} + ReturnValue Combat::canDoCombat(const std::shared_ptr &attacker, const std::shared_ptr &target, bool aggressive) { if (!aggressive) { return RETURNVALUE_NOERROR; @@ -508,6 +581,20 @@ ReturnValue Combat::canDoCombat(const std::shared_ptr &attacker, const return RETURNVALUE_YOUMAYNOTATTACKTHISPLAYER; } + // Open PvP (2014 rules): a player hidden under another player in the tile stack + // cannot INITIATE PvP — defending inside an existing situation stays allowed. + if (g_game().getWorldType() == WORLDTYPE_OPEN && !attackerPlayer->isFirstInStack() + && !attackerPlayer->isInPvpSituationWith(targetPlayer) && !targetPlayer->hasAttacked(attackerPlayer)) { + return RETURNVALUE_YOUMAYNOTATTACKTHISPLAYER; + } + + // Open PvP expert mode gate — enforced here too so dove/white hand players never + // damage innocents, not even with area-spell splash. + const ReturnValue expertRet = checkExpertPvpMode(attackerPlayer, targetPlayer); + if (expertRet != RETURNVALUE_NOERROR) { + return expertRet; + } + // nopvp-zone const auto &attackerTile = attackerPlayer->getTile(); if (targetPlayerTile && targetPlayerTile->hasFlag(TILESTATE_NOPVPZONE)) { @@ -2645,6 +2732,24 @@ void MagicField::onStepInField(const std::shared_ptr &creature) { const auto &conditionCopy = it.conditionDamage->clone(); auto ownerId = getOwnerId(); if (ownerId) { + // Open PvP (2014 rules): a player-made field harms only its caster and players in a + // PvP situation with him — bystanders walk through unharmed. + if (g_game().getWorldType() == WORLDTYPE_OPEN) { + if (const auto &steppingPlayer = creature->getPlayer()) { + auto fieldOwnerPlayer = g_game().getPlayerByGUID(ownerId); + if (!fieldOwnerPlayer) { + if (const auto &ownerCreature = g_game().getCreatureByID(ownerId)) { + if (ownerCreature->isSummon() && ownerCreature->getMaster()) { + fieldOwnerPlayer = ownerCreature->getMaster()->getPlayer(); + } + } + } + if (fieldOwnerPlayer && fieldOwnerPlayer != steppingPlayer && !fieldOwnerPlayer->isInPvpSituationWith(steppingPlayer)) { + return; + } + } + } + bool harmfulField = true; const auto &itemTile = getTile(); if (g_game().getWorldType() == WORLDTYPE_OPTIONAL || (itemTile && itemTile->hasFlag(TILESTATE_NOPVPZONE))) { diff --git a/src/creatures/combat/combat.hpp b/src/creatures/combat/combat.hpp index 1d3147bff..0f3915016 100644 --- a/src/creatures/combat/combat.hpp +++ b/src/creatures/combat/combat.hpp @@ -211,6 +211,10 @@ class Combat { static bool isInPvpZone(const std::shared_ptr &attacker, const std::shared_ptr &target); static bool isProtected(const std::shared_ptr &attacker, const std::shared_ptr &target); + // Open PvP expert modes (dove / white hand / yellow hand / red fist): who may attack whom. + static ReturnValue checkExpertPvpMode(const std::shared_ptr &attacker, const std::shared_ptr &target); + static bool isOwnedFieldBystander(const std::shared_ptr &player, const std::shared_ptr &field); + static bool isPveWall(const std::shared_ptr &item); static bool isPlayerCombat(const std::shared_ptr &target); static CombatType_t ConditionToDamageType(ConditionType_t type); static ConditionType_t DamageToConditionType(CombatType_t type); diff --git a/src/creatures/combat/spells.cpp b/src/creatures/combat/spells.cpp index 66da0e41e..a5f8f58ea 100644 --- a/src/creatures/combat/spells.cpp +++ b/src/creatures/combat/spells.cpp @@ -430,6 +430,14 @@ bool Spell::playerSpellCheck(const std::shared_ptr &player) const { return false; } + // Open PvP (2014 rules): a player hidden under another player in the tile stack + // cannot use aggressive area spells/runes — only the top ("first in stack") player can. + if (aggressive && range < 1 && g_game().getWorldType() == WORLDTYPE_OPEN && !player->isFirstInStack()) { + player->sendCancelMessage(RETURNVALUE_NOTPOSSIBLE); + g_game().addMagicEffect(player->getPosition(), CONST_ME_POFF); + return false; + } + if (aggressive && player->hasCondition(CONDITION_PACIFIED)) { player->sendCancelMessage(RETURNVALUE_YOUAREEXHAUSTED); g_game().addMagicEffect(player->getPosition(), CONST_ME_POFF); diff --git a/src/creatures/creatures_definitions.hpp b/src/creatures/creatures_definitions.hpp index 78cc7c8b7..1691e0d2a 100644 --- a/src/creatures/creatures_definitions.hpp +++ b/src/creatures/creatures_definitions.hpp @@ -1615,9 +1615,11 @@ struct Kill { uint32_t target; time_t time; bool unavenged; + // Open PvP "unfair fight" frag share: 1.0 = full frag, <1.0 with 6+ participants. + double weight; - Kill(uint32_t _target, time_t _time, bool _unavenged) : - target(_target), time(_time), unavenged(_unavenged) { } + Kill(uint32_t _target, time_t _time, bool _unavenged, double _weight = 1.0) : + target(_target), time(_time), unavenged(_unavenged), weight(_weight) { } }; struct IntervalInfo { diff --git a/src/creatures/players/player.cpp b/src/creatures/players/player.cpp index 48d95fac7..d5ebb82a4 100644 --- a/src/creatures/players/player.cpp +++ b/src/creatures/players/player.cpp @@ -1421,7 +1421,7 @@ bool Player::canWalkthrough(const std::shared_ptr &creature) { if (player) { const auto &playerTile = player->getTile(); - if (!playerTile || (!playerTile->hasFlag(TILESTATE_NOPVPZONE) && !playerTile->hasFlag(TILESTATE_PROTECTIONZONE) && player->getLevel() > static_cast(g_configManager().getNumber(PROTECTION_LEVEL)) && g_game().getWorldType() != WORLDTYPE_OPTIONAL)) { + if (!playerTile) { return false; } @@ -1430,6 +1430,16 @@ bool Player::canWalkthrough(const std::shared_ptr &creature) { return false; } + // Open PvP (2014 rules): characters never block each other — pass through freely, + // without the PZ/level restrictions or the double-step confirmation. + if (g_game().getWorldType() == WORLDTYPE_OPEN) { + return true; + } + + if (!playerTile->hasFlag(TILESTATE_NOPVPZONE) && !playerTile->hasFlag(TILESTATE_PROTECTIONZONE) && player->getLevel() > static_cast(g_configManager().getNumber(PROTECTION_LEVEL)) && g_game().getWorldType() != WORLDTYPE_OPTIONAL) { + return false; + } + const auto &thisPlayer = getPlayer(); if ((OTSYS_TIME() - lastWalkthroughAttempt) > 2000) { thisPlayer->setLastWalkthroughAttempt(OTSYS_TIME()); @@ -1469,6 +1479,11 @@ bool Player::canWalkthroughEx(const std::shared_ptr &creature) const { const auto &npc = creature->getNpc(); if (player) { const auto &playerTile = player->getTile(); + // Open PvP (2014 rules): characters never block each other. + if (g_game().getWorldType() == WORLDTYPE_OPEN) { + const auto &playerTileGround = playerTile ? playerTile->getGround() : nullptr; + return playerTileGround && playerTileGround->hasWalkStack(); + } return playerTile && (playerTile->hasFlag(TILESTATE_NOPVPZONE) || playerTile->hasFlag(TILESTATE_PROTECTIONZONE) || player->getLevel() <= static_cast(g_configManager().getNumber(PROTECTION_LEVEL)) || g_game().getWorldType() == WORLDTYPE_OPTIONAL); } else if (npc) { const auto &tile = npc->getTile(); @@ -6355,12 +6370,25 @@ void Player::onAttackedCreature(const std::shared_ptr &target) { const auto &targetPlayer = target->getPlayer(); if (targetPlayer && !isPartner(targetPlayer) && !isGuildMate(targetPlayer)) { + // Open PvP: any aggressive act creates/refreshes a mutual PvP situation between the two + // players (drives field damage, magic wall blocking and the PvP situation boxes). + if (!Combat::isInPvpZone(static_self_cast(), targetPlayer)) { + addPvpSituationWith(targetPlayer); + } + if (!pzLocked && g_game().getWorldType() == WORLDTYPE_HARDCORE) { pzLocked = true; sendIcons(); } - if (getSkull() == SKULL_NONE && getSkullClient(targetPlayer) == SKULL_YELLOW) { + if (getSkull() == SKULL_NONE && (getSkullClient(targetPlayer) == SKULL_YELLOW || targetPlayer->getSkull() != SKULL_NONE)) { + // Open PvP: retaliating against our aggressor or attacking an already SKULLED player is + // justified — no white skull for us, but the attacked player sees us with a yellow skull + // ("obtained when attacking skulled players; visible only to the attacked player"). + if (targetPlayer->getSkull() != SKULL_NONE && !pzLocked) { + pzLocked = true; + sendIcons(); + } addAttacked(targetPlayer); targetPlayer->sendCreatureSkull(static_self_cast()); } else if (!targetPlayer->hasAttacked(static_self_cast())) { @@ -7050,25 +7078,29 @@ Skulls_t Player::getSkullClient(const std::shared_ptr &creature) { } const auto &player = creature->getPlayer(); - if (player && player->getSkull() == SKULL_NONE) { - if (player.get() == this) { - if (std::ranges::any_of(unjustifiedKills, [&](const auto &kill) { - return kill.unavenged && (getTimeNow() - kill.time) < g_configManager().getNumber(ORANGE_SKULL_DURATION) * 24 * 60 * 60; - })) { - return SKULL_ORANGE; - } - } - - if (player->hasKilled(getPlayer())) { + if (player) { + // Open PvP: an unjustified killer is marked ORANGE to his victim ("visible only between + // involved parties") — it overrides the killer's own white skull, but not red/black. + if (player.get() != this && player->getSkull() <= SKULL_WHITE && player->hasKilled(getPlayer())) { return SKULL_ORANGE; } - if (player->hasAttacked(getPlayer())) { - return SKULL_YELLOW; - } + if (player->getSkull() == SKULL_NONE) { + if (player.get() == this) { + if (std::ranges::any_of(unjustifiedKills, [&](const auto &kill) { + return kill.unavenged && (getTimeNow() - kill.time) < g_configManager().getNumber(ORANGE_SKULL_DURATION) * 24 * 60 * 60; + })) { + return SKULL_ORANGE; + } + } + + if (player->hasAttacked(getPlayer())) { + return SKULL_YELLOW; + } - if (m_party && m_party == player->m_party) { - return SKULL_GREEN; + if (m_party && m_party == player->m_party) { + return SKULL_GREEN; + } } } return Creature::getSkullClient(creature); @@ -7098,6 +7130,20 @@ void Player::addAttacked(const std::shared_ptr &attacked) { attackedSet.emplace(attacked->guid); } +bool Player::hasAttackedAllyOf(const std::shared_ptr &defender) const { + if (!defender) { + return false; + } + + for (const auto attackedGuid : attackedSet) { + const auto &victim = g_game().getPlayerByGUID(attackedGuid); + if (victim && (defender->isPartner(victim) || defender->isGuildMate(victim))) { + return true; + } + } + return false; +} + void Player::removeAttacked(const std::shared_ptr &attacked) { if (!attacked || attacked == getPlayer()) { return; @@ -7110,6 +7156,111 @@ void Player::clearAttacked() { attackedSet.clear(); } +void Player::addPvpSituationWith(const std::shared_ptr &other) { + if (!other || other.get() == this || hasFlag(PlayerFlags_t::NotGainInFight) || other->hasFlag(PlayerFlags_t::NotGainInFight)) { + return; + } + + const int64_t expiry = OTSYS_TIME() + g_configManager().getNumber(PZ_LOCKED); + pvpSituations[other->getGUID()] = expiry; + other->pvpSituations[getGUID()] = expiry; + + // refresh the situation boxes right away (no-op unless the active-pair count changed) + updatePvpSituationMarks(); + other->updatePvpSituationMarks(); +} + +bool Player::isInPvpSituationWith(const std::shared_ptr &other) const { + if (!other) { + return false; + } + + const auto it = pvpSituations.find(other->getGUID()); + return it != pvpSituations.end() && it->second > OTSYS_TIME(); +} + +bool Player::hasActivePvpSituation() const { + const int64_t now = OTSYS_TIME(); + for (const auto &[guid, expiry] : pvpSituations) { + if (expiry > now) { + return true; + } + } + return false; +} + +PvPBox_t Player::getPvpSituationMarkFor(const std::shared_ptr &viewer) const { + if (g_game().getWorldType() != WORLDTYPE_OPEN || !viewer) { + return PvPBox_t::PVP_BOX_NONE; + } + + const int64_t now = OTSYS_TIME(); + bool anyActive = false; + bool viewerAlliedWithAdversary = false; + for (const auto &[guid, expiry] : pvpSituations) { + if (expiry <= now) { + continue; + } + anyActive = true; + if (viewer->getGUID() == guid) { + return PvPBox_t::PVP_BOX_YELLOW; // the viewer is my adversary + } + if (!viewerAlliedWithAdversary) { + if (const auto &adversary = g_game().getPlayerByGUID(guid)) { + if (viewer->isPartner(adversary) || viewer->isGuildMate(adversary)) { + viewerAlliedWithAdversary = true; + } + } + } + } + + if (!anyActive) { + return PvPBox_t::PVP_BOX_NONE; + } + if (viewer.get() == this) { + return PvPBox_t::PVP_BOX_YELLOW; // own frame while flagged + } + return viewerAlliedWithAdversary ? PvPBox_t::PVP_BOX_ORANGE : PvPBox_t::PVP_BOX_BROWN; +} + +void Player::updatePvpSituationMarks() { + if (g_game().getWorldType() != WORLDTYPE_OPEN) { + return; + } + + // refresh our creature block for all viewers whenever the set of active pairs changes + // (fight starts, a new adversary joins, situations expire) — each viewer recomputes its color + size_t activePairs = 0; + const int64_t now = OTSYS_TIME(); + for (const auto &[guid, expiry] : pvpSituations) { + if (expiry > now) { + ++activePairs; + } + } + + if (activePairs != pvpActivePairs) { + pvpActivePairs = activePairs; + g_game().sendUpdateCreature(static_self_cast()); + } +} + +bool Player::isFirstInStack() const { + const auto &tile = getTile(); + if (!tile) { + return true; + } + + if (const CreatureVector* creatures = tile->getCreatures()) { + for (const auto &tileCreature : *creatures) { + if (const auto &tilePlayer = tileCreature->getPlayer()) { + // creatures are inserted at the front, so the first player found is the top one + return tilePlayer.get() == this; + } + } + } + return true; +} + void Player::addUnjustifiedDead(const std::shared_ptr &attacked) { if (hasFlag(PlayerFlags_t::NotGainInFight) || hasFlag(PlayerFlags_t::NotGainUnjustified) || attacked == getPlayer() || g_game().getWorldType() == WORLDTYPE_HARDCORE) { return; @@ -7117,22 +7268,50 @@ void Player::addUnjustifiedDead(const std::shared_ptr &attacked) { sendTextMessage(MESSAGE_EVENT_ADVANCE, "Warning! The murder of " + attacked->getName() + " was not justified."); - unjustifiedKills.emplace_back(attacked->getGUID(), time(nullptr), true); + // Open PvP (2014 rules) "unfair fight": with 5 or fewer player participants everyone gets the + // full frag; with 6+ each participant only receives a share of it. + double killWeight = 1.0; + { + const int64_t timeNow = OTSYS_TIME(); + const auto inFightTicks = static_cast(g_configManager().getNumber(PZ_LOCKED)); + phmap::flat_hash_set participants; + for (const auto &[attackerId, damageInfo] : attacked->damageMap) { + const auto &[total, ticks] = damageInfo; + if (total == 0 || static_cast(timeNow - ticks) > inFightTicks) { + continue; + } + const auto &attackerCreature = g_game().getCreatureByID(attackerId); + if (!attackerCreature) { + continue; + } + const auto &master = attackerCreature->getMaster() ? attackerCreature->getMaster() : attackerCreature; + if (const auto &participant = master->getPlayer()) { + if (participant != attacked) { + participants.insert(participant->getGUID()); + } + } + } + if (participants.size() > 5) { + killWeight = 5.0 / static_cast(participants.size()); + } + } + + unjustifiedKills.emplace_back(attacked->getGUID(), time(nullptr), true, killWeight); - uint8_t dayKills = 0; - uint8_t weekKills = 0; - uint8_t monthKills = 0; + double dayKills = 0; + double weekKills = 0; + double monthKills = 0; for (const auto &kill : unjustifiedKills) { const auto diff = time(nullptr) - kill.time; - if (diff <= 4 * 60 * 60) { - dayKills += 1; + if (diff <= 24 * 60 * 60) { + dayKills += kill.weight; } if (diff <= 7 * 24 * 60 * 60) { - weekKills += 1; + weekKills += kill.weight; } if (diff <= 30 * 24 * 60 * 60) { - monthKills += 1; + monthKills += kill.weight; } } @@ -7801,13 +7980,13 @@ void Player::sendUnjustifiedPoints() const { for (const auto &kill : unjustifiedKills) { const auto diff = time(nullptr) - kill.time; if (diff <= 24 * 60 * 60) { - dayKills += 1; + dayKills += kill.weight; } if (diff <= 7 * 24 * 60 * 60) { - weekKills += 1; + weekKills += kill.weight; } if (diff <= 30 * 24 * 60 * 60) { - monthKills += 1; + monthKills += kill.weight; } } @@ -8538,6 +8717,8 @@ void Player::onThink(uint32_t interval) { triggerTranscendence(); // Momentum (cooldown resets) triggerMomentum(); + // Open PvP situation boxes (1 Hz refresh + clear on expiry) + updatePvpSituationMarks(); const auto &playerTile = getTile(); const bool vipStaysOnline = isVip() && g_configManager().getBoolean(VIP_STAY_ONLINE); idleTime += interval; diff --git a/src/creatures/players/player.hpp b/src/creatures/players/player.hpp index ed021acf8..cd36c5365 100644 --- a/src/creatures/players/player.hpp +++ b/src/creatures/players/player.hpp @@ -803,10 +803,18 @@ class Player final : public Creature, public Cylinder, public Bankable { void setChaseMode(bool mode); void setFightMode(FightMode_t mode); void setSecureMode(bool mode); + // Open PvP expert mode (dove / white hand / yellow hand / red fist), sent by the 15.25 client in 0xA0. + void setPvpMode(PvpMode_t mode) { + pvpMode = mode; + } + PvpMode_t getPvpMode() const { + return pvpMode; + } Faction_t getFaction() const override; void setFaction(Faction_t factionId); + // combat functions bool setAttackedCreature(const std::shared_ptr &creature) override; bool isImmune(CombatType_t type) const override; @@ -922,9 +930,23 @@ class Player final : public Creature, public Cylinder, public Bankable { void setSkullTicks(int64_t ticks); bool hasAttacked(const std::shared_ptr &attacked) const; + // Open PvP (white hand): has this player attacked any party/guild member of `defender`? + bool hasAttackedAllyOf(const std::shared_ptr &defender) const; void addAttacked(const std::shared_ptr &attacked); void removeAttacked(const std::shared_ptr &attacked); void clearAttacked(); + // Open PvP: mutual "PvP situation" between two players, refreshed on every aggressive act. + void addPvpSituationWith(const std::shared_ptr &other); + bool isInPvpSituationWith(const std::shared_ptr &other) const; + bool hasActivePvpSituation() const; + // Open PvP situation boxes: the persistent creature-mark byte this player renders with for + // `viewer` (yellow = participant, orange = fights viewer's party/guild mate, brown = fights + // somebody else, 0xFF = none). Sent in the creature description; refreshed on state changes. + PvPBox_t getPvpSituationMarkFor(const std::shared_ptr &viewer) const; + // 1 Hz: re-send our creature block to viewers whenever the set of active situations changes. + void updatePvpSituationMarks(); + // Open PvP: true when no other player is stacked on top of us on our tile. + bool isFirstInStack() const; void addUnjustifiedDead(const std::shared_ptr &attacked); void sendCreatureEmblem(const std::shared_ptr &creature) const; void sendCreatureSkull(const std::shared_ptr &creature) const; @@ -1762,6 +1784,10 @@ class Player final : public Creature, public Cylinder, public Bankable { void addBosstiaryKill(const std::shared_ptr &mType); phmap::flat_hash_set attackedSet {}; + // Open PvP: pairwise "PvP situation" state (guid -> expiry, OTSYS_TIME ms). Mutual aggression + // puts both players in a situation; fields/magic walls/boxes key off it. Runtime-only. + phmap::flat_hash_map pvpSituations {}; + size_t pvpActivePairs = 0; // last broadcast count of active situations (box refresh detection) std::map openContainers; std::map> depotLockerMap; @@ -1957,6 +1983,7 @@ class Player final : public Creature, public Cylinder, public Bankable { BlockType_t lastAttackBlockType = BLOCK_NONE; TradeState_t tradeState = TRADE_NONE; FightMode_t fightMode = FIGHTMODE_ATTACK; + PvpMode_t pvpMode = PVP_MODE_DOVE; Faction_t faction = FACTION_PLAYER; QuickLootFilter_t quickLootFilter {}; PlayerPronoun_t pronoun = PLAYERPRONOUN_THEY; diff --git a/src/game/game.cpp b/src/game/game.cpp index cdc4927c8..06c3e8e95 100644 --- a/src/game/game.cpp +++ b/src/game/game.cpp @@ -6264,7 +6264,7 @@ void Game::playerFollowCreature(uint32_t playerId, uint32_t creatureId) { player->setFollowCreature(getCreatureByID(creatureId)); } -void Game::playerSetFightModes(uint32_t playerId, FightMode_t fightMode, bool chaseMode, bool secureMode) { +void Game::playerSetFightModes(uint32_t playerId, FightMode_t fightMode, bool chaseMode, bool secureMode, PvpMode_t pvpMode) { const auto &player = getPlayerByID(playerId); if (!player) { return; @@ -6273,6 +6273,7 @@ void Game::playerSetFightModes(uint32_t playerId, FightMode_t fightMode, bool ch player->setFightMode(fightMode); player->setChaseMode(chaseMode); player->setSecureMode(secureMode); + player->setPvpMode(pvpMode); } void Game::playerRequestAddVip(uint32_t playerId, const std::string &name) { diff --git a/src/game/game.hpp b/src/game/game.hpp index 12a1639d2..464aac415 100644 --- a/src/game/game.hpp +++ b/src/game/game.hpp @@ -420,7 +420,7 @@ class Game { void playerSetAttackedCreature(uint32_t playerId, uint32_t creatureId); void playerFollowCreature(uint32_t playerId, uint32_t creatureId); void playerCancelAttackAndFollow(uint32_t playerId); - void playerSetFightModes(uint32_t playerId, FightMode_t fightMode, bool chaseMode, bool secureMode); + void playerSetFightModes(uint32_t playerId, FightMode_t fightMode, bool chaseMode, bool secureMode, PvpMode_t pvpMode = PVP_MODE_DOVE); void playerLookAt(uint32_t playerId, uint16_t itemId, const Position &pos, uint8_t stackPos); void playerLookInBattleList(uint32_t playerId, uint32_t creatureId); void playerQuickLootCorpse(const std::shared_ptr &player, const std::shared_ptr &corpse, const Position &position); diff --git a/src/io/functions/iologindata_load_player.cpp b/src/io/functions/iologindata_load_player.cpp index c7219567f..2940dc026 100644 --- a/src/io/functions/iologindata_load_player.cpp +++ b/src/io/functions/iologindata_load_player.cpp @@ -419,12 +419,15 @@ void IOLoginDataLoad::loadPlayerKills(const std::shared_ptr &player, DBR Database &db = Database::getInstance(); std::ostringstream query; - query << "SELECT `player_id`, `time`, `target`, `unavenged` FROM `player_kills` WHERE `player_id` = " << player->getGUID(); + query << "SELECT `player_id`, `time`, `target`, `unavenged`, `weight` FROM `player_kills` WHERE `player_id` = " << player->getGUID(); if ((result = db.storeQuery(query.str()))) { do { auto killTime = result->getNumber("time"); - if ((time(nullptr) - killTime) <= g_configManager().getNumber(FRAG_TIME)) { - player->unjustifiedKills.emplace_back(result->getNumber("target"), killTime, result->getNumber("unavenged")); + // keep the full 30-day window: the month frag bar counts it and the orange skull + // (unavenged kills) lasts 7 days — the old FRAG_TIME (24h) gate dropped both on relog + if ((time(nullptr) - killTime) <= 30 * 24 * 60 * 60) { + const double weight = std::clamp(static_cast(result->getNumber("weight")) / 100.0, 0.0, 1.0); + player->unjustifiedKills.emplace_back(result->getNumber("target"), killTime, result->getNumber("unavenged"), weight); } } while (result->next()); } diff --git a/src/io/functions/iologindata_save_player.cpp b/src/io/functions/iologindata_save_player.cpp index 9879a62a2..6235cca6a 100644 --- a/src/io/functions/iologindata_save_player.cpp +++ b/src/io/functions/iologindata_save_player.cpp @@ -453,9 +453,11 @@ bool IOLoginDataSave::savePlayerKills(const std::shared_ptr &player) { query.str(""); - DBInsert killsQuery("INSERT INTO `player_kills` (`player_id`, `target`, `time`, `unavenged`) VALUES"); + DBInsert killsQuery("INSERT INTO `player_kills` (`player_id`, `target`, `time`, `unavenged`, `weight`) VALUES"); for (const auto &kill : player->unjustifiedKills) { - query << player->getGUID() << ',' << kill.target << ',' << kill.time << ',' << kill.unavenged; + // frag-share weight persisted as a percentage (100 = full frag) to avoid float columns + const auto weightPercent = static_cast(std::clamp(std::round(kill.weight * 100.0), 0.0, 100.0)); + query << player->getGUID() << ',' << kill.target << ',' << kill.time << ',' << kill.unavenged << ',' << weightPercent; if (!killsQuery.addRow(query)) { return false; } diff --git a/src/items/tile.cpp b/src/items/tile.cpp index 3542f2135..032f7212e 100644 --- a/src/items/tile.cpp +++ b/src/items/tile.cpp @@ -772,12 +772,23 @@ ReturnValue Tile::queryAdd(int32_t, const std::shared_ptr &thing, uint32_ if (creature && creature->getPlayer()) { if (const auto fieldList = getItemList()) { for (const auto &findfield : *fieldList) { - if (findfield && (findfield->getID() == ITEM_WILDGROWTH_SAFE || findfield->getID() == ITEM_MAGICWALL_SAFE)) { + if (!findfield) { + continue; + } + + if (findfield->getID() == ITEM_WILDGROWTH_SAFE || findfield->getID() == ITEM_MAGICWALL_SAFE) { if (!creature->isInGhostMode()) { g_game().internalRemoveItem(findfield, 1); } return RETURNVALUE_NOERROR; } + + // Open PvP (2014 rules): a magic wall / wild growth blocks only its owner and + // players in a PvP situation with him — everyone else walks through while the + // wall stays up for those it does block. + if ((findfield->getID() == ITEM_MAGICWALL || findfield->getID() == ITEM_WILDGROWTH) && (Combat::isPveWall(findfield) || Combat::isOwnedFieldBystander(creature->getPlayer(), findfield))) { + return RETURNVALUE_NOERROR; + } } } } diff --git a/src/lua/functions/creatures/player/player_functions.cpp b/src/lua/functions/creatures/player/player_functions.cpp index f765a84c2..03aea5225 100644 --- a/src/lua/functions/creatures/player/player_functions.cpp +++ b/src/lua/functions/creatures/player/player_functions.cpp @@ -149,6 +149,8 @@ void PlayerFunctions::init(lua_State* L) { Lua::registerMethod(L, "Player", "getKills", PlayerFunctions::luaPlayerGetKills); Lua::registerMethod(L, "Player", "setKills", PlayerFunctions::luaPlayerSetKills); + Lua::registerMethod(L, "Player", "sendCreatureSquare", PlayerFunctions::luaPlayerSendCreatureSquare); + Lua::registerMethod(L, "Player", "hasActivePvpSituation", PlayerFunctions::luaPlayerHasActivePvpSituation); Lua::registerMethod(L, "Player", "getReward", PlayerFunctions::luaPlayerGetReward); Lua::registerMethod(L, "Player", "removeReward", PlayerFunctions::luaPlayerRemoveReward); @@ -1419,13 +1421,15 @@ int PlayerFunctions::luaPlayerGetKills(lua_State* L) { lua_createtable(L, player->unjustifiedKills.size(), 0); int idx = 0; for (const auto &kill : player->unjustifiedKills) { - lua_createtable(L, 3, 0); + lua_createtable(L, 4, 0); lua_pushnumber(L, kill.target); lua_rawseti(L, -2, 1); lua_pushnumber(L, kill.time); lua_rawseti(L, -2, 2); Lua::pushBoolean(L, kill.unavenged); lua_rawseti(L, -2, 3); + lua_pushnumber(L, kill.weight); + lua_rawseti(L, -2, 4); lua_rawseti(L, -2, ++idx); } @@ -1450,8 +1454,10 @@ int PlayerFunctions::luaPlayerSetKills(lua_State* L) { lua_rawgeti(L, -1, 1); // push target lua_rawgeti(L, -2, 2); // push time lua_rawgeti(L, -3, 3); // push unavenged - newKills.emplace_back(luaL_checknumber(L, -3), luaL_checknumber(L, -2), Lua::getBoolean(L, -1)); - lua_pop(L, 4); + lua_rawgeti(L, -4, 4); // push weight (optional, defaults to a full frag) + const double weight = lua_isnumber(L, -1) != 0 ? lua_tonumber(L, -1) : 1.0; + newKills.emplace_back(luaL_checknumber(L, -4), luaL_checknumber(L, -3), Lua::getBoolean(L, -2), weight); + lua_pop(L, 5); } player->unjustifiedKills = std::move(newKills); @@ -1460,6 +1466,39 @@ int PlayerFunctions::luaPlayerSetKills(lua_State* L) { return 1; } +int PlayerFunctions::luaPlayerSendCreatureSquare(lua_State* L) { + // player:sendCreatureSquare(creature, markType[, weaponType = 255]) + const auto &player = Lua::getUserdataShared(L, 1); + if (!player) { + Lua::pushBoolean(L, false); + return 1; + } + + const auto &creature = Lua::getUserdataShared(L, 2); + if (!creature) { + Lua::pushBoolean(L, false); + return 1; + } + + const auto markType = Lua::getNumber(L, 3, 0); + const auto weaponType = Lua::getNumber(L, 4, 255); + player->sendCreatureSquare(creature, static_cast(markType), static_cast(weaponType)); + Lua::pushBoolean(L, true); + return 1; +} + +int PlayerFunctions::luaPlayerHasActivePvpSituation(lua_State* L) { + // player:hasActivePvpSituation() — Open PvP: any non-expired PvP situation with another player + const auto &player = Lua::getUserdataShared(L, 1); + if (!player) { + lua_pushnil(L); + return 1; + } + + Lua::pushBoolean(L, player->hasActivePvpSituation()); + return 1; +} + int PlayerFunctions::luaPlayerGetReward(lua_State* L) { // player:getReward(rewardId[, autoCreate = false]) const auto &player = Lua::getUserdataShared(L, 1); diff --git a/src/lua/functions/creatures/player/player_functions.hpp b/src/lua/functions/creatures/player/player_functions.hpp index c43141be1..e1876e918 100644 --- a/src/lua/functions/creatures/player/player_functions.hpp +++ b/src/lua/functions/creatures/player/player_functions.hpp @@ -122,6 +122,8 @@ class PlayerFunctions { static int luaPlayerGetKills(lua_State* L); static int luaPlayerSetKills(lua_State* L); + static int luaPlayerSendCreatureSquare(lua_State* L); + static int luaPlayerHasActivePvpSituation(lua_State* L); static int luaPlayerGetFreeCapacity(lua_State* L); diff --git a/src/server/network/protocol/protocolgame.cpp b/src/server/network/protocol/protocolgame.cpp index ecc63f023..5c421714f 100644 --- a/src/server/network/protocol/protocolgame.cpp +++ b/src/server/network/protocol/protocolgame.cpp @@ -22,6 +22,7 @@ #include "core.hpp" #include "creatures/appearance/mounts/mounts.hpp" #include "creatures/appearance/attached_effects/attached_effects.hpp" +#include "creatures/combat/combat.hpp" #include "creatures/combat/condition.hpp" #include "creatures/combat/spells.hpp" #include "creatures/interactions/chat.hpp" @@ -475,7 +476,42 @@ void ProtocolGame::AddItem(NetworkMessage &msg, const std::shared_ptr &ite const ItemType &it = Item::items[item->getID()]; - msg.add(it.id); + uint16_t wireId = it.id; + // Open PvP (2014 rules): player-made fields and walls are shown per-viewer. A bystander (no PvP + // situation with the owner) sees the harmless "nopvp"/"safe" variant — fields signal they deal + // no damage, walls become walkable client-side (the server still arbitrates in Tile::queryAdd). + // The owner and players in a PvP situation with him see the dangerous/solid originals. + if (!oldProtocol && player) { + switch (wireId) { + case ITEM_MAGICWALL: + case ITEM_WILDGROWTH: + case ITEM_FIREFIELD_PVP_FULL: + case ITEM_FIREFIELD_PVP_MEDIUM: + case ITEM_FIREFIELD_PVP_SMALL: + case ITEM_POISONFIELD_PVP: + case ITEM_ENERGYFIELD_PVP: { + // PvE walls (cast outside a PvP situation) look walkable to everyone, caster included + if (!Combat::isPveWall(item) && !Combat::isOwnedFieldBystander(player, item)) { + break; + } + if (wireId == ITEM_MAGICWALL) { + wireId = ITEM_MAGICWALL_SAFE; + } else if (wireId == ITEM_WILDGROWTH) { + wireId = ITEM_WILDGROWTH_SAFE; + } else if (wireId == ITEM_POISONFIELD_PVP) { + wireId = ITEM_POISONFIELD_NOPVP; + } else if (wireId == ITEM_ENERGYFIELD_PVP) { + wireId = ITEM_ENERGYFIELD_NOPVP; + } else { + wireId = ITEM_FIREFIELD_NOPVP; + } + break; + } + default: + break; + } + } + msg.add(wireId); if (oldProtocol) { msg.addByte(0xFF); @@ -2193,7 +2229,15 @@ void ProtocolGame::parseFightModes(NetworkMessage &msg) { uint8_t rawChaseMode = msg.getByte(); // 0 - stand while fightning, 1 - chase opponent uint8_t rawSecureMode = msg.getByte(); // 0 - can't attack unmarked, 1 - can attack unmarked - g_game().playerSetFightModes(player->getID(), FIGHTMODE_ATTACK, rawChaseMode != 0, rawSecureMode != 0); + // 15.25 tail (RE 2026-07-02): [pvpMode:u8][junk:u8] — 0 dove, 1 white hand, 2 yellow hand, 3 red fist. + uint8_t rawPvpMode = PVP_MODE_DOVE; + const int32_t tailRemaining = static_cast(msg.getLength()) - (static_cast(msg.getBufferPosition()) - 7); + if (tailRemaining > 0) { + rawPvpMode = msg.getByte(); + } + const auto pvpMode = rawPvpMode <= PVP_MODE_RED_FIST ? static_cast(rawPvpMode) : PVP_MODE_DOVE; + + g_game().playerSetFightModes(player->getID(), FIGHTMODE_ATTACK, rawChaseMode != 0, rawSecureMode != 0, pvpMode); } void ProtocolGame::parseAttack(NetworkMessage &msg) { @@ -7838,7 +7882,7 @@ void ProtocolGame::sendFightModes() { msg.addByte(player->fightMode); msg.addByte(player->chaseMode); msg.addByte(player->secureMode); - msg.addByte(PVP_MODE_DOVE); + msg.addByte(player->getPvpMode()); writeToOutputBuffer(msg); } @@ -7906,8 +7950,10 @@ void ProtocolGame::sendAddCreature(const std::shared_ptr &creature, co } } - msg.addByte(0x00); // can change pvp framing option - msg.addByte(0x00); // expert mode button enabled + // Open PvP: unlock the client's expert PvP controls (the "E" button + dove/white/yellow/red modes) + const bool expertPvpControls = g_game().getWorldType() == WORLDTYPE_OPEN; + msg.addByte(expertPvpControls ? 0x01 : 0x00); // can change pvp framing option + msg.addByte(expertPvpControls ? 0x01 : 0x00); // expert mode button enabled msg.addString(g_configManager().getString(STORE_IMAGES_URL)); msg.add(static_cast(g_configManager().getNumber(STORE_COIN_PACKET))); @@ -8968,7 +9014,10 @@ void ProtocolGame::AddCreature(NetworkMessage &msg, const std::shared_ptrgetSpeechBubble(); msg.addByte(oldProtocol && bubble == SPEECHBUBBLE_HIRELING ? static_cast(SPEECHBUBBLE_NONE) : bubble); - msg.addByte(0xFF); // MARK_UNMARKED + // Open PvP situation boxes: persistent creature mark, computed per viewer + // (yellow = participant, orange = fights the viewer's party/guild mate, brown = other fights) + const auto &markedPlayer = creature->getPlayer(); + msg.addByte(markedPlayer ? markedPlayer->getPvpSituationMarkFor(player) : 0xFF); if (!oldProtocol) { msg.addByte(0x00); // inspection type } else { diff --git a/src/utils/utils_definitions.hpp b/src/utils/utils_definitions.hpp index d42e3ae63..fa0106f31 100644 --- a/src/utils/utils_definitions.hpp +++ b/src/utils/utils_definitions.hpp @@ -451,6 +451,14 @@ enum SquareColor_t : uint8_t { SQ_CREATURE_SQUARE_LEGACY = 255, // omit weaponType to send legacy border mark (opcode 0x93 + 0x01 + color) }; +enum PvPBox_t : uint8_t { + // Open PvP situation boxes — classic 6x6x6 palette indices (tune after in-game verification) + PVP_BOX_YELLOW = 210, + PVP_BOX_ORANGE = 198, + PVP_BOX_BROWN = 114, + PVP_BOX_NONE = 0xFF, // MARK_UNMARKED +}; + enum TextColor_t : uint8_t { TEXTCOLOR_BLUE = 5, TEXTCOLOR_LIGHTGREEN = 30, From 79fe958a920e1b6cd61dd8eaf775c85ed0277aca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo?= Date: Sat, 18 Jul 2026 14:57:12 -0300 Subject: [PATCH 2/3] Add Expert PvP mode --- config.lua.dist | 6 +- data/scripts/runes/magic_wall.lua | 8 +- data/scripts/runes/wild_growth.lua | 8 +- src/creatures/combat/combat.cpp | 118 ++++++++++-------- src/creatures/combat/combat.hpp | 20 +++ src/creatures/combat/spells.cpp | 2 +- src/creatures/players/player.cpp | 13 +- src/crystalserver.cpp | 13 +- src/game/game.cpp | 14 +++ src/game/game.hpp | 14 +++ src/server/network/protocol/protocolgame.cpp | 7 +- src/utils/utils_definitions.hpp | 5 +- tests/unit/CMakeLists.txt | 1 + tests/unit/creatures/CMakeLists.txt | 3 + tests/unit/creatures/combat/pvp_mode_test.cpp | 51 ++++++++ 15 files changed, 211 insertions(+), 72 deletions(-) create mode 100644 tests/unit/creatures/CMakeLists.txt create mode 100644 tests/unit/creatures/combat/pvp_mode_test.cpp diff --git a/config.lua.dist b/config.lua.dist index 13375723e..3db301f81 100644 --- a/config.lua.dist +++ b/config.lua.dist @@ -25,10 +25,12 @@ toggleMaintainMode = false maintainModeMessage = "" -- Combat settings --- NOTE: valid values for worldType are: "pvp", "no-pvp" and "pvp-enforced" +-- NOTE: valid values for worldType are: "expert-pvp", "retro-pvp", "no-pvp" and "pvp-enforced" +-- NOTE: "expert-pvp" = full 2014 Open PvP (pass-through, situation boxes, dove/white/yellow/red hand modes, first-in-stack) +-- NOTE: "retro-pvp" (aliases "pvp"/"open") = plain Open PvP without the 2014 Expert extras -- NOTE: removeBeginningWeaponAmmunition: spears, arrows, bolt have endless ammo (allows training for paladins) -- NOTE: refundManaOnBeginningWeapons: wand of vortex and snakebite refund mana used (allows training for mages) -worldType = "pvp" +worldType = "expert-pvp" hotkeyAimbotEnabled = true protectionLevel = 7 pzLocked = 60 * 1000 diff --git a/data/scripts/runes/magic_wall.lua b/data/scripts/runes/magic_wall.lua index ba41e29fe..6e1d1e83a 100644 --- a/data/scripts/runes/magic_wall.lua +++ b/data/scripts/runes/magic_wall.lua @@ -23,11 +23,13 @@ function onCreateMagicWall(creature, position) if item then item:setDuration(16, 24) item:setAttribute(ITEM_ATTRIBUTE_DESCRIPTION, string.format("Casted by: %s", creature:getName())) - -- Open PvP (2014 rules): ownership decides who the wall blocks and who walks through - item:setOwner(creature) + -- Open PvP (2014 rules): ownership decides who the wall blocks and who walks through. + -- Normalize summon casts to the master so the owner is always a stable player GUID. + local fieldOwner = creature:getMaster() or creature + item:setOwner(fieldOwner) -- Cast outside a PvP situation -> PvE wall: blocks only monsters, every player -- (including the caster) walks through it - if creature:isPlayer() and not creature:hasActivePvpSituation() then + if fieldOwner:isPlayer() and not fieldOwner:hasActivePvpSituation() then item:setCustomAttribute("pveWall", true) end end diff --git a/data/scripts/runes/wild_growth.lua b/data/scripts/runes/wild_growth.lua index ac43d2cff..42e43a619 100644 --- a/data/scripts/runes/wild_growth.lua +++ b/data/scripts/runes/wild_growth.lua @@ -23,11 +23,13 @@ function onCreateWildGrowth(creature, position) if item then item:setDuration(30) item:setAttribute(ITEM_ATTRIBUTE_DESCRIPTION, string.format("Casted by: %s", creature:getName())) - -- Open PvP (2014 rules): ownership decides who the growth blocks, who passes and who may cut it - item:setOwner(creature) + -- Open PvP (2014 rules): ownership decides who the growth blocks, who passes and who may cut it. + -- Normalize summon casts to the master so the owner is always a stable player GUID. + local fieldOwner = creature:getMaster() or creature + item:setOwner(fieldOwner) -- Cast outside a PvP situation -> PvE growth: blocks only monsters, every player -- (including the caster) walks through it - if creature:isPlayer() and not creature:hasActivePvpSituation() then + if fieldOwner:isPlayer() and not fieldOwner:hasActivePvpSituation() then item:setCustomAttribute("pveWall", true) end end diff --git a/src/creatures/combat/combat.cpp b/src/creatures/combat/combat.cpp index d0ad927e4..aa07ff2e2 100644 --- a/src/creatures/combat/combat.cpp +++ b/src/creatures/combat/combat.cpp @@ -404,7 +404,7 @@ ReturnValue Combat::canTargetCreature(const std::shared_ptr &player, con return RETURNVALUE_YOUMAYNOTATTACKTHISPLAYER; } - if (g_game().getWorldType() == WORLDTYPE_OPEN) { + if (g_game().isExpertPvp()) { // Open PvP: the expert PvP mode governs who may be targeted (supersedes secure mode) const ReturnValue modeRet = checkExpertPvpMode(player, target->getPlayer()); if (modeRet != RETURNVALUE_NOERROR) { @@ -473,48 +473,73 @@ bool Combat::isProtected(const std::shared_ptr &attacker, const std::sha return false; } +bool pvpModeAllows(PvpMode_t mode, const PvpFacts &facts) { + if (mode == PVP_MODE_RED_FIST) { + // red fist: attack anyone EXCEPT party/guild members + return !facts.partner && !facts.guildMate; + } + + // every remaining mode allows self-defense: the target attacked us or we are already in a mutual PvP situation + if (facts.targetAttackedMe || facts.inSituation) { + return true; + } + + // white/yellow hand: defend party/guild by allowing attacks on someone who hit one of our members + if ((mode == PVP_MODE_WHITE_HAND || mode == PVP_MODE_YELLOW_HAND) && facts.attackedAllyOfMe) { + return true; + } + + // yellow hand: skulled players are fair game + if (mode == PVP_MODE_YELLOW_HAND && facts.targetSkulled) { + return true; + } + + return false; +} + ReturnValue Combat::checkExpertPvpMode(const std::shared_ptr &attacker, const std::shared_ptr &target) { if (!attacker || !target || attacker == target || attacker->isAccessPlayer()) { return RETURNVALUE_NOERROR; } - if (g_game().getWorldType() != WORLDTYPE_OPEN || Combat::isInPvpZone(attacker, target) || attacker->isInWar(target)) { + if (!g_game().isExpertPvp() || Combat::isInPvpZone(attacker, target) || attacker->isInWar(target)) { return RETURNVALUE_NOERROR; } const PvpMode_t mode = attacker->getPvpMode(); - if (mode == PVP_MODE_RED_FIST) { - // red fist: attack anyone EXCEPT party/guild members - if (attacker->isPartner(target) || attacker->isGuildMate(target)) { - return RETURNVALUE_YOUMAYNOTATTACKTHISPLAYER; - } - return RETURNVALUE_NOERROR; - } - // every mode allows self-defense: the target attacked us or we are already in a mutual PvP situation - if (target->hasAttacked(attacker) || attacker->isInPvpSituationWith(target)) { - return RETURNVALUE_NOERROR; - } + // gather the decision facts from the live players, then defer to the pure decision matrix. All of + // these accessors are side-effect-free reads, so evaluating them eagerly does not change behavior. + PvpFacts facts; + facts.targetAttackedMe = target->hasAttacked(attacker); + facts.inSituation = attacker->isInPvpSituationWith(target); + facts.targetSkulled = attacker->getSkullClient(target) != SKULL_NONE; + facts.attackedAllyOfMe = target->hasAttackedAllyOf(attacker); + facts.partner = attacker->isPartner(target); + facts.guildMate = attacker->isGuildMate(target); - if (mode == PVP_MODE_WHITE_HAND || mode == PVP_MODE_YELLOW_HAND) { - // white hand: defend party/guild — the target has attacked one of our party/guild members - if (target->hasAttackedAllyOf(attacker)) { - return RETURNVALUE_NOERROR; - } - } + return pvpModeAllows(mode, facts) ? RETURNVALUE_NOERROR : RETURNVALUE_YOUMAYNOTATTACKTHISPLAYER; +} - if (mode == PVP_MODE_YELLOW_HAND) { - // yellow hand: skulled players are fair game - if (attacker->getSkullClient(target) != SKULL_NONE) { - return RETURNVALUE_NOERROR; +std::shared_ptr Combat::resolveFieldOwnerPlayer(uint32_t ownerId) { + // A field's ownerId is a stable player GUID (setOwner normalises players/summons to their master GUID). + // Resolve it to the owning player, falling back to a live summon's master for safety. + if (ownerId == 0) { + return nullptr; + } + if (const auto &ownerPlayer = g_game().getPlayerByGUID(ownerId)) { + return ownerPlayer; + } + if (const auto &ownerCreature = g_game().getCreatureByID(ownerId)) { + if (ownerCreature->isSummon() && ownerCreature->getMaster()) { + return ownerCreature->getMaster()->getPlayer(); } } - - return RETURNVALUE_YOUMAYNOTATTACKTHISPLAYER; + return nullptr; } bool Combat::isOwnedFieldBystander(const std::shared_ptr &player, const std::shared_ptr &field) { - if (!player || !field || g_game().getWorldType() != WORLDTYPE_OPEN) { + if (!player || !field || !g_game().isExpertPvp()) { return false; } @@ -523,16 +548,8 @@ bool Combat::isOwnedFieldBystander(const std::shared_ptr &player, const return false; // map/GM/monster fields concern everyone } - auto ownerPlayer = g_game().getPlayerByGUID(ownerId); - if (!ownerPlayer) { - if (const auto &ownerCreature = g_game().getCreatureByID(ownerId)) { - if (ownerCreature->isSummon() && ownerCreature->getMaster()) { - ownerPlayer = ownerCreature->getMaster()->getPlayer(); - } - } - } - // owner and everyone in a PvP situation with him are involved; the rest are bystanders + const auto &ownerPlayer = resolveFieldOwnerPlayer(ownerId); return ownerPlayer && ownerPlayer != player && !ownerPlayer->isInPvpSituationWith(player); } @@ -572,6 +589,15 @@ ReturnValue Combat::canDoCombat(const std::shared_ptr &attacker, const } const auto &targetPlayerTile = targetPlayer->getTile(); + // Open PvP (2014 rules): a summon's aggression is attributed to its master, so the owner's + // expert PvP mode (dove/white hand) governs whether the summon may hit this player. Without + // this a dove/white-hand player could freely damage neutrals through their summon. + if (!attackerPlayer && masterAttackerPlayer) { + const ReturnValue summonExpertRet = checkExpertPvpMode(masterAttackerPlayer, targetPlayer); + if (summonExpertRet != RETURNVALUE_NOERROR) { + return summonExpertRet; + } + } if (attackerPlayer) { if (attackerPlayer->hasFlag(PlayerFlags_t::CannotAttackPlayer)) { return RETURNVALUE_YOUMAYNOTATTACKTHISPLAYER; @@ -583,7 +609,7 @@ ReturnValue Combat::canDoCombat(const std::shared_ptr &attacker, const // Open PvP (2014 rules): a player hidden under another player in the tile stack // cannot INITIATE PvP — defending inside an existing situation stays allowed. - if (g_game().getWorldType() == WORLDTYPE_OPEN && !attackerPlayer->isFirstInStack() + if (g_game().isExpertPvp() && !attackerPlayer->isFirstInStack() && !attackerPlayer->isInPvpSituationWith(targetPlayer) && !targetPlayer->hasAttacked(attackerPlayer)) { return RETURNVALUE_YOUMAYNOTATTACKTHISPLAYER; } @@ -2734,19 +2760,9 @@ void MagicField::onStepInField(const std::shared_ptr &creature) { if (ownerId) { // Open PvP (2014 rules): a player-made field harms only its caster and players in a // PvP situation with him — bystanders walk through unharmed. - if (g_game().getWorldType() == WORLDTYPE_OPEN) { - if (const auto &steppingPlayer = creature->getPlayer()) { - auto fieldOwnerPlayer = g_game().getPlayerByGUID(ownerId); - if (!fieldOwnerPlayer) { - if (const auto &ownerCreature = g_game().getCreatureByID(ownerId)) { - if (ownerCreature->isSummon() && ownerCreature->getMaster()) { - fieldOwnerPlayer = ownerCreature->getMaster()->getPlayer(); - } - } - } - if (fieldOwnerPlayer && fieldOwnerPlayer != steppingPlayer && !fieldOwnerPlayer->isInPvpSituationWith(steppingPlayer)) { - return; - } + if (const auto &steppingPlayer = creature->getPlayer()) { + if (Combat::isOwnedFieldBystander(steppingPlayer, static_self_cast())) { + return; } } @@ -2767,7 +2783,9 @@ void MagicField::onStepInField(const std::shared_ptr &creature) { const auto &targetPlayer = creature->getPlayer(); if (targetPlayer) { - const auto &attackerPlayer = g_game().getPlayerByID(ownerId); + // ownerId is a player GUID, not a runtime creature id — resolve it correctly so the + // isProtected() gate is not silently skipped for player-owned fields. + const auto &attackerPlayer = Combat::resolveFieldOwnerPlayer(ownerId); if (attackerPlayer) { if (Combat::isProtected(attackerPlayer, targetPlayer)) { harmfulField = false; diff --git a/src/creatures/combat/combat.hpp b/src/creatures/combat/combat.hpp index 0f3915016..ded2cce25 100644 --- a/src/creatures/combat/combat.hpp +++ b/src/creatures/combat/combat.hpp @@ -185,6 +185,21 @@ class AreaCombat { bool hasExtArea = false; }; +// Open PvP expert modes: the pure allow/deny decision extracted from Combat::checkExpertPvpMode so it +// can be unit-tested in isolation. Inputs are pre-gathered facts about the attacker/target pair; the +// function reads no globals and has no side effects. +struct PvpFacts { + bool targetAttackedMe = false; // the target has attacked us (self-defense) + bool inSituation = false; // we are already in a mutual PvP situation with the target + bool targetSkulled = false; // the target carries a skull visible to us (yellow-hand fair game) + bool attackedAllyOfMe = false; // the target has attacked one of our party/guild members + bool partner = false; // the target is in our party + bool guildMate = false; // the target is in our guild +}; + +// Returns true if `mode` permits attacking a target described by `facts`. Pure decision matrix. +bool pvpModeAllows(PvpMode_t mode, const PvpFacts &facts); + class Combat { public: Combat() = default; @@ -214,6 +229,11 @@ class Combat { // Open PvP expert modes (dove / white hand / yellow hand / red fist): who may attack whom. static ReturnValue checkExpertPvpMode(const std::shared_ptr &attacker, const std::shared_ptr &target); static bool isOwnedFieldBystander(const std::shared_ptr &player, const std::shared_ptr &field); + // Open PvP (2014 rules): resolve a field's ownerId (a player GUID) to the owning player, falling + // back to a live summon's master. Single source of truth for field-owner lookup. + static std::shared_ptr resolveFieldOwnerPlayer(uint32_t ownerId); + // Open PvP (2014 rules): wall cast OUTSIDE a PvP situation = PvE wall — blocks only monsters, + // every player (including the caster) walks through it. Flagged at cast time by the rune script. static bool isPveWall(const std::shared_ptr &item); static bool isPlayerCombat(const std::shared_ptr &target); static CombatType_t ConditionToDamageType(ConditionType_t type); diff --git a/src/creatures/combat/spells.cpp b/src/creatures/combat/spells.cpp index a5f8f58ea..86aa0b6fd 100644 --- a/src/creatures/combat/spells.cpp +++ b/src/creatures/combat/spells.cpp @@ -432,7 +432,7 @@ bool Spell::playerSpellCheck(const std::shared_ptr &player) const { // Open PvP (2014 rules): a player hidden under another player in the tile stack // cannot use aggressive area spells/runes — only the top ("first in stack") player can. - if (aggressive && range < 1 && g_game().getWorldType() == WORLDTYPE_OPEN && !player->isFirstInStack()) { + if (aggressive && range < 1 && g_game().isExpertPvp() && !player->isFirstInStack()) { player->sendCancelMessage(RETURNVALUE_NOTPOSSIBLE); g_game().addMagicEffect(player->getPosition(), CONST_ME_POFF); return false; diff --git a/src/creatures/players/player.cpp b/src/creatures/players/player.cpp index d5ebb82a4..1f435e2f7 100644 --- a/src/creatures/players/player.cpp +++ b/src/creatures/players/player.cpp @@ -908,6 +908,8 @@ bool Player::hasSecureMode() const { void Player::setParty(std::shared_ptr newParty) { m_party = std::move(newParty); + // Open PvP (2014 rules): party membership affects the situation-box colors this player sees. + g_game().refreshPvpSituationMarks(); } std::shared_ptr Player::getParty() const { @@ -1432,7 +1434,7 @@ bool Player::canWalkthrough(const std::shared_ptr &creature) { // Open PvP (2014 rules): characters never block each other — pass through freely, // without the PZ/level restrictions or the double-step confirmation. - if (g_game().getWorldType() == WORLDTYPE_OPEN) { + if (g_game().isExpertPvp()) { return true; } @@ -1480,7 +1482,7 @@ bool Player::canWalkthroughEx(const std::shared_ptr &creature) const { if (player) { const auto &playerTile = player->getTile(); // Open PvP (2014 rules): characters never block each other. - if (g_game().getWorldType() == WORLDTYPE_OPEN) { + if (g_game().isExpertPvp()) { const auto &playerTileGround = playerTile ? playerTile->getGround() : nullptr; return playerTileGround && playerTileGround->hasWalkStack(); } @@ -7190,7 +7192,7 @@ bool Player::hasActivePvpSituation() const { } PvPBox_t Player::getPvpSituationMarkFor(const std::shared_ptr &viewer) const { - if (g_game().getWorldType() != WORLDTYPE_OPEN || !viewer) { + if (!g_game().isExpertPvp() || !viewer) { return PvPBox_t::PVP_BOX_NONE; } @@ -7224,7 +7226,7 @@ PvPBox_t Player::getPvpSituationMarkFor(const std::shared_ptr &viewer) c } void Player::updatePvpSituationMarks() { - if (g_game().getWorldType() != WORLDTYPE_OPEN) { + if (!g_game().isExpertPvp()) { return; } @@ -9301,6 +9303,9 @@ void Player::setGuild(const std::shared_ptr &newGuild) { guildRank = rank; newGuild->addMember(static_self_cast()); } + + // Open PvP (2014 rules): guild membership affects the situation-box colors this player sees. + g_game().refreshPvpSituationMarks(); } [[nodiscard]] GuildRank_ptr Player::getGuildRank() const { diff --git a/src/crystalserver.cpp b/src/crystalserver.cpp index bd0d39bdb..bfdad25c7 100644 --- a/src/crystalserver.cpp +++ b/src/crystalserver.cpp @@ -160,7 +160,13 @@ int CrystalServer::run() { void CrystalServer::setWorldType() { const std::string worldType = asLowerCaseString(g_configManager().getString(WORLD_TYPE)); - if (worldType == "open" || worldType == "2" || worldType == "openpvp" || worldType == "pvp" || worldType == "normal") { + bool expertPvp = false; + // "expert-pvp" (and legacy open aliases) -> full 2014 Expert Open PvP; "retro-pvp" -> plain Open PvP. + if (worldType == "expert-pvp" || worldType == "expertpvp" || worldType == "expert" + || worldType == "open" || worldType == "2" || worldType == "openpvp" || worldType == "pvp" || worldType == "normal") { + g_game().setWorldType(WORLDTYPE_OPEN); + expertPvp = true; + } else if (worldType == "retro-pvp" || worldType == "retropvp" || worldType == "retro") { g_game().setWorldType(WORLDTYPE_OPEN); } else if (worldType == "optional" || worldType == "1" || worldType == "optionalpvp" || worldType == "safe" || worldType == "nopvp" || worldType == "no-pvp" || worldType == "secure") { g_game().setWorldType(WORLDTYPE_OPTIONAL); @@ -169,13 +175,14 @@ void CrystalServer::setWorldType() { } else { throw FailedToInitializeCrystalServer( fmt::format( - "Unknown world type: {}, valid world types are: open, optional and hardcore", + "Unknown world type: {}, valid world types are: expert-pvp, retro-pvp, optional (no-pvp) and hardcore (pvp-enforced)", g_configManager().getString(WORLD_TYPE) ) ); } + g_game().setExpertPvp(expertPvp); - logger.info("World type set as {}", asUpperCaseString(worldType)); + logger.info("World type set as {}{}", asUpperCaseString(worldType), expertPvp ? " (Expert PvP)" : ""); } void CrystalServer::loadMaps() const { diff --git a/src/game/game.cpp b/src/game/game.cpp index 06c3e8e95..57d7c23f6 100644 --- a/src/game/game.cpp +++ b/src/game/game.cpp @@ -11467,6 +11467,20 @@ void Game::sendUpdateCreature(const std::shared_ptr &creature) { } } +void Game::refreshPvpSituationMarks() { + if (!isExpertPvp()) { + return; + } + // A party/guild change alters how the changing player sees the situation boxes of players who are + // currently fighting (an ally's opponent becomes ORANGE, an unrelated one BROWN). Re-send those + // players' creature blocks once so every viewer in range recomputes its color — event-driven, no flicker. + for (const auto &[playerId, player] : players) { + if (player && player->hasActivePvpSituation()) { + sendUpdateCreature(player); + } + } +} + uint32_t Game::makeInfluencedMonster() { if (auto influencedLimit = g_configManager().getNumber(FORGE_INFLUENCED_CREATURES_LIMIT); // Condition diff --git a/src/game/game.hpp b/src/game/game.hpp index 464aac415..7bbbb552a 100644 --- a/src/game/game.hpp +++ b/src/game/game.hpp @@ -149,6 +149,16 @@ class Game { return worldType; } + // Open PvP (2014 rules): the full "Expert PvP" ruleset (character pass-through, situation boxes, + // first-in-stack initiate rule, expert client controls, field ownership) is gated on this. + // Selected via config worldType: "expert-pvp" enables it, "retro-pvp" keeps plain Open PvP. + bool isExpertPvp() const { + return worldType == WORLDTYPE_OPEN && expertPvp; + } + void setExpertPvp(bool enabled) { + expertPvp = enabled; + } + const std::map> &getTeamFinderList() const { return teamFinderMap; } @@ -731,6 +741,9 @@ class Game { bool addInfluencedMonster(const std::shared_ptr &monster); void sendUpdateCreature(const std::shared_ptr &creature); + // Open PvP (2014 rules): re-send situation-box marks for every player currently in a fight, so + // viewers recompute yellow/orange/brown after a party/guild membership change alters their alliances. + void refreshPvpSituationMarks(); std::shared_ptr wrapItem(const std::shared_ptr &item, const std::shared_ptr &house); void playerCheckActivity(const std::string &playerName, int interval); @@ -951,6 +964,7 @@ class Game { GameState_t gameState = GAME_STATE_NORMAL; WorldType_t worldType = WORLDTYPE_OPEN; + bool expertPvp = false; // Open PvP (2014 rules): true only for config worldType "expert-pvp" LightState_t lightState = LIGHT_STATE_DAY; LightState_t currentLightState = lightState; diff --git a/src/server/network/protocol/protocolgame.cpp b/src/server/network/protocol/protocolgame.cpp index 5c421714f..7d83d62fc 100644 --- a/src/server/network/protocol/protocolgame.cpp +++ b/src/server/network/protocol/protocolgame.cpp @@ -2231,8 +2231,9 @@ void ProtocolGame::parseFightModes(NetworkMessage &msg) { // 15.25 tail (RE 2026-07-02): [pvpMode:u8][junk:u8] — 0 dove, 1 white hand, 2 yellow hand, 3 red fist. uint8_t rawPvpMode = PVP_MODE_DOVE; - const int32_t tailRemaining = static_cast(msg.getLength()) - (static_cast(msg.getBufferPosition()) - 7); - if (tailRemaining > 0) { + // Deterministic version gate: modern (non-oldProtocol) clients always send [pvpMode:u8][junk:u8]; + // oldProtocol (<=1100) clients omit the tail and keep the PVP_MODE_DOVE default. + if (!oldProtocol) { rawPvpMode = msg.getByte(); } const auto pvpMode = rawPvpMode <= PVP_MODE_RED_FIST ? static_cast(rawPvpMode) : PVP_MODE_DOVE; @@ -7951,7 +7952,7 @@ void ProtocolGame::sendAddCreature(const std::shared_ptr &creature, co } // Open PvP: unlock the client's expert PvP controls (the "E" button + dove/white/yellow/red modes) - const bool expertPvpControls = g_game().getWorldType() == WORLDTYPE_OPEN; + const bool expertPvpControls = g_game().isExpertPvp(); msg.addByte(expertPvpControls ? 0x01 : 0x00); // can change pvp framing option msg.addByte(expertPvpControls ? 0x01 : 0x00); // expert mode button enabled diff --git a/src/utils/utils_definitions.hpp b/src/utils/utils_definitions.hpp index fa0106f31..d17a8c984 100644 --- a/src/utils/utils_definitions.hpp +++ b/src/utils/utils_definitions.hpp @@ -452,10 +452,9 @@ enum SquareColor_t : uint8_t { }; enum PvPBox_t : uint8_t { - // Open PvP situation boxes — classic 6x6x6 palette indices (tune after in-game verification) - PVP_BOX_YELLOW = 210, - PVP_BOX_ORANGE = 198, PVP_BOX_BROWN = 114, + PVP_BOX_ORANGE = 198, + PVP_BOX_YELLOW = 210, PVP_BOX_NONE = 0xFF, // MARK_UNMARKED }; diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index 583de9e85..64d64758b 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -1,6 +1,7 @@ setup_test(crystalserver_ut unit) add_subdirectory(account) +add_subdirectory(creatures) add_subdirectory(items) add_subdirectory(kv) add_subdirectory(lib) diff --git a/tests/unit/creatures/CMakeLists.txt b/tests/unit/creatures/CMakeLists.txt new file mode 100644 index 000000000..a77e93f07 --- /dev/null +++ b/tests/unit/creatures/CMakeLists.txt @@ -0,0 +1,3 @@ +target_sources(crystalserver_ut PRIVATE + combat/pvp_mode_test.cpp +) diff --git a/tests/unit/creatures/combat/pvp_mode_test.cpp b/tests/unit/creatures/combat/pvp_mode_test.cpp new file mode 100644 index 000000000..74acb4566 --- /dev/null +++ b/tests/unit/creatures/combat/pvp_mode_test.cpp @@ -0,0 +1,51 @@ +#include "pch.hpp" + +#include + +#include "creatures/combat/combat.hpp" + +using namespace boost::ut; + +// Decision-matrix coverage for pvpModeAllows: the pure allow/deny core of Combat::checkExpertPvpMode. +// Each row locks in one Open/Expert PvP rule so a future refactor cannot silently change who may attack whom. +suite<"combat"> pvpModeAllowsTest = [] { + struct Case { + std::string name; + PvpMode_t mode; + PvpFacts facts; + bool expected; + }; + + const std::vector cases { + // Dove: pacifist, only genuine self-defense is allowed. + { "dove denies a neutral", PVP_MODE_DOVE, PvpFacts {}, false }, + { "dove allows when the target attacked me", PVP_MODE_DOVE, PvpFacts { .targetAttackedMe = true }, true }, + { "dove allows inside an existing pvp situation", PVP_MODE_DOVE, PvpFacts { .inSituation = true }, true }, + { "dove ignores an attacked ally", PVP_MODE_DOVE, PvpFacts { .attackedAllyOfMe = true }, false }, + { "dove ignores a skulled target", PVP_MODE_DOVE, PvpFacts { .targetSkulled = true }, false }, + + // White hand: self-defense plus defending party/guild members. + { "white hand denies a neutral", PVP_MODE_WHITE_HAND, PvpFacts {}, false }, + { "white hand allows self-defense", PVP_MODE_WHITE_HAND, PvpFacts { .targetAttackedMe = true }, true }, + { "white hand defends an attacked ally", PVP_MODE_WHITE_HAND, PvpFacts { .attackedAllyOfMe = true }, true }, + { "white hand ignores a skulled target", PVP_MODE_WHITE_HAND, PvpFacts { .targetSkulled = true }, false }, + + // Yellow hand: white hand plus skulled players are fair game. + { "yellow hand denies a neutral", PVP_MODE_YELLOW_HAND, PvpFacts {}, false }, + { "yellow hand allows self-defense", PVP_MODE_YELLOW_HAND, PvpFacts { .targetAttackedMe = true }, true }, + { "yellow hand defends an attacked ally", PVP_MODE_YELLOW_HAND, PvpFacts { .attackedAllyOfMe = true }, true }, + { "yellow hand attacks a skulled target", PVP_MODE_YELLOW_HAND, PvpFacts { .targetSkulled = true }, true }, + + // Red fist: attack everyone except own party/guild, even in a self-defense context. + { "red fist attacks a neutral", PVP_MODE_RED_FIST, PvpFacts {}, true }, + { "red fist spares a party member", PVP_MODE_RED_FIST, PvpFacts { .partner = true }, false }, + { "red fist spares a guild mate", PVP_MODE_RED_FIST, PvpFacts { .guildMate = true }, false }, + { "red fist still spares party even under attack", PVP_MODE_RED_FIST, PvpFacts { .targetAttackedMe = true, .partner = true }, false }, + }; + + for (const auto &c : cases) { + test(c.name) = [&c] { + expect(eq(c.expected, pvpModeAllows(c.mode, c.facts))) << c.name; + }; + } +}; From eec4c1ea9e08c9ebfc59f3d14268bd4d7764a201 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo?= Date: Sun, 2 Aug 2026 10:24:09 -0300 Subject: [PATCH 3/3] fix: Qualify log call to std::log --- src/creatures/creature.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/creatures/creature.hpp b/src/creatures/creature.hpp index d813373b6..9aafc9bb2 100644 --- a/src/creatures/creature.hpp +++ b/src/creatures/creature.hpp @@ -903,7 +903,7 @@ class Creature : virtual public Thing, public SharedObject { const auto stepSpeed = getStepSpeed(); walk.calculatedStepSpeed = 1; if (stepSpeed > -Creature::speedB) { - const auto formula = std::floor((Creature::speedA * log(stepSpeed + Creature::speedB) + Creature::speedC) + .5); + const auto formula = std::floor((Creature::speedA * std::log(stepSpeed + Creature::speedB) + Creature::speedC) + .5); walk.calculatedStepSpeed = static_cast(std::max(formula, 1.)); }