From a9db6a6d3df2aea711f49225430ec9b5159dbee5 Mon Sep 17 00:00:00 2001 From: Dino <8dino2@gmail.com> Date: Tue, 24 Mar 2026 14:52:47 -0400 Subject: [PATCH] True damage support --- doc/action.md | 45 ++++++++++++++++++++++++++++++++++++++++++ src/action/a_cmds.c | 1 + src/action/a_team.c | 5 +++++ src/action/g_cmds.c | 5 +++++ src/action/g_combat.c | 29 ++++++++++++++++++++++++++- src/action/g_local.h | 12 +++++++++++ src/action/p_client.c | 7 +++++++ src/action/p_view.c | 13 +++++++++++- src/action/tng_stats.c | 16 +++++++++++++++ src/action/tng_stats.h | 1 + 10 files changed, 132 insertions(+), 2 deletions(-) diff --git a/doc/action.md b/doc/action.md index 97609d603..7a7739556 100644 --- a/doc/action.md +++ b/doc/action.md @@ -89,6 +89,7 @@ Additions and enhancements by darksaint, Reki, Rektek and the AQ2World team - [Darkmatch](#darkmatch) - [Map Restarting](#map-restarting) - [Statistics](#statistics) + - [True Damage Tracking](#true-damage-tracking) - [Automatic Joining/Equipping/Menu](#automatic-joiningequippingmenu) - [Automatic Demo Recording](#automatic-demo-recording) - [Spawn Code](#spawn-code) @@ -782,6 +783,50 @@ The following read-only cvars are automatically maintained by the server and exp - `t2` - current score for Team 2 - `t3` - current score for Team 3 (3-team modes) +### True Damage Tracking + +Standard damage statistics count raw damage dealt, including overkill. A sniper headshot deals 450+ damage, but the target only had 100 HP — the "true" damage is 100. True damage tracking provides two things: + +1. **Attacker-side `true_damage_dealt`**: Accumulated damage dealt to other players, capped at each victim's remaining HP at the time damage is applied. This is tracked alongside the existing raw `damage_dealt` stat and persists across respawns and reconnects (via ghost). The sum of all players' `true_damage_dealt` plus environmental damage equals the total HP lost across all victims in a round. + +2. **Victim-side HP breakdown**: When a player dies, their 100 HP is categorized into three buckets that sum to exactly 100: + - **Player damage**: HP lost from instant hits by other players (kevlar bruise damage, kicks, punches) + - **Bleeding damage**: HP lost from bleed-over-time ticks (the primary way gunshot damage depletes HP in AQ2) + - **Environmental damage**: HP lost from falls, drowning, lava, slime, or self-inflicted damage + +**Commands:** +- `truedmg` - displays the victim-side HP breakdown from the player's last death (client side) +- `stats` - now includes a "True Damage Dealt" line alongside the existing raw damage stat (client side) + +**Scoreboard:** + +True damage dealt can optionally be shown on the configurable scoreboard using the `E` field code. The `scoreboard` cvar accepts a string of field codes that define which columns appear: + +| Code | Column | Width | +|------|--------|-------| +| `F` | Frags | 5 chars | +| `N` | Player name | 15 chars | +| `M` | Time (minutes) | 4 chars | +| `P` | Ping | 4 chars | +| `S` | Score | 5 chars | +| `K` | Kills | 5 chars | +| `D` | Deaths | 6 chars | +| `I` | Damage (raw) | 6 chars | +| `E` | TrDmg (true damage) | 6 chars | +| `A` | Accuracy (%) | 3 chars | +| `T` | Team | 4 chars | +| `C` | CTF Caps | 4 chars | + +The `E` field is **not included in any default scoreboard layout** and must be explicitly added by the server operator. This avoids adding packet overhead for legacy clients or servers that don't need it. Each additional column adds approximately 7-8 bytes per player line to the layout string, which is constrained to 1024 bytes. Server operators should be mindful of the total column count when many players are on the server. + +Example: `set scoreboard "FNMPEI"` adds true damage alongside raw damage on the scoreboard. + +Default layouts (when `scoreboard` is empty): +- Standard teamplay: `FNMPIT` +- Team deathmatch: `FNMPDT` +- CTF: `SNMPCT` +- No-score mode: `NMP` + ### Automatic Joining/Equipping/Menu For the lazy players under us, we have created three new commands to make things easier. diff --git a/src/action/a_cmds.c b/src/action/a_cmds.c index f66655a54..fd2d4cdf8 100644 --- a/src/action/a_cmds.c +++ b/src/action/a_cmds.c @@ -1309,6 +1309,7 @@ void Cmd_Ghost_f(edict_t * ent) ent->client->resp.kills = ghost->kills; ent->client->resp.deaths = ghost->deaths; ent->client->resp.damage_dealt = ghost->damage_dealt; + ent->client->resp.true_damage_dealt = ghost->true_damage_dealt; ent->client->resp.ctf_caps = ghost->ctf_caps; ent->client->resp.ctf_capstreak = ghost->ctf_capstreak; ent->client->resp.team_kills = ghost->team_kills; diff --git a/src/action/a_team.c b/src/action/a_team.c index c00d9eb7a..166b111b8 100644 --- a/src/action/a_team.c +++ b/src/action/a_team.c @@ -1981,6 +1981,7 @@ void ResetScores (qboolean playerScores) ent->client->resp.score = 0; ent->client->resp.kills = 0; ent->client->resp.damage_dealt = 0; + ent->client->resp.true_damage_dealt = 0; ent->client->resp.streakHS = 0; ent->client->resp.streakKills = 0; ent->client->resp.roundStreakKills = 0; @@ -3834,6 +3835,7 @@ void A_ScoreboardMessage (edict_t * ent, edict_t * killer) else if( field == 'K' ) chars += 5; else if( field == 'D' ) chars += 6; else if( field == 'I' ) chars += 6; + else if( field == 'E' ) chars += 6; else if( field == 'A' ) chars += 3; else chars ++; } @@ -3862,6 +3864,7 @@ void A_ScoreboardMessage (edict_t * ent, edict_t * killer) else if( field == 'K' ) strcat( string, "Kills" ); else if( field == 'D' ) strcat( string, "Deaths" ); else if( field == 'I' ) strcat( string, "Damage" ); + else if( field == 'E' ) strcat( string, "TrDmg " ); else if( field == 'A' ) strcat( string, "Acc" ); else sprintf( string + strlen(string), "%c", sb[ i ] ); } @@ -3884,6 +3887,7 @@ void A_ScoreboardMessage (edict_t * ent, edict_t * killer) else if( field == 'K' ) strcat( string, "\x9D\x9E\x9E\x9E\x9F" ); else if( field == 'D' ) strcat( string, "\x9D\x9E\x9E\x9E\x9E\x9F" ); else if( field == 'I' ) strcat( string, "\x9D\x9E\x9E\x9E\x9E\x9F" ); + else if( field == 'E' ) strcat( string, "\x9D\x9E\x9E\x9E\x9E\x9F" ); else if( field == 'A' ) strcat( string, "\x9D\x9E\x9F" ); else sprintf( string + strlen(string), "%c", sb[ i ] ); } @@ -3958,6 +3962,7 @@ void A_ScoreboardMessage (edict_t * ent, edict_t * killer) else if( field == 'K' ) Q_snprintf( buf, sizeof(buf), "%5i", min( 99999, cl->resp.kills) ); else if( field == 'D' ) Q_snprintf( buf, sizeof(buf), "%6i", min( 999999, cl->resp.deaths) ); else if( field == 'I' ) Q_snprintf( buf, sizeof(buf), "%6i", min( 999999, cl->resp.damage_dealt) ); + else if( field == 'E' ) Q_snprintf( buf, sizeof(buf), "%6i", min( 999999, cl->resp.true_damage_dealt) ); else if( field == 'A' ) Q_snprintf( buf, sizeof(buf), "%3.f", cl->resp.shotsTotal ? (double) cl->resp.hitsTotal * 100.0 / (double) cl->resp.shotsTotal : 0. ); else sprintf( buf, "%c", sb[ j ] ); diff --git a/src/action/g_cmds.c b/src/action/g_cmds.c index 755295aab..f88b7095c 100644 --- a/src/action/g_cmds.c +++ b/src/action/g_cmds.c @@ -1798,6 +1798,10 @@ static void Cmd_PrintStats_f (edict_t *ent) { Cmd_Stats_f(ent, gi.argv(1)); } +static void Cmd_PrintTrueDmg_f (edict_t *ent) { + Cmd_TrueDmg_f(ent); +} + static void Cmd_Timeout_f (edict_t *ent) { Cmd_CallTimeout_f(ent); } @@ -1994,6 +1998,7 @@ static cmdList_t commandList[] = { "unlock", Cmd_UnlockTeam_f, 0 }, { "entcount", Cmd_Ent_Count_f, 0 }, { "stats", Cmd_PrintStats_f, 0 }, + { "truedmg", Cmd_PrintTrueDmg_f, 0 }, { "flashlight", FL_make, CMDF_PAUSE }, { "matchadmin", Cmd_SetAdmin_f, 0 }, { "roundtimeleft", Cmd_Roundtimeleft_f, 0 }, diff --git a/src/action/g_combat.c b/src/action/g_combat.c index 2aaf2db89..e4f661124 100644 --- a/src/action/g_combat.c +++ b/src/action/g_combat.c @@ -856,6 +856,15 @@ void T_Damage (edict_t * targ, edict_t * inflictor, edict_t * attacker, const ve else SpawnDamage(te_sparks, point, normal, take); + // True damage tracking (victim-side): cap at remaining HP + if (instant_dam && client && take > 0 && targ->health > 0) { + int true_take = (take > targ->health) ? targ->health : take; + if (attacker->client && attacker != targ) + client->truedmg_player += true_take; + else + client->truedmg_env += true_take; + } + // all things that have at least some instantaneous damage, i.e. bruising/falling if (instant_dam) targ->health = targ->health - take; @@ -869,6 +878,15 @@ void T_Damage (edict_t * targ, edict_t * inflictor, edict_t * attacker, const ve attacker->client->resp.damage_dealt += damage; // Hit markers attacker->client->damage_dealt += damage; + + // True damage dealt: only credit instant hits here. + // Bleeding damage is credited in Do_Bleeding when HP actually drops. + if (instant_dam) { + int pre_health = targ->health + take; // reconstruct pre-reduction HP + int true_take_att = (damage > pre_health) ? pre_health : damage; + if (true_take_att > 0) + attacker->client->resp.true_damage_dealt += true_take_att; + } } if (mod > 0 && mod < MAX_GUNSTAT) { attacker->client->resp.gunstats[mod].damage += damage; @@ -880,7 +898,7 @@ void T_Damage (edict_t * targ, edict_t * inflictor, edict_t * attacker, const ve mod != MOD_TARGET_LASER) { attacker->client->damage_dealt += take + psave + asave; } - + client->attacker = attacker; client->attacker_mod = mod; client->attacker_loc = damage_type; @@ -936,6 +954,15 @@ void T_Damage (edict_t * targ, edict_t * inflictor, edict_t * attacker, const ve attacker->client->resp.damage_dealt += damage; // Hit markers attacker->client->damage_dealt += damage; + + // True damage dealt: only credit instant hits here. + // Bleeding damage is credited in Do_Bleeding when HP actually drops. + if (instant_dam) { + int victim_hp = targ->health + take; // reconstruct pre-reduction HP + int true_dmg = (damage > victim_hp) ? victim_hp : damage; + if (true_dmg > 0) + attacker->client->resp.true_damage_dealt += true_dmg; + } } // All normal weapon damage if (mod > 0 && mod < MAX_GUNSTAT) { diff --git a/src/action/g_local.h b/src/action/g_local.h index 409384ad5..f109d4972 100644 --- a/src/action/g_local.h +++ b/src/action/g_local.h @@ -2022,6 +2022,12 @@ typedef struct int deaths; // deaths int damage_dealt; // keep track of damage dealt by player to other players + int true_damage_dealt; // damage dealt capped at victim's remaining HP + + // Last life's true damage breakdown (for truedmg command, persists across respawn) + int last_truedmg_player; // HP lost from other players' instant hits + int last_truedmg_bleed; // HP lost from bleeding ticks + int last_truedmg_env; // HP lost from environment/self damage int team; // team the player is on int subteam; @@ -2178,6 +2184,11 @@ struct gclient_s vec3_t damage_from; // origin for vector calculation int damage_dealt; // total damage dealt to other players (used for hit markers) + // True damage breakdown (victim-side, per-life, sums to 100 on death) + int truedmg_player; // HP lost from other players' instant hits + int truedmg_bleed; // HP lost from bleeding ticks + int truedmg_env; // HP lost from environment/self damage + float killer_yaw; // when dead, look at killer weaponstate_t weaponstate; @@ -2870,6 +2881,7 @@ typedef struct int kills; int deaths; int damage_dealt; + int true_damage_dealt; int ctf_caps; int shotsTotal; int hitsTotal; diff --git a/src/action/p_client.c b/src/action/p_client.c index df182179f..65f774287 100644 --- a/src/action/p_client.c +++ b/src/action/p_client.c @@ -1860,6 +1860,12 @@ void player_die(edict_t *self, edict_t *inflictor, edict_t *attacker, int damage } LookAtKiller(self, inflictor, attacker); self->client->ps.pmove.pm_type = PM_DEAD; + + // Snapshot true damage breakdown for truedmg command + self->client->resp.last_truedmg_player = self->client->truedmg_player; + self->client->resp.last_truedmg_bleed = self->client->truedmg_bleed; + self->client->resp.last_truedmg_env = self->client->truedmg_env; + ClientObituary(self, inflictor, attacker); if (ctf->value) CTFFragBonuses(self, inflictor, attacker); @@ -4040,6 +4046,7 @@ void CreateGhost(edict_t * ent) // Score ghost->score = ent->client->resp.score; ghost->damage_dealt = ent->client->resp.damage_dealt; + ghost->true_damage_dealt = ent->client->resp.true_damage_dealt; ghost->kills = ent->client->resp.kills; ghost->deaths = ent->client->resp.deaths; ghost->ctf_caps = ent->client->resp.ctf_caps; diff --git a/src/action/p_view.c b/src/action/p_view.c index 4ead38292..004cc3b7b 100644 --- a/src/action/p_view.c +++ b/src/action/p_view.c @@ -1203,11 +1203,22 @@ void Do_Bleeding (edict_t * ent) damage = (int) (ent->client->bleed_remain / BLEED_TIME); if (ent->client->bleed_remain >= BLEED_TIME) { + // True damage tracking: cap bleed at remaining HP + if (damage > 0 && ent->health > 0) { + int true_bleed = (damage > ent->health) ? ent->health : damage; + ent->client->truedmg_bleed += true_bleed; + + // Credit the attacker's true_damage_dealt when HP actually drops + if (ent->client->attacker && ent->client->attacker->client + && ent->client->attacker != ent) + ent->client->attacker->client->resp.true_damage_dealt += true_bleed; + } + ent->health -= damage; if (damage > 1) { // action doens't do this - //ent->client->damage_blood += damage; // for feedback + //ent->client->damage_blood += damage; // for feedback } if (ent->health <= 0) { diff --git a/src/action/tng_stats.c b/src/action/tng_stats.c index 4a0f23029..908f51edc 100644 --- a/src/action/tng_stats.c +++ b/src/action/tng_stats.c @@ -322,6 +322,21 @@ void Cmd_Stats_f (edict_t *targetent, char *arg) gi.cprintf (targetent, PRINT_HIGH, "Average Accuracy: %.2f\n", perc_hit); // Average gi.cprintf (targetent, PRINT_HIGH, "\n\x9D\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9E\x9F\n\n"); gi.cprintf(targetent, PRINT_HIGH, "Highest streaks: kills: %d headshots: %d\n", ent->client->resp.streakKillsHighest, ent->client->resp.streakHSHighest); + gi.cprintf(targetent, PRINT_HIGH, "True Damage Dealt: %d (Raw: %d)\n", + ent->client->resp.true_damage_dealt, ent->client->resp.damage_dealt); +} + +void Cmd_TrueDmg_f(edict_t *ent) +{ + int p = ent->client->resp.last_truedmg_player; + int b = ent->client->resp.last_truedmg_bleed; + int e = ent->client->resp.last_truedmg_env; + if (p + b + e == 0) { + gi.cprintf(ent, PRINT_HIGH, "No true damage data yet.\n"); + return; + } + gi.cprintf(ent, PRINT_HIGH, "Last life HP breakdown: %d player, %d bleeding, %d environment (total: %d)\n", + p, b, e, p + b + e); } void A_ScoreboardEndLevel (edict_t * ent, edict_t * killer) @@ -1592,6 +1607,7 @@ void LogEndMatchStats(void) ghlient->resp.kills = ghost->kills; ghlient->resp.deaths = ghost->deaths; ghlient->resp.damage_dealt = ghost->damage_dealt; + ghlient->resp.true_damage_dealt = ghost->true_damage_dealt; ghlient->resp.ctf_caps = ghost->ctf_caps; ghlient->resp.ctf_capstreak = ghost->ctf_capstreak; ghlient->resp.team_kills = ghost->team_kills; diff --git a/src/action/tng_stats.h b/src/action/tng_stats.h index 3b62f7afc..97bf58a8b 100644 --- a/src/action/tng_stats.h +++ b/src/action/tng_stats.h @@ -6,5 +6,6 @@ void Stats_AddHit(edict_t *ent, int gun, int hitPart); float CalculateAccuracy(edict_t* ent); void A_ScoreboardEndLevel (edict_t * ent, edict_t * killer); void Cmd_Stats_f (edict_t *targetent, char *arg); +void Cmd_TrueDmg_f(edict_t *ent); void Cmd_Statmode_f(edict_t *ent); edict_t *find_player_by_steamid(const char* steamid);