Skip to content
Merged
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
79 changes: 70 additions & 9 deletions pkg/steam/item.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,23 +50,84 @@ type ParsedItem struct {
}

// Parse scrapes the item description for the mod IDs and map folders the game
// uses. It tolerates the two casing variants Steam mod authors use in practice.
// uses. Some descriptions advertise *other* mods by reproducing their PZ footer
// (a "Workshop ID:" line paired with a "Mod ID:" / "Map Folder:" line), which
// would otherwise leak those foreign IDs into this item. To guard against that,
// each Mod ID / Map Folder is attributed to the nearest "Workshop ID:" line in
// either direction (authors write the pair in both orders, sometimes separated
// by a blank line; ties favour the item's own ID) and is dropped only when that
// nearest Workshop ID belongs to a *different* item. Attribution only kicks in
// once the item's own Workshop ID is seen; descriptions that never state it, or
// omit Workshop IDs entirely, keep every entry as before. It tolerates the
// casing variants ("Mod ID:"/"ModID:", "Workshop ID:"/"WorkshopID:") authors
// use in practice.
func (w *WorkshopItem) Parse() *ParsedItem {
lines := strings.Split(w.Description, "\n")

// First pass: locate every Workshop ID line and note whether our own appears.
type widLine struct {
idx int
own bool
}
var wids []widLine
ownSeen := false
for i, line := range lines {
if v, ok := fieldValue(strings.TrimSpace(line), "Workshop ID:", "WorkshopID:"); ok {
own := v != "" && v == w.PublishedFileID
wids = append(wids, widLine{idx: i, own: own})
ownSeen = ownSeen || own
}
}

// keep reports whether the footer entry on line idx should be attributed to
// this item. With no own Workshop ID to anchor against, keep everything.
keep := func(idx int) bool {
if !ownSeen {
return true
}
bestDist, nearestOwn := -1, true
for _, wl := range wids {
d := wl.idx - idx
if d < 0 {
d = -d
}
if bestDist == -1 || d < bestDist || (d == bestDist && wl.own) {
bestDist, nearestOwn = d, wl.own
}
}
return nearestOwn
}

var mods, maps []string
for _, line := range strings.Split(w.Description, "\n") {
for i, line := range lines {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "Mod ID: "):
mods = append(mods, strings.TrimSpace(strings.TrimPrefix(line, "Mod ID: ")))
case strings.HasPrefix(line, "ModID: "):
mods = append(mods, strings.TrimSpace(strings.TrimPrefix(line, "ModID: ")))
case strings.HasPrefix(line, "Map Folder: "):
maps = append(maps, strings.TrimSpace(strings.TrimPrefix(line, "Map Folder: ")))
if v, ok := fieldValue(line, "Mod ID:", "ModID:"); ok {
if keep(i) {
mods = append(mods, v)
}
} else if v, ok := fieldValue(line, "Map Folder:"); ok {
if keep(i) {
maps = append(maps, v)
}
}
}
return &ParsedItem{Mods: domain.Dedupe(mods), Maps: domain.Dedupe(maps)}
}

// fieldValue returns the trimmed value of the first "Key value" line among keys,
// matching each key with a trailing space per the PZ footer convention (so
// "Mod ID:Foo" without the space is not treated as a footer line), and whether
// any key matched.
func fieldValue(line string, keys ...string) (string, bool) {
for _, k := range keys {
p := k + " "
if strings.HasPrefix(line, p) {
return strings.TrimSpace(strings.TrimPrefix(line, p)), true
}
}
return "", false
}

// IsCollection reports whether the item is a Workshop collection.
func (w *WorkshopItem) IsCollection() bool { return w.FileType == FileTypeCollection }

Expand Down
118 changes: 118 additions & 0 deletions pkg/steam/item_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package steam

import (
"reflect"
"testing"
)

func TestParseScopesToOwnWorkshopID(t *testing.T) {
tests := []struct {
name string
id string
desc string
wantMods []string
wantMaps []string
}{
{
name: "advertised footer is ignored (real 3042138819 case)",
id: "3042138819",
desc: "The new multiplayer true music jukebox is now available!\n" +
"True Music Jukebox\n" +
"https://steamcommunity.com/sharedfiles/filedetails/?id=3118990099\n" +
"Workshop ID: 3118990099\n" +
"Mod ID: TrueMusicJukebox\n" +
"\n" +
"Workshop ID: 3042138819\n" +
"Mod ID: FunctionalAppliances2\n",
wantMods: []string{"FunctionalAppliances2"},
wantMaps: []string{},
},
{
name: "several mod ids under the item's own workshop id are all kept",
id: "555",
desc: "A bundle.\n" +
"Workshop ID: 555\n" +
"Mod ID: ModA\n" +
"Mod ID: ModB\n",
wantMods: []string{"ModA", "ModB"},
wantMaps: []string{},
},
{
name: "reversed footer (Mod ID before Workshop ID, blank between) is kept (real 3654929003 case)",
id: "3654929003",
desc: "Other batch action work:\n" +
"https://steamcommunity.com/sharedfiles/filedetails/?id=3584890848 batch recipe action\n" +
"https://steamcommunity.com/sharedfiles/filedetails/?id=3660401764 pick everything on floor\n" +
"\n" +
"Mod ID: vac_mod_b42_4\n" +
"\n" +
"Workshop ID: 3654929003\n",
wantMods: []string{"vac_mod_b42_4"},
wantMaps: []string{},
},
{
name: "other mods advertised only as URLs do not cause drops",
id: "100",
desc: "Check out my other mods:\n" +
"https://steamcommunity.com/sharedfiles/filedetails/?id=999\n" +
"Mod ID: MyMod\n" +
"Workshop ID: 100\n",
wantMods: []string{"MyMod"},
wantMaps: []string{},
},
{
name: "a separated foreign footer block is dropped, own block kept",
id: "100",
desc: "Workshop ID: 999\n" +
"Mod ID: ForeignMod\n" +
"Map Folder: ForeignMap\n" +
"\n" +
"\n" +
"Workshop ID: 100\n" +
"Mod ID: MyMod\n" +
"Map Folder: MyMap\n",
wantMods: []string{"MyMod"},
wantMaps: []string{"MyMap"},
},
{
name: "no workshop id line falls back to keeping everything",
id: "42",
desc: "A simple mod.\n" +
"Mod ID: Simple\n" +
"Map Folder: SimpleMap\n",
wantMods: []string{"Simple"},
wantMaps: []string{"SimpleMap"},
},
{
name: "casing variants are tolerated",
id: "88",
desc: "WorkshopID: 88\n" +
"ModID: CasedMod\n",
wantMods: []string{"CasedMod"},
wantMaps: []string{},
},
{
name: "only a foreign workshop id present keeps everything (own id absent)",
id: "200",
desc: "Check out my other mod:\n" +
"Workshop ID: 999\n" +
"Mod ID: OtherMod\n" +
"Mod ID: BareMod\n",
wantMods: []string{"OtherMod", "BareMod"},
wantMaps: []string{},
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
w := WorkshopItem{PublishedFileID: tc.id, Description: tc.desc}
got := w.Parse()
if !reflect.DeepEqual(got.Mods, tc.wantMods) {
t.Errorf("mods = %#v, want %#v", got.Mods, tc.wantMods)
}
if !reflect.DeepEqual(got.Maps, tc.wantMaps) {
t.Errorf("maps = %#v, want %#v", got.Maps, tc.wantMaps)
}
})
}
}
Loading