Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 36 additions & 7 deletions internal/drops/progress.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,12 +100,13 @@ func (s *Service) PollProgressOnce() {
return
}

s.ApplyProgressUpdate(twitch.DropProgressData{
CampaignID: pickedCampID,
DropID: session.DropID,
CurrentMinutesWatched: session.CurrentMinutesWatched,
RequiredMinutesWatched: session.RequiredMinutesWatched,
})
owned := s.campaignOwnsDrop(pickedCampID, session.DropID)
progress, done := progressFromSession(pickedCampID, session, owned)
s.ApplyProgressUpdate(progress)
if !owned && s.writeLogFile != nil {
s.writeLogFile(fmt.Sprintf("[Drops/Poll] session drop %s is not part of campaign %s — minutes applied, completion check skipped",
session.DropID, pickedCampID))
}

// When poll says the current drop is at 100%, do TWO things:
// 1. Try MarkCompletedIfFinishedExternally — fetches inventory + only
Expand All @@ -116,7 +117,7 @@ func (s *Service) PollProgressOnce() {
// un-completed.
// 2. Trigger processDrops so the selector re-evaluates (next drop
// in queue gets picked if this one is done, etc).
if session.RequiredMinutesWatched > 0 && session.CurrentMinutesWatched >= session.RequiredMinutesWatched {
if done {
if s.writeLogFile != nil {
s.writeLogFile(fmt.Sprintf("[Drops/Poll] drop complete on campaign %s (%d/%d)",
pickedCampID, session.CurrentMinutesWatched, session.RequiredMinutesWatched))
Expand Down Expand Up @@ -316,3 +317,31 @@ func (s *Service) LookupCampaignByDropID(dropID string) string {
}
return ""
}

func (s *Service) campaignOwnsDrop(campaignID, dropID string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
for _, d := range s.campaignCache[campaignID].Drops {
if d.ID == dropID {
return true
}
}
return false
}

// Twitch's session can name a drop from a sibling campaign of the same game.
// Its minutes still track the watched channel, but its drop ID and required
// minutes belong to the other campaign and must not drive completion.
func progressFromSession(pickedCampID string, session *twitch.CurrentDropSession, ownedByPick bool) (twitch.DropProgressData, bool) {
p := twitch.DropProgressData{
CampaignID: pickedCampID,
CurrentMinutesWatched: session.CurrentMinutesWatched,
}
if !ownedByPick {
return p, false
}
p.DropID = session.DropID
p.RequiredMinutesWatched = session.RequiredMinutesWatched
done := session.RequiredMinutesWatched > 0 && session.CurrentMinutesWatched >= session.RequiredMinutesWatched
return p, done
}
87 changes: 87 additions & 0 deletions internal/drops/progress_session_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package drops

import (
"testing"

"github.com/miwi/twitchpoint/internal/config"
"github.com/miwi/twitchpoint/internal/twitch"
)

// The session named WARDOGS Beta & Launch's claimed 30-min drop while the
// pick was farming Early Access 0.1, whose drops need 660+ minutes.
func TestProgressFromSession_SiblingCampaignDropNeverCompletes(t *testing.T) {
session := &twitch.CurrentDropSession{DropID: "wardog", CurrentMinutesWatched: 648, RequiredMinutesWatched: 30}

p, done := progressFromSession("early-access", session, false)

if done {
t.Fatal("a sibling campaign's finished drop marked the picked campaign's drop done")
}
if p.CampaignID != "early-access" || p.CurrentMinutesWatched != 648 {
t.Fatalf("minutes must still be applied to the pick, got %+v", p)
}
if p.DropID != "" || p.RequiredMinutesWatched != 0 {
t.Fatalf("sibling drop ID/required leaked into the pick's progress: %+v", p)
}
}

func TestProgressFromSession_OwnedDrop(t *testing.T) {
cases := []struct {
current, required int
wantDone bool
}{
{647, 660, false},
{660, 660, true},
{700, 660, true},
{10, 0, false},
}
for _, c := range cases {
session := &twitch.CurrentDropSession{DropID: "silver-2", CurrentMinutesWatched: c.current, RequiredMinutesWatched: c.required}
p, done := progressFromSession("early-access", session, true)
if done != c.wantDone {
t.Fatalf("%d/%d: done=%v, want %v", c.current, c.required, done, c.wantDone)
}
if p.DropID != "silver-2" || p.RequiredMinutesWatched != c.required {
t.Fatalf("%d/%d: owned drop not passed through: %+v", c.current, c.required, p)
}
}
}

func TestCampaignOwnsDrop(t *testing.T) {
s := &Service{campaignCache: map[string]twitch.DropCampaign{
"early-access": {ID: "early-access", Drops: []twitch.TimeBasedDrop{{ID: "silver-2"}, {ID: "gold-4"}}},
"beta-launch": {ID: "beta-launch", Drops: []twitch.TimeBasedDrop{{ID: "wardog"}}},
}}

if !s.campaignOwnsDrop("early-access", "gold-4") {
t.Fatal("own drop not recognised")
}
if s.campaignOwnsDrop("early-access", "wardog") {
t.Fatal("sibling campaign's drop attributed to the pick")
}
if s.campaignOwnsDrop("uncached", "silver-2") {
t.Fatal("uncached campaign must not claim ownership")
}
}

func TestApplyProgressUpdate_SiblingSessionKeepsRowRequired(t *testing.T) {
s := &Service{
cfg: &config.Config{},
log: func(string, ...interface{}) {},
writeLogFile: func(string) {},
campaignCache: map[string]twitch.DropCampaign{
"early-access": {ID: "early-access", Drops: []twitch.TimeBasedDrop{{ID: "silver-2", RequiredMinutesWatched: 660}}},
},
activeDrops: []ActiveDrop{{CampaignID: "early-access", DropName: "Silver Drop 2", Progress: 640, Required: 660}},
}
session := &twitch.CurrentDropSession{DropID: "wardog", CurrentMinutesWatched: 648, RequiredMinutesWatched: 30}

p, _ := progressFromSession("early-access", session, s.campaignOwnsDrop("early-access", "wardog"))
s.ApplyProgressUpdate(p)

row := s.activeDrops[0]
assertConsistent(t, row)
if row.Required != 660 || row.Progress != 648 || row.DropName != "Silver Drop 2" {
t.Fatalf("row corrupted by sibling session: %+v", row)
}
}
34 changes: 14 additions & 20 deletions internal/twitch/drops.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,12 @@ func (g *GQLClient) GetDropsInventory() ([]DropCampaign, error) {
}

// Fetch inventory for progress data + gameEventDrops
inventoryCampaigns, claimedBenefits, _ := g.getDropsFromInventory()
inventoryCampaigns, claimedBenefits, err := g.getDropsFromInventory()
if err != nil {
// Without the inventory every campaign reads as not-in-progress,
// which the completion checks take as finished.
return nil, err
}

// One-shot diag: dump every inventory campaign so we can spot
// "should-be-active campaign missing or mislabeled" cases.
Expand Down Expand Up @@ -549,37 +554,26 @@ func (g *GQLClient) getDropsFromInventory() ([]DropCampaign, map[string]time.Tim
if err != nil {
return nil, nil, fmt.Errorf("get drops inventory: %w", err)
}
return parseInventoryResponse(resp.Data)
}

currentUser, ok := resp.Data["currentUser"]
if !ok || currentUser == nil {
return nil, nil, nil
}
userMap, ok := currentUser.(map[string]interface{})
func parseInventoryResponse(data map[string]interface{}) ([]DropCampaign, map[string]time.Time, error) {
userMap, ok := data["currentUser"].(map[string]interface{})
if !ok {
return nil, nil, nil
return nil, nil, fmt.Errorf("get drops inventory: response has no currentUser")
}

inventory, ok := userMap["inventory"]
if !ok || inventory == nil {
return nil, nil, nil
}
invMap, ok := inventory.(map[string]interface{})
invMap, ok := userMap["inventory"].(map[string]interface{})
if !ok {
return nil, nil, nil
return nil, nil, fmt.Errorf("get drops inventory: response has no inventory")
}

// Parse gameEventDrops — permanent history of all claimed benefit IDs
claimedBenefits := parseGameEventDrops(invMap)

campaignsRaw, ok := invMap["dropCampaignsInProgress"]
if !ok || campaignsRaw == nil {
return nil, claimedBenefits, nil
}
campaignList, ok := campaignsRaw.([]interface{})
campaignList, ok := invMap["dropCampaignsInProgress"].([]interface{})
if !ok {
return nil, claimedBenefits, nil
}

return parseCampaignList(campaignList), claimedBenefits, nil
}

Expand Down
32 changes: 32 additions & 0 deletions internal/twitch/inventory_parse_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package twitch

import "testing"

func TestParseInventoryResponse_MissingDataIsAnError(t *testing.T) {
cases := map[string]map[string]interface{}{
"no currentUser": {},
"null currentUser": {"currentUser": nil},
"no inventory": {"currentUser": map[string]interface{}{}},
"null inventory": {"currentUser": map[string]interface{}{"inventory": nil}},
}
for name, data := range cases {
if _, _, err := parseInventoryResponse(data); err == nil {
t.Fatalf("%s: parsed as an empty inventory instead of failing", name)
}
}
}

func TestParseInventoryResponse_NothingInProgress(t *testing.T) {
data := map[string]interface{}{"currentUser": map[string]interface{}{
"inventory": map[string]interface{}{"dropCampaignsInProgress": nil, "gameEventDrops": []interface{}{}},
}}

campaigns, _, err := parseInventoryResponse(data)

if err != nil {
t.Fatalf("a valid inventory with nothing in progress failed: %v", err)
}
if len(campaigns) != 0 {
t.Fatalf("expected no campaigns, got %d", len(campaigns))
}
}