Repository: markus-wa/demoinfocs-golang

Issue:
`SmokeStart` events report incorrect `e.GrenadeEvent.Grenade.UniqueID2()`
**Describe the bug**
Some `SmokeStart` events report incorrect `e.GrenadeEvent.Grenade.UniqueID2()`.

**To Reproduce**
Match: https://www.hltv.org/matches/2383258/leo-vs-novaq-galaxy-battle-2025-phase-3 
Map: de_dust2

In round 4, neaLaN (Novaq team, T side) throws 3 smokes, with the first smoke being thrown at tick 24851, the second smoke at tick 26065 and the third smoke at tick 27369.

Code:
```go
func x() {
...
p.RegisterEventHandler(func(e events.GrenadeProjectileThrow) {
  if e.Projectile.WeaponInstance.Type.String() == "Smoke Grenade" {
    fmt.Println("SMOKE THROWN", e.Projectile.WeaponInstance.UniqueID2(), p.GameState().IngameTick())
  }
})

p.RegisterEventHandler(func(e events.SmokeStart) {
  fmt.Println("SMOKE EXPLODED", e.GrenadeEvent.Grenade.UniqueID2(), p.GameState().IngameTick())
})

p.RegisterEventHandler(func(e events.GrenadeProjectileDestroy) {
  if e.Projectile.WeaponInstance.Type.String() == "Smoke Grenade" {
    fmt.Println("SMOKE DESTROYED", e.Projectile.WeaponInstance.UniqueID2(), p.GameState().IngameTick())
  }
})
...
}
```

The code above produces the following logs:

```
SMOKE THROWN 01JYCX0CTXM4BEQ2ANTW7M2QWC 24851
SMOKE EXPLODED 01JYCX0CTXM4BEQ2ANTW7M2QWC 25319
SMOKE THROWN 01JYCX0CXBW6M6E9CBRPPX4E5N 26065
SMOKE EXPLODED 01JYCX0CTXM4BEQ2ANTW7M2QWC 26420
SMOKE DESTROYED 01JYCX0CTXM4BEQ2ANTW7M2QWC 26731
SMOKE THROWN 01JYCX0CQD26J747ATW8AK7PRB 27369
SMOKE EXPLODED 01JYCX0CXBW6M6E9CBRPPX4E5N 27540
SMOKE DESTROYED 01JYCX0CXBW6M6E9CBRPPX4E5N 27832
SMOKE DESTROYED 01JYCX0CQD26J747ATW8AK7PRB 28952
```

By looking at the demo for each of the ticks from the logs, I can group these logs logically into groups for each grenade like so:

```
SMOKE THROWN 01JYCX0CTXM4BEQ2ANTW7M2QWC 24851
SMOKE EXPLODED 01JYCX0CTXM4BEQ2ANTW7M2QWC 25319
SMOKE DESTROYED 01JYCX0CTXM4BEQ2ANTW7M2QWC 26731

SMOKE THROWN 01JYCX0CXBW6M6E9CBRPPX4E5N 26065
SMOKE EXPLODED 01JYCX0CTXM4BEQ2ANTW7M2QWC 26420
SMOKE DESTROYED 01JYCX0CXBW6M6E9CBRPPX4E5N 27832

SMOKE THROWN 01JYCX0CQD26J747ATW8AK7PRB 27369
SMOKE EXPLODED 01JYCX0CXBW6M6E9CBRPPX4E5N 27540
SMOKE DESTROYED 01JYCX0CQD26J747ATW8AK7PRB 28952
```

This grouping can be confirmed by actually looking at the demo to verify the "explosion" ticks for each grenade and then using those in combination with the notion that smokes last for a constant amount of time, i.e. SMOKE_EXPLODED_TICK - SMOKE_DESTROYED_TICK should be equal and constant for each smoke, which, using this grouping, they are (1412 ticks).

However, as you can see, the IDs of the grenades in the explosion logs go haywire after the second smoke is thrown, where each following smoke explosion log has the ID of the previous one.

**Expected behavior**
Each `SmokeStart` event should report the appropriate ID, meaning the logs should look like this (granted, in a different order, but grouping here for readability):

```
SMOKE THROWN 01JYCX0CTXM4BEQ2ANTW7M2QWC 24851
SMOKE EXPLODED 01JYCX0CTXM4BEQ2ANTW7M2QWC 25319
SMOKE DESTROYED 01JYCX0CTXM4BEQ2ANTW7M2QWC 26731

SMOKE THROWN 01JYCX0CXBW6M6E9CBRPPX4E5N 26065
SMOKE EXPLODED 01JYCX0CXBW6M6E9CBRPPX4E5N 26420
SMOKE DESTROYED 01JYCX0CXBW6M6E9CBRPPX4E5N 27832

SMOKE THROWN 01JYCX0CQD26J747ATW8AK7PRB 27369
SMOKE EXPLODED 01JYCX0CQD26J747ATW8AK7PRB 27540
SMOKE DESTROYED 01JYCX0CQD26J747ATW8AK7PRB 28952
```

