Repository: markus-wa/demoinfocs-golang
Pull request #269 (2021-03-10 07:43:08)

Give some love to hostage maps
Hi!

This PR allows to access hostages information and adds events related to hostage maps.

You can find a hostage demo here:
https://www85.zippyshare.com/v/ebimojQq/file.html

Thank you

akiver: @markus-wa
```
package should be `common_test` instead of `common` (testpackage)
```

Should I disable this linter?
markus-wa: Thanks for this PR @akiver - that's some amazing first contribution! Great job on following existing patterns and good test coverage!

I think the reviewdog comment is correct, we should change test files to be in packages called `_test`

Other than that I just had two questions I put in some comments but I think once those are answered we should be good to merge this

:clap: 
akiver: > I think the reviewdog comment is correct, we should change test files to be in packages called `_test`

If we move the tests in a package, non-exposed fields such as `demoInfoProvider` in the `Player` struct will need to be exposed.
What do you think?

markus-wa: > If we move the tests in a package, non-exposed fields such as demoInfoProvider in the Player struct will need to be exposed.
What do you think?

ahh :hankey: - you're right, let's not do that for now. I might have to go and re-jig those tests at some point (and make them run in parallel!)


markus-wa: found something else, but I think that's all (promise)
akiver: > > If we move the tests in a package, non-exposed fields such as demoInfoProvider in the Player struct will need to be exposed.
> > What do you think?
> 
> ahh 💩 - you're right, let's not do that for now. I might have to go and re-jig those tests at some point (and make them run in parallel!)

I disabled the linter in the meantime 🙂

