Repository: markus-wa/demoinfocs-golang
Pull request #265 (2021-02-15 21:20:20)

add GameState.Rules().{Round,Freeze,Bomb}Time()

codecov[bot]: # [Codecov](https://codecov.io/gh/markus-wa/demoinfocs-golang/pull/265?src=pr&el=h1) Report
> Merging [#265](https://codecov.io/gh/markus-wa/demoinfocs-golang/pull/265?src=pr&el=desc) (1c6e316) into [master](https://codecov.io/gh/markus-wa/demoinfocs-golang/commit/6129f1ac88b97dfbb977cd90f107d1d3e3aa9020?el=desc) (6129f1a) will **decrease** coverage by `0.52%`.
> The diff coverage is `39.13%`.

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

```diff
@@            Coverage Diff             @@
##           master     #265      +/-   ##
==========================================
- Coverage   92.25%   91.72%   -0.53%     
==========================================
  Files          28       28              
  Lines        2388     2406      +18     
==========================================
+ Hits         2203     2207       +4     
- Misses        132      146      +14     
  Partials       53       53              
```


| [Impacted Files](https://codecov.io/gh/markus-wa/demoinfocs-golang/pull/265?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [pkg/demoinfocs/game\_state.go](https://codecov.io/gh/markus-wa/demoinfocs-golang/pull/265/diff?src=pr&el=tree#diff-cGtnL2RlbW9pbmZvY3MvZ2FtZV9zdGF0ZS5nbw==) | `77.41% <26.31%> (-13.38%)` | :arrow_down: |
| [pkg/demoinfocs/datatables.go](https://codecov.io/gh/markus-wa/demoinfocs-golang/pull/265/diff?src=pr&el=tree#diff-cGtnL2RlbW9pbmZvY3MvZGF0YXRhYmxlcy5nbw==) | `99.37% <100.00%> (+<0.01%)` | :arrow_up: |
| [pkg/demoinfocs/net\_messages.go](https://codecov.io/gh/markus-wa/demoinfocs-golang/pull/265/diff?src=pr&el=tree#diff-cGtnL2RlbW9pbmZvY3MvbmV0X21lc3NhZ2VzLmdv) | `100.00% <100.00%> (ø)` | |
| [pkg/demoinfocs/parser.go](https://codecov.io/gh/markus-wa/demoinfocs-golang/pull/265/diff?src=pr&el=tree#diff-cGtnL2RlbW9pbmZvY3MvcGFyc2VyLmdv) | `85.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/markus-wa/demoinfocs-golang/pull/265?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/265?src=pr&el=footer). Last update [6129f1a...1c6e316](https://codecov.io/gh/markus-wa/demoinfocs-golang/pull/265?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/.golangci.yml b/.golangci.yml
index bfeee01..e186fd0 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -29,3 +29,5 @@ linters-settings:
   gocritic:
     disabled-checks:
       - ifElseChain
+  gci:
+    local-prefixes: github.com/markus-wa/demoinfocs-golang/v2
diff --git a/pkg/demoinfocs/datatables.go b/pkg/demoinfocs/datatables.go
index 0f9e8fe..0f5b567 100644
--- a/pkg/demoinfocs/datatables.go
+++ b/pkg/demoinfocs/datatables.go
@@ -533,6 +533,8 @@ func (p *parser) bindGameRules() {
 
 	gameRules := p.ServerClasses().FindByName("CCSGameRulesProxy")
 	gameRules.OnEntityCreated(func(entity st.Entity) {
+		p.gameState.rules.entity = entity
+
 		entity.Property(grPrefix("m_gamePhase")).OnUpdate(func(val st.PropertyValue) {
 			oldGamePhase := p.gameState.gamePhase
 			p.gameState.gamePhase = common.GamePhase(val.IntVal)
diff --git a/pkg/demoinfocs/fake/game_rules.go b/pkg/demoinfocs/fake/game_rules.go
new file mode 100644
index 0000000..6244b40
--- /dev/null
+++ b/pkg/demoinfocs/fake/game_rules.go
@@ -0,0 +1,36 @@
+package fake
+
+import (
+	"time"
+
+	"github.com/stretchr/testify/mock"
+
+	demoinfocs "github.com/markus-wa/demoinfocs-golang/v2/pkg/demoinfocs"
+)
+
+var _ demoinfocs.GameRules = new(GameRules)
+
+// GameRules is a mock for of demoinfocs.GameRules.
+type GameRules struct {
+	mock.Mock
+}
+
+// BombTime is a mock-implementation of GameRules.ByUserID().
+func (gr *GameRules) BombTime() (time.Duration, error) {
+	return gr.Called().Get(0).(time.Duration), gr.Called().Get(0).(error)
+}
+
+// FreezeTime is a mock-implementation of GameRules.ByUserID().
+func (gr *GameRules) FreezeTime() (time.Duration, error) {
+	return gr.Called().Get(0).(time.Duration), gr.Called().Get(0).(error)
+}
+
+// RoundTime is a mock-implementation of GameRules.ByUserID().
+func (gr *GameRules) RoundTime() (time.Duration, error) {
+	return gr.Called().Get(0).(time.Duration), gr.Called().Get(0).(error)
+}
+
+// ConVars is a mock-implementation of GameRules.ConVars().
+func (gr *GameRules) ConVars() map[string]string {
+	return gr.Called().Get(0).(map[string]string)
+}
diff --git a/pkg/demoinfocs/fake/game_state.go b/pkg/demoinfocs/fake/game_state.go
index fbdbf20..02defac 100644
--- a/pkg/demoinfocs/fake/game_state.go
+++ b/pkg/demoinfocs/fake/game_state.go
@@ -89,3 +89,8 @@ func (gs *GameState) IsMatchStarted() bool {
 func (gs *GameState) ConVars() map[string]string {
 	return gs.Called().Get(0).(map[string]string)
 }
+
+// Rules is a mock-implementation of GameState.Rules().
+func (gs *GameState) Rules() demoinfocs.GameRules {
+	return gs.Called().Get(0).(demoinfocs.GameRules)
+}
diff --git a/pkg/demoinfocs/game_rules_interface.go b/pkg/demoinfocs/game_rules_interface.go
new file mode 100644
index 0000000..ab21d81
--- /dev/null
+++ b/pkg/demoinfocs/game_rules_interface.go
@@ -0,0 +1,24 @@
+// DO NOT EDIT: Auto generated
+
+package demoinfocs
+
+import (
+	"time"
+)
+
+// GameRules is an auto-generated interface for gameRules.
+type GameRules interface {
+	// RoundTime returns how long rounds in the current match last for (excluding freeze time).
+	// May return error if cs_gamerules_data.m_iRoundTime is not set.
+	RoundTime() (time.Duration, error)
+	// FreezeTime returns how long freeze time lasts for in the current match (mp_freezetime).
+	// May return error if mp_freezetime cannot be converted to a time duration.
+	FreezeTime() (time.Duration, error)
+	// BombTime returns how long freeze time lasts for in the current match (mp_freezetime).
+	// May return error if mp_c4timer cannot be converted to a time duration.
+	BombTime() (time.Duration, error)
+	// ConVars returns a map of CVar keys and values.
+	// Not all values might be set.
+	// See also: https://developer.valvesoftware.com/wiki/List_of_CS:GO_Cvars.
+	ConVars() map[string]string
+}
diff --git a/pkg/demoinfocs/game_state.go b/pkg/demoinfocs/game_state.go
index 68a28b4..3bf5ae1 100644
--- a/pkg/demoinfocs/game_state.go
+++ b/pkg/demoinfocs/game_state.go
@@ -1,6 +1,10 @@
 package demoinfocs
 
 import (
+	"errors"
+	"strconv"
+	"time"
+
 	constants "github.com/markus-wa/demoinfocs-golang/v2/internal/constants"
 	common "github.com/markus-wa/demoinfocs-golang/v2/pkg/demoinfocs/common"
 	st "github.com/markus-wa/demoinfocs-golang/v2/pkg/demoinfocs/sendtables"
@@ -8,6 +12,7 @@ import (
 
 //go:generate ifacemaker -f game_state.go -s gameState -i GameState -p demoinfocs -D -y "GameState is an auto-generated interface for gameState." -c "DO NOT EDIT: Auto generated" -o game_state_interface.go
 //go:generate ifacemaker -f game_state.go -s participants -i Participants -p demoinfocs -D -y "Participants is an auto-generated interface for participants." -c "DO NOT EDIT: Auto generated" -o participants_interface.go
+//go:generate ifacemaker -f game_state.go -s gameRules -i GameRules -p demoinfocs -D -y "GameRules is an auto-generated interface for gameRules." -c "DO NOT EDIT: Auto generated" -o game_rules_interface.go
 
 // gameState contains all game-state relevant information.
 type gameState struct {
@@ -20,7 +25,6 @@ type gameState struct {
 	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.)
 	entities           map[int]st.Entity                 // Maps entity IDs to entities
-	conVars            map[string]string
 	bomb               common.Bomb
 	totalRoundsPlayed  int
 	gamePhase          common.GamePhase
@@ -30,6 +34,8 @@ type gameState struct {
 	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)
+	rules              gameRules
+	demoInfo           demoInfoProvider
 }
 
 type lastFlash struct {
@@ -88,6 +94,12 @@ func (gs gameState) Participants() Participants {
 	}
 }
 
+// Rules returns the GameRules for the current match.
+// Contains information like freeze time duration etc.
+func (gs gameState) Rules() GameRules {
+	return gs.rules
+}
+
 // GrenadeProjectiles returns a map from entity-IDs to all live grenade projectiles.
 //
 // Only constains projectiles currently in-flight or still active (smokes etc.),
@@ -140,11 +152,12 @@ func (gs gameState) IsMatchStarted() bool {
 // ConVars returns a map of CVar keys and values.
 // Not all values might be set.
 // See also: https://developer.valvesoftware.com/wiki/List_of_CS:GO_Cvars.
+// Deprecated: see GameRules().ConVars()
 func (gs *gameState) ConVars() map[string]string {
-	return gs.conVars
+	return gs.rules.ConVars()
 }
 
-func newGameState() *gameState {
+func newGameState(demoInfo demoInfoProvider) *gameState {
 	gs := &gameState{
 		playersByEntityID:  make(map[int]*common.Player),
 		playersByUserID:    make(map[int]*common.Player),
@@ -152,11 +165,14 @@ func newGameState() *gameState {
 		infernos:           make(map[int]*common.Inferno),
 		weapons:            make(map[int]*common.Equipment),
 		entities:           make(map[int]st.Entity),
-		conVars:            make(map[string]string),
 		thrownGrenades:     make(map[*common.Player][]*common.Equipment),
 		lastFlash: lastFlash{
 			projectileByPlayer: make(map[*common.Player]*common.GrenadeProjectile),
 		},
+		rules: gameRules{
+			conVars: make(map[string]string),
+		},
+		demoInfo: demoInfo,
 	}
 
 	gs.tState = common.NewTeamState(common.TeamTerrorists, gs.Participants().TeamMembers)
@@ -167,6 +183,57 @@ func newGameState() *gameState {
 	return gs
 }
 
+type gameRules struct {
+	conVars map[string]string
+	entity  st.Entity
+}
+
+var ErrFailedToRetrieveGameRule = errors.New("failed to retrieve GameRule value, it's recommended to have a fallback to a default value for this scenario")
+
+// RoundTime returns how long rounds in the current match last for (excluding freeze time).
+// May return error if cs_gamerules_data.m_iRoundTime is not set.
+func (gr gameRules) RoundTime() (time.Duration, error) {
+	if gr.entity == nil {
+		return 0, ErrFailedToRetrieveGameRule
+	}
+
+	prop := gr.entity.Property("cs_gamerules_data.m_iRoundTime")
+	if prop == nil {
+		return 0, ErrFailedToRetrieveGameRule
+	}
+
+	return time.Duration(prop.Value().IntVal) * time.Second, nil
+}
+
+// FreezeTime returns how long freeze time lasts for in the current match (mp_freezetime).
+// May return error if mp_freezetime cannot be converted to a time duration.
+func (gr gameRules) FreezeTime() (time.Duration, error) {
+	t, err := strconv.Atoi(gr.conVars["mp_freezetime"])
+	if err != nil {
+		return 0, ErrFailedToRetrieveGameRule
+	}
+
+	return time.Duration(t) * time.Second, nil
+}
+
+// BombTime returns how long freeze time lasts for in the current match (mp_freezetime).
+// May return error if mp_c4timer cannot be converted to a time duration.
+func (gr gameRules) BombTime() (time.Duration, error) {
+	t, err := strconv.Atoi(gr.conVars["mp_c4timer"])
+	if err != nil {
+		return 0, ErrFailedToRetrieveGameRule
+	}
+
+	return time.Duration(t) * time.Second, nil
+}
+
+// ConVars returns a map of CVar keys and values.
+// Not all values might be set.
+// See also: https://developer.valvesoftware.com/wiki/List_of_CS:GO_Cvars.
+func (gr gameRules) ConVars() map[string]string {
+	return gr.conVars
+}
+
 // participants provides helper functions on top of the currently connected players.
 // E.g. ByUserID(), ByEntityID(), TeamMembers(), etc.
 //
diff --git a/pkg/demoinfocs/game_state_interface.go b/pkg/demoinfocs/game_state_interface.go
index abac0c4..691182e 100644
--- a/pkg/demoinfocs/game_state_interface.go
+++ b/pkg/demoinfocs/game_state_interface.go
@@ -30,6 +30,9 @@ type GameState interface {
 	// Participants returns a struct with all currently connected players & spectators and utility functions.
 	// The struct contains references to the original maps so it's always up-to-date.
 	Participants() Participants
+	// Rules returns the GameRules for the current match.
+	// Contains information like freeze time duration etc.
+	Rules() GameRules
 	// GrenadeProjectiles returns a map from entity-IDs to all live grenade projectiles.
 	//
 	// Only constains projectiles currently in-flight or still active (smokes etc.),
@@ -55,5 +58,6 @@ type GameState interface {
 	// ConVars returns a map of CVar keys and values.
 	// Not all values might be set.
 	// See also: https://developer.valvesoftware.com/wiki/List_of_CS:GO_Cvars.
+	// Deprecated: see GameRules().ConVars()
 	ConVars() map[string]string
 }
diff --git a/pkg/demoinfocs/net_messages.go b/pkg/demoinfocs/net_messages.go
index f955416..8123199 100644
--- a/pkg/demoinfocs/net_messages.go
+++ b/pkg/demoinfocs/net_messages.go
@@ -54,7 +54,7 @@ func (p *parser) handleSetConVar(setConVar *msg.CNETMsg_SetConVar) {
 	updated := make(map[string]string)
 	for _, cvar := range setConVar.Convars.Cvars {
 		updated[cvar.Name] = cvar.Value
-		p.gameState.conVars[cvar.Name] = cvar.Value
+		p.gameState.rules.conVars[cvar.Name] = cvar.Value
 	}
 
 	p.eventDispatcher.Dispatch(events.ConVarsUpdated{
diff --git a/pkg/demoinfocs/parser.go b/pkg/demoinfocs/parser.go
index b9a91a6..1881adb 100644
--- a/pkg/demoinfocs/parser.go
+++ b/pkg/demoinfocs/parser.go
@@ -305,11 +305,11 @@ func NewParserWithConfig(demostream io.Reader, config ParserConfig) Parser {
 	p.rawPlayers = make(map[int]*playerInfo)
 	p.triggers = make(map[int]*boundingBoxInformation)
 	p.cancelChan = make(chan struct{}, 1)
-	p.gameState = newGameState()
+	p.demoInfoProvider = demoInfoProvider{parser: &p}
+	p.gameState = newGameState(p.demoInfoProvider)
 	p.grenadeModelIndices = make(map[int]common.EquipmentType)
 	p.gameEventHandler = newGameEventHandler(&p)
 	p.userMessageHandler = newUserMessageHandler(&p)
-	p.demoInfoProvider = demoInfoProvider{parser: &p}
 
 	dispatcherCfg := dp.Config{
 		PanicHandler: func(v interface{}) {
diff --git a/pkg/demoinfocs/sendtables/fake/entity.go b/pkg/demoinfocs/sendtables/fake/entity.go
index 514839f..26c232e 100644
--- a/pkg/demoinfocs/sendtables/fake/entity.go
+++ b/pkg/demoinfocs/sendtables/fake/entity.go
@@ -46,7 +46,13 @@ func (e *Entity) Properties() []st.Property {
 
 // Property is a mock-implementation of Entity.Property().
 func (e *Entity) Property(name string) st.Property {
-	return e.Called(name).Get(0).(st.Property)
+	v := e.Called(name).Get(0)
+	if v == nil {
+		// see https://stackoverflow.com/questions/13476349/check-for-nil-and-nil-interface-in-go
+		return nil
+	}
+
+	return v.(st.Property)
 }
 
 // BindProperty is a mock-implementation of Entity.BindProperty().


Test patch:
diff --git a/pkg/demoinfocs/game_state_test.go b/pkg/demoinfocs/game_state_test.go
index ae494c1..c7c2c30 100644
--- a/pkg/demoinfocs/game_state_test.go
+++ b/pkg/demoinfocs/game_state_test.go
@@ -2,6 +2,7 @@ package demoinfocs
 
 import (
 	"testing"
+	"time"
 
 	"github.com/stretchr/testify/assert"
 
@@ -12,7 +13,7 @@ import (
 )
 
 func TestNewGameState(t *testing.T) {
-	gs := newGameState()
+	gs := newGameState(demoInfoProvider{})
 
 	assert.NotNil(t, gs.playersByEntityID)
 	assert.NotNil(t, gs.playersByUserID)
@@ -20,26 +21,27 @@ func TestNewGameState(t *testing.T) {
 	assert.NotNil(t, gs.infernos)
 	assert.NotNil(t, gs.weapons)
 	assert.NotNil(t, gs.entities)
+	assert.NotNil(t, gs.rules.conVars)
 	assert.Equal(t, common.TeamTerrorists, gs.tState.Team())
 	assert.Equal(t, common.TeamCounterTerrorists, gs.ctState.Team())
 }
 
 func TestNewGameState_TeamState_Pointers(t *testing.T) {
-	gs := newGameState()
+	gs := newGameState(demoInfoProvider{})
 
 	assert.True(t, gs.TeamCounterTerrorists() == &gs.ctState)
 	assert.True(t, gs.TeamTerrorists() == &gs.tState)
 }
 
 func TestNewGameState_TeamState_Opponent(t *testing.T) {
-	gs := newGameState()
+	gs := newGameState(demoInfoProvider{})
 
 	assert.True(t, &gs.ctState == gs.tState.Opponent)
 	assert.True(t, &gs.tState == gs.ctState.Opponent)
 }
 
 func TestGameState_Participants(t *testing.T) {
-	gs := newGameState()
+	gs := newGameState(demoInfoProvider{})
 	ptcp := gs.Participants()
 	byEntity := ptcp.ByEntityID()
 	byUserID := ptcp.ByUserID()
@@ -70,13 +72,6 @@ func TestGameState_Participants(t *testing.T) {
 	assert.NotEqual(t, allByUserID, ptcp.ByUserID())
 }
 
-func TestGameState_ConVars(t *testing.T) {
-	cvars := make(map[string]string)
-	gs := gameState{conVars: cvars}
-
-	assert.Equal(t, cvars, gs.ConVars())
-}
-
 func TestParticipants_All(t *testing.T) {
 	pl := newPlayer()
 	ptcps := participants{
@@ -282,6 +277,69 @@ func TestParticipants_SpottedBy(t *testing.T) {
 	assert.ElementsMatch(t, []*common.Player{spotted1, spotted2}, spotted)
 }
 
+func TestGameRules_ConVars(t *testing.T) {
+	cvars := make(map[string]string)
+	gr := gameRules{conVars: cvars}
+
+	assert.Equal(t, cvars, gr.ConVars())
+
+	gs := gameState{rules: gr}
+	assert.Equal(t, cvars, gs.ConVars())
+}
+
+func TestGameRules_BombTime(t *testing.T) {
+	gs := gameRules{conVars: map[string]string{"mp_c4timer": "5"}}
+
+	bt, err := gs.BombTime()
+
+	assert.Nil(t, err)
+	assert.Equal(t, 5*time.Second, bt)
+}
+
+func TestGameRules_FreezeTime(t *testing.T) {
+	gs := gameRules{conVars: map[string]string{"mp_freezetime": "5"}}
+
+	bt, err := gs.FreezeTime()
+
+	assert.Nil(t, err)
+	assert.Equal(t, 5*time.Second, bt)
+}
+
+func TestGameRules_RoundTime(t *testing.T) {
+	prop := new(stfake.Property)
+	prop.On("Value").Return(st.PropertyValue{IntVal: 115})
+	ent := new(stfake.Entity)
+	ent.On("Property", "cs_gamerules_data.m_iRoundTime").Return(prop)
+	gr := gameRules{entity: ent}
+
+	rt, err := gr.RoundTime()
+
+	assert.Nil(t, err)
+	assert.Equal(t, 115*time.Second, rt)
+}
+
+func TestGameRules(t *testing.T) {
+	gr := gameRules{
+		conVars: map[string]string{},
+	}
+
+	_, err := gr.RoundTime()
+	assert.Equal(t, ErrFailedToRetrieveGameRule, err)
+
+	_, err = gr.BombTime()
+	assert.Equal(t, ErrFailedToRetrieveGameRule, err)
+
+	_, err = gr.FreezeTime()
+	assert.Equal(t, ErrFailedToRetrieveGameRule, err)
+
+	ent := new(stfake.Entity)
+	ent.On("Property", "cs_gamerules_data.m_iRoundTime").Return(nil)
+	gr = gameRules{entity: ent}
+
+	_, err = gr.RoundTime()
+	assert.Equal(t, ErrFailedToRetrieveGameRule, err)
+}
+
 func newPlayer() *common.Player {
 	pl := newPlayerWithEntityID(1)
 	return pl