**Library version**
v4.3.3

Discussion:
@CharlesGuinot: Hello,

I've been using the library since weeks now and really enjoy it! 
Thank you for the work!

I'm encountering the exact same issue.

Examples below:
https://imgur.com/a/Seifve3

Demo is dust2 from: https://www.hltv.org/matches/2382695/mouz-vs-vitality-iem-dallas-2025

Usually happens more with flashbang.
One good example is flashbangs at tick(currentFrame) 3148 and 3265

I wanted to link the nades from different events such PlayerHurt, Kill, and GrenadeProjectileThrow.

Please it would be nice to fix this so we can track nades through events.@markus-wa: Hi @CharlesGuinot @dankotov - can you test with `go get github.com/markus-wa/demoinfocs-golang@mw/issue-596-fix-SmokeStart-UniqueID2`@CharlesGuinot: Didn't manage to pull the branch with the command. 
"Invalid character '@' "
No version specified


@markus-wa: my bad - try `go get github.com/markus-wa/demoinfocs-golang/v5@097b925a37b0f9f00f6b7a9837423597d4592ec8`@akiver: @markus-wa i noticed a regression while backporting it into the v4, here is the PR https://github.com/markus-wa/demoinfocs-golang/pull/599

Solution patch:
diff --git a/pkg/demoinfocs/game_events.go b/pkg/demoinfocs/game_events.go
index cd407b5..4e579cb 100644
--- a/pkg/demoinfocs/game_events.go
+++ b/pkg/demoinfocs/game_events.go
@@ -324,7 +324,7 @@ func (geh gameEventHandler) clearGrenadeProjectiles() {
 	}
 
 	// Thrown grenades could not be deleted at the end of the round (if they are thrown at the very end, they never get destroyed)
-	geh.gameState().thrownGrenades = make(map[*common.Player][]*common.Equipment)
+	geh.gameState().thrownGrenades = make(map[*common.Player]map[common.EquipmentType][]*common.Equipment)
 	geh.gameState().flyingFlashbangs = make([]*FlyingFlashbang, 0)
 }
 
@@ -1021,7 +1021,11 @@ func (geh gameEventHandler) addThrownGrenade(p *common.Player, wep *common.Equip
 	}
 
 	gameState := geh.gameState()
