diff --git a/internal/drops/progress.go b/internal/drops/progress.go index aa00784..b1647e6 100644 --- a/internal/drops/progress.go +++ b/internal/drops/progress.go @@ -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 @@ -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)) @@ -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 +} diff --git a/internal/drops/progress_session_test.go b/internal/drops/progress_session_test.go new file mode 100644 index 0000000..e530568 --- /dev/null +++ b/internal/drops/progress_session_test.go @@ -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) + } +} diff --git a/internal/twitch/drops.go b/internal/twitch/drops.go index ba01cb7..855b50a 100644 --- a/internal/twitch/drops.go +++ b/internal/twitch/drops.go @@ -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. @@ -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 } diff --git a/internal/twitch/inventory_parse_test.go b/internal/twitch/inventory_parse_test.go new file mode 100644 index 0000000..04e0326 --- /dev/null +++ b/internal/twitch/inventory_parse_test.go @@ -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)) + } +}