codecov[bot]: # [Codecov](https://codecov.io/gh/markus-wa/demoinfocs-golang/pull/269?src=pr&el=h1) Report
> Merging [#269](https://codecov.io/gh/markus-wa/demoinfocs-golang/pull/269?src=pr&el=desc) (244165b) into [master](https://codecov.io/gh/markus-wa/demoinfocs-golang/commit/553b6958152e70142c47ca552f181c747e56ed29?el=desc) (553b695) will **decrease** coverage by `0.98%`.
> The diff coverage is `40.00%`.

[![Impacted file tree graph](https://codecov.io/gh/markus-wa/demoinfocs-golang/pull/269/graphs/tree.svg?width=650&height=150&src=pr&token=20emCaZRHz)](https://codecov.io/gh/markus-wa/demoinfocs-golang/pull/269?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #269      +/-   ##
==========================================
- Coverage   92.24%   91.25%   -0.99%     
==========================================
  Files          28       29       +1     
  Lines        2410     2458      +48     
==========================================
+ Hits         2223     2243      +20     
- Misses        134      161      +27     
- Partials       53       54       +1     
```


| [Impacted Files](https://codecov.io/gh/markus-wa/demoinfocs-golang/pull/269?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [pkg/demoinfocs/events/events.go](https://codecov.io/gh/markus-wa/demoinfocs-golang/pull/269/diff?src=pr&el=tree#diff-cGtnL2RlbW9pbmZvY3MvZXZlbnRzL2V2ZW50cy5nbw==) | `87.50% <ø> (ø)` | |
| [pkg/demoinfocs/datatables.go](https://codecov.io/gh/markus-wa/demoinfocs-golang/pull/269/diff?src=pr&el=tree#diff-cGtnL2RlbW9pbmZvY3MvZGF0YXRhYmxlcy5nbw==) | `95.79% <7.69%> (-3.58%)` | :arrow_down: |
| [pkg/demoinfocs/game\_events.go](https://codecov.io/gh/markus-wa/demoinfocs-golang/pull/269/diff?src=pr&el=tree#diff-cGtnL2RlbW9pbmZvY3MvZ2FtZV9ldmVudHMuZ28=) | `92.32% <36.84%> (-2.54%)` | :arrow_down: |
| [pkg/demoinfocs/common/hostage.go](https://codecov.io/gh/markus-wa/demoinfocs-golang/pull/269/diff?src=pr&el=tree#diff-cGtnL2RlbW9pbmZvY3MvY29tbW9uL2hvc3RhZ2UuZ28=) | `66.66% <66.66%> (ø)` | |
| [pkg/demoinfocs/game\_state.go](https://codecov.io/gh/markus-wa/demoinfocs-golang/pull/269/diff?src=pr&el=tree#diff-cGtnL2RlbW9pbmZvY3MvZ2FtZV9zdGF0ZS5nbw==) | `91.00% <100.00%> (+0.37%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/markus-wa/demoinfocs-golang/pull/269?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/markus-wa/demoinfocs-golang/pull/269?src=pr&el=footer). Last update [553b695...244165b](https://codecov.io/gh/markus-wa/demoinfocs-golang/pull/269?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

markus-wa: > https://www85.zippyshare.com/v/ebimojQq/file.html

that link is not working for me @akiver :disappointed: 

But I think I'm happy with the code as is so I'll go ahead and merge it.
Could you re-upload that demo somewhere though? I might want to include it in the regression set.

Patch:
diff --git a/.golangci.yml b/.golangci.yml
index e186fd0..d3a896a 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -18,6 +18,7 @@ linters:
     - exhaustivestruct
     - godot
     - gofumpt
+    - testpackage
 issues:
   exclude-rules:
     # Exclude some linters from running on tests files.
diff --git a/pkg/demoinfocs/common/hostage.go b/pkg/demoinfocs/common/hostage.go
new file mode 100644
index 0000000..1407a3c
--- /dev/null
+++ b/pkg/demoinfocs/common/hostage.go
@@ -0,0 +1,65 @@
+package common
+
+import (
+	"github.com/golang/geo/r3"
+
+	st "github.com/markus-wa/demoinfocs-golang/v2/pkg/demoinfocs/sendtables"
+)
+
+// HostageState is the type for the various HostageStateXYZ constants.
+type HostageState byte
+
+// HostageState constants give information about hostages state.
+// e.g. being untied, picked up, rescued etc.
+const (
+	HostageStateIdle            HostageState = 0
+	HostageStateBeingUntied     HostageState = 1
+	HostageStateGettingPickedUp HostageState = 2
+	HostageStateBeingCarried    HostageState = 3
+	HostageStateFollowingPlayer HostageState = 4
+	HostageStateGettingDropped  HostageState = 5
+	HostageStateRescued         HostageState = 6
+	HostageStateDead            HostageState = 7
+)
+
+// Hostage represents a hostage.
+type Hostage struct {
+	Entity           st.Entity
+	demoInfoProvider demoInfoProvider
+}
+
+// Position returns the current position of the hostage.
+func (hostage *Hostage) Position() r3.Vector {
+	if hostage.Entity == nil {
+		return r3.Vector{}
+	}
+
+	return hostage.Entity.Position()
+}
+
+// State returns the current hostage's state.
+// e.g. being untied, picked up, rescued etc.
+// See HostageState for all possible values.
+func (hostage *Hostage) State() HostageState {
+	return HostageState(getInt(hostage.Entity, "m_nHostageState"))
+}
+
+// Health returns the hostage's health points.
+// ! On Valve MM matches hostages are invulnerable, it will always return 100 unless "mp_hostages_takedamage" is set to 1
+func (hostage *Hostage) Health() int {
+	return getInt(hostage.Entity, "m_iHealth")
+}
+
+// Leader returns the possible player leading the hostage.
+// Returns nil if the hostage is not following a player.
+func (hostage *Hostage) Leader() *Player {
+	return hostage.demoInfoProvider.FindPlayerByHandle(getInt(hostage.Entity, "m_leader"))
+}
+
+// NewHostage creates a hostage.
+func NewHostage(demoInfoProvider demoInfoProvider, entity st.Entity) *Hostage {
+	return &Hostage{
+		demoInfoProvider: demoInfoProvider,
+		Entity:           entity,
+	}
+}
diff --git a/pkg/demoinfocs/datatables.go b/pkg/demoinfocs/datatables.go
index ef239aa..7f9dd6c 100644
--- a/pkg/demoinfocs/datatables.go
+++ b/pkg/demoinfocs/datatables.go
@@ -66,6 +66,7 @@ func (p *parser) bindEntities() {
 	p.bindWeapons()
 	p.bindBomb()
 	p.bindGameRules()
+	p.bindHostages()
 }
 
 func (p *parser) bindBomb() {
@@ -595,3 +596,23 @@ func (p *parser) bindGameRules() {
 		// "m_nCTTimeOuts"
 	})
 }
+
+func (p *parser) bindHostages() {
+	p.stParser.ServerClasses().FindByName("CHostage").OnEntityCreated(func(entity st.Entity) {
+		entityID := entity.ID()
+		p.gameState.hostages[entityID] = common.NewHostage(p.demoInfoProvider, entity)
+
+		entity.OnDestroy(func() {
+			delete(p.gameState.hostages, entityID)
+		})
+
+		var state common.HostageState
+		entity.Property("m_nHostageState").OnUpdate(func(val st.PropertyValue) {
+			oldState := state
+			state = common.HostageState(val.IntVal)
+			if oldState != state {
+				p.eventDispatcher.Dispatch(events.HostageStateChanged{OldState: oldState, NewState: state, Hostage: p.gameState.hostages[entityID]})
+			}
+		})
+	})
+}
diff --git a/pkg/demoinfocs/events/events.go b/pkg/demoinfocs/events/events.go
index 07bdece..7b29603 100644
--- a/pkg/demoinfocs/events/events.go
+++ b/pkg/demoinfocs/events/events.go
@@ -343,6 +343,36 @@ type BombPickup struct {
 	Player *common.Player
 }
 
+// HostageRecued signals that a hostage has been rescued.
+type HostageRecued struct {
+	Player  *common.Player
+	Hostage *common.Hostage
+}
+
+// HostageRescuedAll signals that all hostages have been rescued.
+type HostageRescuedAll struct{}
+
+// HostageHurt signals that a hostage has been hurt.
+type HostageHurt struct {
+	Player  *common.Player
+	Hostage *common.Hostage
+}
+
+// HostageKilled signals that a hostage has been killed.
+type HostageKilled struct {
+	Killer  *common.Player
+	Hostage *common.Hostage
+}
+
+// HostageStateChanged signals that the state of a hostage has changed.
+// e.g. being untied, picked up, rescued etc.
+// See HostageState for all possible values.
+type HostageStateChanged struct {
+	OldState common.HostageState
+	NewState common.HostageState
+	Hostage  *common.Hostage
+}
+
 // HitGroup is the type for the various HitGroupXYZ constants.
 //
 // See PlayerHurt.
diff --git a/pkg/demoinfocs/fake/game_state.go b/pkg/demoinfocs/fake/game_state.go
index 06c6eb4..2ae7be9 100644
--- a/pkg/demoinfocs/fake/game_state.go
+++ b/pkg/demoinfocs/fake/game_state.go
@@ -99,3 +99,8 @@ func (gs *GameState) Rules() demoinfocs.GameRules {
 func (gs *GameState) PlayerResourceEntity() st.Entity {
 	return gs.Called().Get(0).(st.Entity)
 }
+
+// Hostages is a mock-implementation of GameState.Hostages().
+func (gs *GameState) Hostages() []*common.Hostage {
+	return gs.Called().Get(0).([]*common.Hostage)
+}
diff --git a/pkg/demoinfocs/game_events.go b/pkg/demoinfocs/game_events.go
index 7845400..6154a52 100644
--- a/pkg/demoinfocs/game_events.go
+++ b/pkg/demoinfocs/game_events.go
@@ -136,6 +136,10 @@ func newGameEventHandler(parser *parser) gameEventHandler {
 		"exit_buyzone":                    nil,                                   // Dunno, only in locally recorded (POV) demo
 		"flashbang_detonate":              geh.flashBangDetonate,                 // Flash exploded
 		"hegrenade_detonate":              geh.heGrenadeDetonate,                 // HE exploded
+		"hostage_killed":                  geh.hostageKilled,                     // Hostage killed
+		"hostage_hurt":                    geh.hostageHurt,                       // Hostage hurt
+		"hostage_rescued":                 geh.hostageRescued,                    // Hostage rescued
+		"hostage_rescued_all":             geh.HostageRescuedAll,                 // All hostages rescued
 		"hltv_chase":                      nil,                                   // Don't care
 		"hltv_fixed":                      nil,                                   // Dunno
 		"hltv_message":                    nil,                                   // No clue
@@ -476,6 +480,37 @@ func (geh gameEventHandler) infernoExpire(data map[string]*msg.CSVCMsg_GameEvent
 	})
 }
 
+func (geh gameEventHandler) hostageHurt(data map[string]*msg.CSVCMsg_GameEventKeyT) {
+	event := events.HostageHurt{
+		Player:  geh.playerByUserID32(data["userid"].GetValShort()),
+		Hostage: geh.gameState().hostages[int(data["hostage"].GetValShort())],
+	}
+
+	geh.dispatch(event)
+}
+
+func (geh gameEventHandler) hostageKilled(data map[string]*msg.CSVCMsg_GameEventKeyT) {
+	event := events.HostageKilled{
+		Killer:  geh.playerByUserID32(data["userid"].GetValShort()),
+		Hostage: geh.gameState().hostages[int(data["hostage"].GetValShort())],
+	}
+
+	geh.dispatch(event)
+}
+
+func (geh gameEventHandler) hostageRescued(data map[string]*msg.CSVCMsg_GameEventKeyT) {
+	event := events.HostageRecued{
+		Player:  geh.playerByUserID32(data["userid"].GetValShort()),
+		Hostage: geh.gameState().hostages[int(data["hostage"].GetValShort())],
+	}
+
+	geh.dispatch(event)
+}
+
+func (geh gameEventHandler) HostageRescuedAll(map[string]*msg.CSVCMsg_GameEventKeyT) {
+	geh.dispatch(events.HostageRescuedAll{})
+}
+
 func (geh gameEventHandler) playerConnect(data map[string]*msg.CSVCMsg_GameEventKeyT) {
 	pl := &playerInfo{
 		userID: int(data["userid"].GetValShort()),
diff --git a/pkg/demoinfocs/game_state.go b/pkg/demoinfocs/game_state.go
index 1824a73..d914813 100644
--- a/pkg/demoinfocs/game_state.go
+++ b/pkg/demoinfocs/game_state.go
@@ -25,6 +25,7 @@ type gameState struct {
 	grenadeProjectiles   map[int]*common.GrenadeProjectile // Maps entity-IDs to active nade-projectiles. That's grenades that have been thrown, but have not yet detonated.
 	infernos             map[int]*common.Inferno           // Maps entity-IDs to active infernos.
 	weapons              map[int]*common.Equipment         // Maps entity IDs to weapons. Used to remember what a weapon is (p250 / cz etc.)
+	hostages             map[int]*common.Hostage           // Maps entity-IDs to hostages.
 	entities             map[int]st.Entity                 // Maps entity IDs to entities
 	bomb                 common.Bomb
 	totalRoundsPlayed    int
@@ -101,6 +102,16 @@ func (gs gameState) Rules() GameRules {
 	return gs.rules
 }
 
+// Hostages returns all current hostages.
+func (gs gameState) Hostages() []*common.Hostage {
+	hostages := make([]*common.Hostage, 0, len(gs.hostages))
+	for _, hostage := range gs.hostages {
+		hostages = append(hostages, hostage)
+	}
+
+	return hostages
+}
+
 // GrenadeProjectiles returns a map from entity-IDs to all live grenade projectiles.
 //
 // Only constains projectiles currently in-flight or still active (smokes etc.),
@@ -171,6 +182,7 @@ func newGameState(demoInfo demoInfoProvider) *gameState {
 		grenadeProjectiles: make(map[int]*common.GrenadeProjectile),
 		infernos:           make(map[int]*common.Inferno),
 		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),
 		lastFlash: lastFlash{
diff --git a/pkg/demoinfocs/game_state_interface.go b/pkg/demoinfocs/game_state_interface.go
index b9c40bf..6653ba5 100644
--- a/pkg/demoinfocs/game_state_interface.go
+++ b/pkg/demoinfocs/game_state_interface.go
@@ -33,6 +33,8 @@ type GameState interface {
 	// Rules returns the GameRules for the current match.
 	// Contains information like freeze time duration etc.
 	Rules() GameRules
+	// Hostages returns all current hostages.
+	Hostages() []*common.Hostage
 	// GrenadeProjectiles returns a map from entity-IDs to all live grenade projectiles.
 	//
 	// Only constains projectiles currently in-flight or still active (smokes etc.),


Test patch:
diff --git a/pkg/demoinfocs/common/hostage_test.go b/pkg/demoinfocs/common/hostage_test.go
new file mode 100644
index 0000000..199d16a
--- /dev/null
+++ b/pkg/demoinfocs/common/hostage_test.go
@@ -0,0 +1,36 @@
+package common
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	st "github.com/markus-wa/demoinfocs-golang/v2/pkg/demoinfocs/sendtables"
+)
+
+func TestHostage_Leader(t *testing.T) {
+	player := new(Player)
+	player.EntityID = 10
+	provider := demoInfoProviderMock{
+		playersByHandle: map[int]*Player{10: player},
+	}
+	hostage := hostageWithProperty("m_leader", st.PropertyValue{IntVal: 10}, provider)
+
+	assert.Equal(t, player, hostage.Leader())
+}
+
+func TestHostage_State(t *testing.T) {
+	hostage := hostageWithProperty("m_nHostageState", st.PropertyValue{IntVal: int(HostageStateFollowingPlayer)}, demoInfoProviderMock{})
+
+	assert.Equal(t, HostageStateFollowingPlayer, hostage.State())
+}
+
+func TestHostage_Health(t *testing.T) {
+	hostage := hostageWithProperty("m_iHealth", st.PropertyValue{IntVal: 40}, demoInfoProviderMock{})
+
+	assert.Equal(t, 40, hostage.Health())
+}
+
+func hostageWithProperty(propName string, value st.PropertyValue, provider demoInfoProviderMock) *Hostage {
+	return &Hostage{Entity: entityWithProperty(propName, value), demoInfoProvider: provider}
+}
diff --git a/pkg/demoinfocs/game_state_test.go b/pkg/demoinfocs/game_state_test.go
index c7c2c30..8778e81 100644
--- a/pkg/demoinfocs/game_state_test.go
+++ b/pkg/demoinfocs/game_state_test.go
@@ -20,6 +20,7 @@ func TestNewGameState(t *testing.T) {
 	assert.NotNil(t, gs.grenadeProjectiles)
 	assert.NotNil(t, gs.infernos)
 	assert.NotNil(t, gs.weapons)
+	assert.NotNil(t, gs.hostages)
 	assert.NotNil(t, gs.entities)
 	assert.NotNil(t, gs.rules.conVars)
 	assert.Equal(t, common.TeamTerrorists, gs.tState.Team())
@@ -359,3 +360,13 @@ func newDisconnectedPlayer() *common.Player {
 
 	return pl
 }
+
+func TestGameState_Hostages(t *testing.T) {
+	hostageA := common.NewHostage(nil, new(stfake.Entity))
+	hostageB := common.NewHostage(nil, new(stfake.Entity))
+	hostages := map[int]*common.Hostage{0: hostageA, 1: hostageB}
+	gs := gameState{hostages: hostages}
+
+	expectedHostages := []*common.Hostage{hostageA, hostageB}
+	assert.Equal(t, expectedHostages, gs.Hostages())
+}