diff --git a/pkg/game/game.go b/pkg/game/game.go index c3a3bfa..5554e87 100644 --- a/pkg/game/game.go +++ b/pkg/game/game.go @@ -612,7 +612,7 @@ func (g *Game) hiddenCurrentState() *protocol.State { hiddenState.Issues = make([]*protocol.Issue, 0, len(g.state.Issues)) for _, item := range g.state.Issues { copiedItem := *item - copiedItem.Votes = make(map[protocol.PlayerID]protocol.VoteResult, len(item.Votes)) + copiedItem.Votes = make(protocol.IssueVotes, len(item.Votes)) for playerID, vote := range item.Votes { if item.ID == g.state.ActiveIssue { copiedItem.Votes[playerID] = vote.Hidden() @@ -702,7 +702,7 @@ func (g *Game) addIssue(titleOrURL string) (protocol.IssueID, error) { issue := protocol.Issue{ ID: issueID, TitleOrURL: titleOrURL, - Votes: make(map[protocol.PlayerID]protocol.VoteResult), + Votes: make(protocol.IssueVotes), Result: nil, } @@ -720,11 +720,11 @@ func (g *Game) SelectIssue(index int) error { } if index < 0 || index >= len(g.state.Issues) { - return errors.New("invalid issue index") + return errors.New("invalid issue deckIndex") } g.state.Issues[index].Result = nil - g.state.Issues[index].Votes = make(map[protocol.PlayerID]protocol.VoteResult) + g.state.Issues[index].Votes = make(protocol.IssueVotes) g.state.ActiveIssue = g.state.Issues[index].ID g.notifyChangedState(true) diff --git a/pkg/game/hints.go b/pkg/game/hints.go new file mode 100644 index 0000000..93fda73 --- /dev/null +++ b/pkg/game/hints.go @@ -0,0 +1,122 @@ +package game + +import ( + "math" + "sort" + + "github.com/pkg/errors" + "golang.org/x/exp/slices" + + "github.com/six78/2-story-points-cli/pkg/protocol" +) + +type hintMeasurements struct { + // median is the index median value of given votes + median int + + // meanDeviation is the mean absolute deviation around the median + // Measured in cards count. + meanDeviation float64 + + // maxDeviation is the maximum absolute deviation from the median + // Measured in cards count. + maxDeviation float64 +} + +const ( + maxAcceptableMaximumDeviation = 1 + maxAcceptableMeanDeviation = 0.5 + // Rejection reasons + varietyOfVotesIsTooHigh = "Variety of votes is too high" + maximumDeviationIsTooHigh = "Maximum deviation is too high" +) + +var ( + ErrVoteNotFoundInDeck = errors.New("vote not found in deck") +) + +func GetResultHint(deck protocol.Deck, issueVotes protocol.IssueVotes) (*protocol.Hint, error) { + // Get votes as deck indexes. + // We ignore the actual deck values when calculating the hint. + indexes, err := getVotesAsDeckIndexes(issueVotes, deck) + if err != nil { + return nil, err + } + + // Calculate measures for the votes + resultMeasures := getMeasures(indexes) + medianValueIndex := resultMeasures.median + medianValue := deck[medianValueIndex] + + // Build the hint based on the measures + hint := &protocol.Hint{ + Hint: medianValue, + Advice: "", + Acceptable: true, + } + + if resultMeasures.maxDeviation > maxAcceptableMaximumDeviation { + hint.Acceptable = false + hint.RejectReason = maximumDeviationIsTooHigh + } + + if resultMeasures.meanDeviation >= maxAcceptableMeanDeviation { + hint.Acceptable = false + hint.RejectReason = varietyOfVotesIsTooHigh + } + + return hint, nil +} + +func getVotesAsDeckIndexes(issueVotes protocol.IssueVotes, deck protocol.Deck) ([]int, error) { + indexes := make([]int, 0, len(issueVotes)) + for _, vote := range issueVotes { + index := slices.Index(deck, vote.Value) + if index < 0 { + return nil, ErrVoteNotFoundInDeck + } + indexes = append(indexes, index) + } + return indexes, nil +} + +// getMeasures returns: +// - median value +// - median absolute deviation +// - maximum absolute deviation +// - error if any occurred +func getMeasures(values []int) hintMeasurements { + r := hintMeasurements{} + + // median value + r.median = median(values) + + // Maximum deviation + r.maxDeviation = 0 + for _, v := range values { + deviation := math.Abs(float64(r.median) - float64(v)) + r.maxDeviation = math.Max(r.maxDeviation, deviation) + } + + // Average deviation + sum := 0 + for _, v := range values { + sum += int(math.Abs(float64(r.median) - float64(v))) + } + r.meanDeviation = float64(sum) / float64(len(values)) + + return r + +} + +func median(values []int) int { + if len(values) == 0 { + return -1 + } + + sort.Slice(values, func(i, j int) bool { + return values[i] < values[j] + }) + center := len(values) / 2 + return values[center] +} diff --git a/pkg/game/hints_test.go b/pkg/game/hints_test.go new file mode 100644 index 0000000..4dec9c2 --- /dev/null +++ b/pkg/game/hints_test.go @@ -0,0 +1,155 @@ +package game + +import ( + "strings" + "testing" + + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/require" + + "github.com/six78/2-story-points-cli/pkg/protocol" +) + +func TestMedian(t *testing.T) { + // Odd number of votes + votes := []int{1, 1, 1, 1, 2} + hint := median(votes) + require.Equal(t, 1, hint) + + // Even number of votes + votes = []int{1, 1, 1, 2} + hint = median(votes) + require.Equal(t, 1, hint) + + // Test round up + votes = []int{1, 1, 2, 2} + hint = median(votes) + require.Equal(t, 2, hint) + + // Empty list + votes = []int{} + hint = median(votes) + require.Equal(t, -1, hint) +} + +func TestHint(t *testing.T) { + deck := protocol.Deck{"1", "2", "3", "5", "8", "13", "21"} + t.Log("deck:", deck) + + type Case struct { + values []protocol.VoteValue + measurements hintMeasurements + expectedHint protocol.Hint + } + + // NOTE: Some test cases here are double check. + // But the intention was also to see how the algorithm behaves for different scenarios. + + testCases := []Case{ + { + values: []protocol.VoteValue{"3", "3", "3", "3", "5"}, + measurements: hintMeasurements{median: 2, meanDeviation: 0.2, maxDeviation: 1}, + expectedHint: protocol.Hint{Acceptable: true, Hint: "3"}, + }, + { + values: []protocol.VoteValue{"3", "3", "3", "3", "8"}, + measurements: hintMeasurements{median: 2, meanDeviation: 0.4, maxDeviation: 2}, + expectedHint: protocol.Hint{Acceptable: false, Hint: "3", RejectReason: maximumDeviationIsTooHigh}, + }, + { + values: []protocol.VoteValue{"3", "3", "3", "3", "13"}, + measurements: hintMeasurements{median: 2, meanDeviation: 0.6, maxDeviation: 3}, + // Test: varietyOfVotesIsTooHigh takes precedence over maximumDeviationIsTooHigh + expectedHint: protocol.Hint{Acceptable: false, Hint: "3", RejectReason: varietyOfVotesIsTooHigh}, + }, + { + values: []protocol.VoteValue{"3", "3", "3", "3", "21"}, + measurements: hintMeasurements{median: 2, meanDeviation: 0.8, maxDeviation: 4}, + expectedHint: protocol.Hint{Acceptable: false, Hint: "3", RejectReason: varietyOfVotesIsTooHigh}, + }, + { + values: []protocol.VoteValue{"3", "3", "3", "5", "5"}, + measurements: hintMeasurements{median: 2, meanDeviation: 0.4, maxDeviation: 1}, + expectedHint: protocol.Hint{Acceptable: true, Hint: "3"}, + }, + { + values: []protocol.VoteValue{"3", "3", "3", "5", "8"}, + measurements: hintMeasurements{median: 2, meanDeviation: 0.6, maxDeviation: 2}, + expectedHint: protocol.Hint{Acceptable: false, Hint: "3", RejectReason: varietyOfVotesIsTooHigh}, + }, + { + values: []protocol.VoteValue{"2", "3", "3", "3", "3", "3", "5"}, + measurements: hintMeasurements{median: 2, meanDeviation: 2 / 7.0, maxDeviation: 1}, + expectedHint: protocol.Hint{Acceptable: true, Hint: "3"}, + }, + { + values: []protocol.VoteValue{"2", "3", "3", "3", "3", "5"}, + measurements: hintMeasurements{median: 2, meanDeviation: 2 / 6.0, maxDeviation: 1}, + expectedHint: protocol.Hint{Acceptable: true, Hint: "3"}, + }, + { + values: []protocol.VoteValue{"2", "3", "3", "3", "5"}, + measurements: hintMeasurements{median: 2, meanDeviation: 2 / 5.0, maxDeviation: 1}, + expectedHint: protocol.Hint{Acceptable: true, Hint: "3"}, + }, + { + values: []protocol.VoteValue{"2", "3", "3", "5"}, + measurements: hintMeasurements{median: 2, meanDeviation: 2 / 4.0, maxDeviation: 1}, + expectedHint: protocol.Hint{Acceptable: false, Hint: "3", RejectReason: varietyOfVotesIsTooHigh}, + }, + { + values: []protocol.VoteValue{"2", "3", "5"}, + measurements: hintMeasurements{median: 2, meanDeviation: 2 / 3.0, maxDeviation: 1}, + expectedHint: protocol.Hint{Acceptable: false, Hint: "3", RejectReason: varietyOfVotesIsTooHigh}, + }, + { + // This also tests round up median when even number of votes + values: []protocol.VoteValue{"2", "3", "5", "8"}, + measurements: hintMeasurements{median: 3, meanDeviation: 1, maxDeviation: 2}, + expectedHint: protocol.Hint{Acceptable: false, Hint: "5", RejectReason: varietyOfVotesIsTooHigh}, + }, + } + + for _, tc := range testCases { + name := voteValuesString(tc.values) + t.Run(name, func(t *testing.T) { + issueVotes := buildIssueVotes(tc.values) + indexes, err := getVotesAsDeckIndexes(issueVotes, deck) + require.NoError(t, err) + + // First, check the measures (private API) + measures := getMeasures(indexes) + require.Equal(t, tc.measurements, measures) + + // Now check the actual hint (public API) + hint, err := GetResultHint(deck, issueVotes) + require.NoError(t, err) + require.Equal(t, tc.expectedHint, *hint) + }) + } +} + +func TestInvalidVote(t *testing.T) { + deck := protocol.Deck{"1", "2"} + issueVotes := buildIssueVotes([]protocol.VoteValue{"1", "X"}) + _, err := GetResultHint(deck, issueVotes) + require.Error(t, err) + require.Equal(t, ErrVoteNotFoundInDeck, err) +} + +func voteValuesString(values []protocol.VoteValue) string { + list := make([]string, len(values)) + for i, v := range values { + list[i] = string(v) + } + return strings.Join(list, ",") +} + +func buildIssueVotes(votes []protocol.VoteValue) protocol.IssueVotes { + issueVotes := make(protocol.IssueVotes) + for _, v := range votes { + playerID := protocol.PlayerID(gofakeit.UUID()) + issueVotes[playerID] = protocol.VoteResult{Value: v} + } + return issueVotes +} diff --git a/pkg/protocol/hint.go b/pkg/protocol/hint.go new file mode 100644 index 0000000..f2ca80f --- /dev/null +++ b/pkg/protocol/hint.go @@ -0,0 +1,20 @@ +package protocol + +type Hint struct { + // Acceptable shows if the voting for given issue can be considered as "acceptable". + // It will be false if the variety of votes is too high. In this case Advice will contain + // a suggestion to discuss and re-vote. + Acceptable bool + + // RejectReason contains an explanation of why the vote is not acceptable. + // When Acceptable is true, RejectReason is empty. + RejectReason string + + // Hint is the recommended value for the issue. + // It's guaranteed to be one of the values from the deck. + Hint VoteValue + + // Advice is a text advice for the team about current vote. + // It might contain players mentions in form "@", where a particular player ID. + Advice string +} diff --git a/pkg/protocol/messages.go b/pkg/protocol/messages.go index 7d34fcb..cf42451 100644 --- a/pkg/protocol/messages.go +++ b/pkg/protocol/messages.go @@ -10,10 +10,11 @@ type PlayerID string type IssueID string type Issue struct { - ID IssueID `json:"id"` - TitleOrURL string `json:"titleOrUrl"` - Votes map[PlayerID]VoteResult `json:"votes"` - Result *VoteValue `json:"result"` // NOTE: keep pointer. Because "empty string means vote is not revealed" + ID IssueID `json:"id"` + TitleOrURL string `json:"titleOrUrl"` + Votes IssueVotes `json:"votes"` + Result *VoteValue `json:"result"` // NOTE: keep pointer. Because "empty string means vote is not revealed" + Hint *Hint `json:"-"` } type VoteState string @@ -89,3 +90,5 @@ type PlayerVoteMessage struct { } type Deck []VoteValue + +type IssueVotes map[PlayerID]VoteResult