[Feature] Open PvP system — retail 2014 rules (PvP situations, expert modes, skull system, per-viewer fields/walls, frag share)
Summary
This implements the complete Open PvP ruleset as used on retail Tibia since the Summer 2014 PvP revamp, following the specification published at https://tibiaduality.com/pvp-openpvp. The core concept is the PvP situation — a mutual, time-limited combat state between two players that everything else keys off: field damage, magic wall blocking, situation boxes and the expert PvP modes.
Everything below is implemented, built and verified in-game on protocol 15.25 (world type pvp).
1. General characteristics
| Rule |
Status |
Implementation |
| Players can walk through each other |
✅ |
Player::canWalkthrough/Ex — unconditional pass-through for players on open PvP (the PZ-only "double step" behaviour remains for other world types) |
| Floor fields only damage the caster unless in a PvP situation |
✅ |
Gate at the top of MagicField::onStepInField — a player-made field applies no damage condition to bystanders |
| Players cannot pass their own Magic Walls but can pass others' |
✅ |
Tile::queryAdd + Combat::isOwnedFieldBystander — a wall blocks its owner and everyone in a PvP situation with him; the wall stays up for those it blocks |
| Magic Wall / Wild Growth cast outside a PvP situation = PvE wall |
✅ |
Flagged at cast time (pveWall custom attribute) — blocks only monsters; every player including the caster walks through |
| The Wild Growth rune can be cut only by its caster |
✅ |
Owner check in the machete action (onUseMachete); ownerless map/quest growths remain cuttable by everyone |
| Non-first-in-stack players cannot use area spells/runes or initiate PvP |
✅ |
Player::isFirstInStack — gates aggressive area casts in Spell::playerSpellCheck (covers runes via playerRuneSpellCheck) and PvP initiation in Combat::canDoCombat; defending inside an existing situation stays allowed |
| Kill participation tracked as percentages ("unfair fight") |
✅ |
With 5 or fewer attackers everyone receives 100% of the frag; with 6+ each participant receives 5 / N of it (see §4) |
| Three frag bars (24 h / 7 days / 30 days) |
✅ |
Already present upstream (sendUnjustifiedPoints, 0xB7) — now fed with weighted sums |
The PvP situation core
Player::pvpSituations — guid → expiry map; every aggressive act between two players creates/refreshes a mutual entry (60 s, pzLocked duration).
isInPvpSituationWith() / hasActivePvpSituation() drive fields, walls, boxes and expert-mode checks.
- Runtime-only state; expires passively, no persistence needed.
Per-viewer field & wall rendering
The client blocks movement onto an "unpassable" item locally and never even sends the walk packet — so server-side pass-through alone is not enough. Retail solves this by showing a different item to players the wall does not concern, and so do we:
ProtocolGame::AddItem swaps the wire id per viewer: bystanders receive the walkable/harmless variants (ITEM_MAGICWALL_SAFE 10181, ITEM_WILDGROWTH_SAFE 10182, ITEM_FIREFIELD_NOPVP 2131, ITEM_POISONFIELD_NOPVP 2134, ITEM_ENERGYFIELD_NOPVP 2135), while the owner and players in a PvP situation with him see the dangerous/solid originals.
- The server still arbitrates every step in
Tile::queryAdd, so a spoofed client gains nothing.
- Bonus fix: the MW/WG rune scripts created their items via
Game.createItem() without an owner (unlike combat-created fields) — item:setOwner(creature) added; ownership also powers the wild-growth cutting rule.
2. Expert PvP modes (dove / white hand / yellow hand / red fist)
Protocol (reverse-engineered on client 15.25)
- The "E" button was permanently greyed out because the login packet hardcoded two flags to
0x00: can change pvp framing option and expert mode button enabled. Both are now sent as 0x01 on open PvP worlds.
- The client appends the selected mode to the fight-modes packet —
0xA0 tail = [pvpMode:u8][junk:u8] (0 dove, 1 white hand, 2 yellow hand, 3 red fist; the second byte is uninitialized garbage and is ignored). parseFightModes now reads it, sendFightModes (0xA7) echoes the real mode back.
Enforcement (Combat::checkExpertPvpMode)
| Mode |
May attack |
| 🕊️ Dove |
Self-defence only — players who attacked you or are in a PvP situation with you. Area spells never splash innocents. |
| 🤚 White hand |
Dove + players who attacked a member of your party/guild (hasAttackedAllyOf) |
| ✋ Yellow hand |
White hand + any skulled player (getSkullClient != NONE) |
| ✊ Red fist |
Anyone except party/guild members |
- Enforced both at target selection (
canTargetCreature, replacing the legacy secure-mode check on open PvP) and per combat target (canDoCombat) — dove/white players cannot damage innocents even with area splash.
- Bypasses: staff characters, guild wars, PvP zones and event teams.
3. Skull system
| Skull |
Rule (per spec) |
Status |
| ⚪ White |
Attacking an unskulled player; 15 min after a kill, ~1 min after mere aggression; auto PZ-lock |
✅ upstream, verified |
| 🟡 Yellow |
Obtained when attacking a skulled player; visible only to the attacked player; duration like white |
✅ fixed — upstream only handled retaliation against unskulled aggressors; attacking a player with a real skull never registered, so the victim never saw the yellow skull. Now: justified (no white skull for the attacker), addAttacked + skull refresh, PZ-lock applied |
| 🔴 Red |
3 frags/24 h, 5/7 days or 10/30 days → 30 days duration |
✅ thresholds upstream; duration fixed 1 → 30 days; full drop + blessing loss upstream |
| ⚫ Black |
2× red thresholds (6/10/20) → 45 days; cannot attack unskulled; takes 100 % damage; respawns with 40 hp / 0 mana |
✅ upstream; duration fixed 3 → 45 days |
| 🟠 Orange |
Marks unjustified killers to their victims for 7 days or until vengeance |
✅ fixed — the check only ran when the killer had no real skull, but an unjustified killer carries a white skull for 15 min, so the victim never saw orange. Now orange overrides white in the victim's view (red/black keep priority). Vengeance clearing (kill.unavenged) upstream |
| 🕐 Day-window bug |
"Day" frag counter used a 4-hour window |
✅ fixed → 24 h |
| 🕐 Relog bug |
Kills were only loaded from DB within 24 h (FRAG_TIME), silently wiping the 30-day bar and the 7-day orange skull on relog |
✅ fixed → 30-day load window |
4. Frag share ("unfair fight")
Kill.weight (1.0 = full frag). On an unjustified kill the victim's damageMap is scanned for distinct player participants (through summon masters, within the in-fight window, victim excluded); with more than 5 participants each killer's entry is stored with weight 5 / N.
- Red/black-skull thresholds and the three frag bars compare weighted sums, so the bars fill by percentages exactly like retail.
- Persistence: new
player_kills.weight column (smallint, percent, default 100) — migration 65 + schema.sql; player:getKills()/setKills() expose the weight as an optional 4th tuple field.
5. PvP situation boxes (yellow / orange / brown)
- The creature description contains a persistent mark byte that upstream hardcoded to
0xFF (MARK_UNMARKED). It is now computed per viewer by Player::getPvpSituationMarkFor():
- 🟨 yellow — you and everyone in a PvP situation with you,
- 🟧 orange — a player fighting one of your party/guild mates,
- 🟫 brown — participants of fights that don't involve you.
- Refresh:
sendUpdateCreature (0x6B) is emitted only on state changes (fight starts, adversary joins, situations expire — the last one polled at 1 Hz). Late-arriving spectators get the correct colour automatically with the regular creature description. No periodic resend, no flicker.
- ⚠️ Do not use the 0x93 border mark for this — that channel is the client's short attack flash animation and blinks.
Config
worldType = "pvp"
dayKillsToRedSkull = 3
weekKillsToRedSkull = 5
monthKillsToRedSkull = 10
redSkullDuration = 30 -- was 1
blackSkullDuration = 45 -- was 3
orangeSkullDuration = 7
whiteSkullTime = 15 * 60 * 1000
walkThroughPlayers = true
Database
- Migration
data/migrations/65.lua — ALTER TABLE player_kills ADD COLUMN weight smallint(5) UNSIGNED NOT NULL DEFAULT 100 (+ schema.sql for fresh installs). Runs automatically at boot.
Dev tooling included
/pvpmark <markType>[,<weaponType>] (god talkaction) — sends raw 0x93 creature marks for client-side protocol experiments.
- Lua:
player:sendCreatureSquare(creature, markType[, weaponType]), player:hasActivePvpSituation().
In-game test checklist (all verified ✔)
Known limitations
- A field/wall already on the ground does not visually "re-arm" the moment a situation starts mid-lifetime — the sprite updates on the next tile refresh (fields live ≤ 20 s; server-side rules always apply regardless).
- PvP situations are runtime-only and end on logout (retail-like).
- Box palette indices (210/198/114) are configurable constants in
player.cpp should the hues need tuning.
🤖 Generated with Claude Code
[Feature] Open PvP system — retail 2014 rules (PvP situations, expert modes, skull system, per-viewer fields/walls, frag share)
Summary
This implements the complete Open PvP ruleset as used on retail Tibia since the Summer 2014 PvP revamp, following the specification published at https://tibiaduality.com/pvp-openpvp. The core concept is the PvP situation — a mutual, time-limited combat state between two players that everything else keys off: field damage, magic wall blocking, situation boxes and the expert PvP modes.
Everything below is implemented, built and verified in-game on protocol 15.25 (world type
pvp).1. General characteristics
Player::canWalkthrough/Ex— unconditional pass-through for players on open PvP (the PZ-only "double step" behaviour remains for other world types)MagicField::onStepInField— a player-made field applies no damage condition to bystandersTile::queryAdd+Combat::isOwnedFieldBystander— a wall blocks its owner and everyone in a PvP situation with him; the wall stays up for those it blockspveWallcustom attribute) — blocks only monsters; every player including the caster walks throughonUseMachete); ownerless map/quest growths remain cuttable by everyonePlayer::isFirstInStack— gates aggressive area casts inSpell::playerSpellCheck(covers runes viaplayerRuneSpellCheck) and PvP initiation inCombat::canDoCombat; defending inside an existing situation stays allowed5 / Nof it (see §4)sendUnjustifiedPoints, 0xB7) — now fed with weighted sumsThe PvP situation core
Player::pvpSituations—guid → expirymap; every aggressive act between two players creates/refreshes a mutual entry (60 s,pzLockedduration).isInPvpSituationWith()/hasActivePvpSituation()drive fields, walls, boxes and expert-mode checks.Per-viewer field & wall rendering
The client blocks movement onto an "unpassable" item locally and never even sends the walk packet — so server-side pass-through alone is not enough. Retail solves this by showing a different item to players the wall does not concern, and so do we:
ProtocolGame::AddItemswaps the wire id per viewer: bystanders receive the walkable/harmless variants (ITEM_MAGICWALL_SAFE10181,ITEM_WILDGROWTH_SAFE10182,ITEM_FIREFIELD_NOPVP2131,ITEM_POISONFIELD_NOPVP2134,ITEM_ENERGYFIELD_NOPVP2135), while the owner and players in a PvP situation with him see the dangerous/solid originals.Tile::queryAdd, so a spoofed client gains nothing.Game.createItem()without an owner (unlike combat-created fields) —item:setOwner(creature)added; ownership also powers the wild-growth cutting rule.2. Expert PvP modes (dove / white hand / yellow hand / red fist)
Protocol (reverse-engineered on client 15.25)
0x00: can change pvp framing option and expert mode button enabled. Both are now sent as0x01on open PvP worlds.0xA0tail =[pvpMode:u8][junk:u8](0dove,1white hand,2yellow hand,3red fist; the second byte is uninitialized garbage and is ignored).parseFightModesnow reads it,sendFightModes(0xA7) echoes the real mode back.Enforcement (
Combat::checkExpertPvpMode)hasAttackedAllyOf)getSkullClient != NONE)canTargetCreature, replacing the legacy secure-mode check on open PvP) and per combat target (canDoCombat) — dove/white players cannot damage innocents even with area splash.3. Skull system
addAttacked+ skull refresh, PZ-lock appliedkill.unavenged) upstreamFRAG_TIME), silently wiping the 30-day bar and the 7-day orange skull on relog4. Frag share ("unfair fight")
Kill.weight(1.0 = full frag). On an unjustified kill the victim'sdamageMapis scanned for distinct player participants (through summon masters, within the in-fight window, victim excluded); with more than 5 participants each killer's entry is stored with weight5 / N.player_kills.weightcolumn (smallint, percent, default 100) — migration 65 +schema.sql;player:getKills()/setKills()expose the weight as an optional 4th tuple field.5. PvP situation boxes (yellow / orange / brown)
0xFF(MARK_UNMARKED). It is now computed per viewer byPlayer::getPvpSituationMarkFor():sendUpdateCreature(0x6B) is emitted only on state changes (fight starts, adversary joins, situations expire — the last one polled at 1 Hz). Late-arriving spectators get the correct colour automatically with the regular creature description. No periodic resend, no flicker.Config
Database
data/migrations/65.lua—ALTER TABLE player_kills ADD COLUMN weight smallint(5) UNSIGNED NOT NULL DEFAULT 100(+schema.sqlfor fresh installs). Runs automatically at boot.Dev tooling included
/pvpmark <markType>[,<weaponType>](god talkaction) — sends raw 0x93 creature marks for client-side protocol experiments.player:sendCreatureSquare(creature, markType[, weaponType]),player:hasActivePvpSituation().In-game test checklist (all verified ✔)
exori/GFB or initiate PvP; the top player canKnown limitations
player.cppshould the hues need tuning.🤖 Generated with Claude Code