-	gameState.thrownGrenades[p] = append(gameState.thrownGrenades[p], wep)
+	if gameState.thrownGrenades[p] == nil {
+		gameState.thrownGrenades[p] = make(map[common.EquipmentType][]*common.Equipment)
+	}
+
+	gameState.thrownGrenades[p][wep.Type] = append(gameState.thrownGrenades[p][wep.Type], wep)
 }
 
 func (geh gameEventHandler) getThrownGrenade(p *common.Player, wepType common.EquipmentType) *common.Equipment {
@@ -1030,14 +1034,32 @@ func (geh gameEventHandler) getThrownGrenade(p *common.Player, wepType common.Eq
 		return nil
 	}
 
-	// Get the first weapon we found for this player with this weapon type
-	for _, thrownGrenade := range geh.gameState().thrownGrenades[p] {
-		if isSameEquipmentElement(thrownGrenade.Type, wepType) {
-			return thrownGrenade
+	playerGrenades := geh.gameState().thrownGrenades[p]
+	grenades := playerGrenades[wepType]
+
+	if len(grenades) == 0 {
+		// Molotovs/incendiaries may be reported as the opposite type in game-events. (i.e. incendiary reported as molotov and vice versa)
+		switch wepType { //nolint:exhaustive
+		case common.EqIncendiary:
+			grenades = playerGrenades[common.EqMolotov]
+		case common.EqMolotov:
+			grenades = playerGrenades[common.EqIncendiary]
+		}
+	}
+
+	if len(grenades) == 0 {
+		// The player might be controlling a bot, in such case the thrown grenade is stored in the bot's state.
+		bot := p.ControlledBot()
+		if bot != nil && bot.SteamID64 != p.SteamID64 {
+			return geh.getThrownGrenade(bot, wepType)
 		}
 	}
 
-	return nil
+	if len(grenades) == 0 {
+		return nil
+	}
+
+	return grenades[len(grenades)-1]
 }
 
 func (geh gameEventHandler) deleteThrownGrenade(p *common.Player, wepType common.EquipmentType) {
@@ -1046,16 +1068,20 @@ func (geh gameEventHandler) deleteThrownGrenade(p *common.Player, wepType common
 		return
 	}
 
-	gameState := geh.gameState()
+	playerGrenades := geh.gameState().thrownGrenades[p]
+	if len(playerGrenades) == 0 {
+		return
+	}
 
-	// Delete the first weapon we found with this weapon type
-	for i, weapon := range gameState.thrownGrenades[p] {
-		// If same weapon type
-		// OR if it's an EqIncendiary we must check for EqMolotov too because of geh.infernoExpire() handling ?
-		if isSameEquipmentElement(wepType, weapon.Type) {
-			gameState.thrownGrenades[p] = append(gameState.thrownGrenades[p][:i], gameState.thrownGrenades[p][i+1:]...)
-			return
-		}
+	grenades := playerGrenades[wepType]
+	if len(grenades) == 0 {
+		return
+	}
+
+	// Delete the first grenade thrown by the player and this grenade type.
+	playerGrenades[wepType] = grenades[:len(grenades)-1]
+	if len(playerGrenades[wepType]) == 0 {
+		delete(playerGrenades, wepType)
 	}
 }
 
@@ -1090,13 +1116,6 @@ func (geh gameEventHandler) getEquipmentInstance(player *common.Player, wepType
 	return getPlayerWeapon(player, wepType)
 }
 
-// checks if two EquipmentElements are the same, considering that incendiary and molotov should be treated as identical
-func isSameEquipmentElement(a common.EquipmentType, b common.EquipmentType) bool {
-	return a == b ||
-		(a == common.EqIncendiary && b == common.EqMolotov) ||
-		(b == common.EqIncendiary && a == common.EqMolotov)
-}
-
 // Returns the players instance of the weapon if applicable or a new instance otherwise.
 func getPlayerWeapon(player *common.Player, wepType common.EquipmentType) *common.Equipment {
 	if player != nil {
diff --git a/pkg/demoinfocs/game_state.go b/pkg/demoinfocs/game_state.go
index 3004ff6..cad72e5 100644
--- a/pkg/demoinfocs/game_state.go
+++ b/pkg/demoinfocs/game_state.go
@@ -37,10 +37,10 @@ type gameState struct {
 	isFreezetime                 bool
 	isMatchStarted               bool
 	overtimeCount                int
-	lastFlash                    lastFlash                              // Information about the last flash that exploded, used to find the attacker and projectile for player_blind events
-	currentDefuser               *common.Player                         // Player currently defusing the bomb, if any
-	currentPlanter               *common.Player                         // Player currently planting the bomb, if any
-	thrownGrenades               map[*common.Player][]*common.Equipment // Information about every player's thrown grenades (from the moment they are thrown to the moment their effect is ended)
+	lastFlash                    lastFlash                                                       // Information about the last flash that exploded, used to find the attacker and projectile for player_blind events
+	currentDefuser               *common.Player                                                  // Player currently defusing the bomb, if any
+	currentPlanter               *common.Player                                                  // Player currently planting the bomb, if any
+	thrownGrenades               map[*common.Player]map[common.EquipmentType][]*common.Equipment // Information about every player's thrown grenades (from the moment they are thrown to the moment their effect is ended)
 	rules                        gameRules
 	demoInfo                     demoInfoProvider
 	lastRoundStartEvent          *events.RoundStart             // Used to dispatch this event after a possible MatchStartedChanged event
@@ -249,7 +249,7 @@ func newGameState(demoInfo demoInfoProvider) *gameState {
 		weapons:                  make(map[int]*common.Equipment),
 		hostages:                 make(map[int]*common.Hostage),
 		entities:                 make(map[int]st.Entity),
-		thrownGrenades:           make(map[*common.Player][]*common.Equipment),
+		thrownGrenades:           make(map[*common.Player]map[common.EquipmentType][]*common.Equipment),
 		flyingFlashbangs:         make([]*FlyingFlashbang, 0),
 		lastFlash: lastFlash{
 			projectileByPlayer: make(map[*common.Player]*common.GrenadeProjectile),



Test patch:
diff --git a/pkg/demoinfocs/game_events_test.go b/pkg/demoinfocs/game_events_test.go
index e7bed05..5974c9a 100644
--- a/pkg/demoinfocs/game_events_test.go
+++ b/pkg/demoinfocs/game_events_test.go
@@ -115,7 +115,7 @@ func TestAddThrownGrenade(t *testing.T) {
 
 	assert.NotEmpty(t, p.gameState.thrownGrenades)
 	assert.NotEmpty(t, p.gameState.thrownGrenades[pl])
-	assert.Equal(t, p.gameState.thrownGrenades[pl][0], he)
+	assert.Equal(t, p.gameState.thrownGrenades[pl][common.EqHE][0], he)
 }
 
 func TestGetThrownGrenade_NilPlayer(t *testing.T) {