diff --git a/internal/peteclient/client.go b/internal/peteclient/client.go index 2536a61..73b15c2 100644 --- a/internal/peteclient/client.go +++ b/internal/peteclient/client.go @@ -284,6 +284,30 @@ type RosterDetail struct { ThreatLevel int `json:"threat_level,omitempty"` Room string `json:"room,omitempty"` Map *RosterMap `json:"map,omitempty"` + // Party is who else is on this expedition, leader first. Absent for a solo + // run — a party of one is not a party, and the page should say nothing rather + // than draw a roster with a single chair in it. + Party []PartySeatView `json:"party,omitempty"` +} + +// PartySeatView is one body on a shared expedition, as the public page may see +// it. Kind is what the seat *is*, which the game keeps carefully separate: +// "leader" owns the expedition row everyone else references, "member" is another +// player, "companion" is the hired NPC (Pete) who fights but has no mailbox and +// no loot. +// +// Name/Token are the same pair the board and the Siege muster use. The opt-out +// rule here is the Siege *contributor* rule, not the realm-occupant rule: an +// opted-out player's seat is anonymised (kept, with no name and no token) rather +// than deleted. A party of three that renders as two is a false statement about +// the run — the supply burn, the enemy scaling and the loot split all felt three +// bodies — whereas an unnamed seat says only that somebody else was there, which +// the zone line on this same page already implies. +type PartySeatView struct { + Kind string `json:"kind"` // leader|member|companion + Name string `json:"name,omitempty"` + Token string `json:"token,omitempty"` // empty: opted out, or a companion (no board row) + Level int `json:"level,omitempty"` } // RosterMap is the fog-of-war cut of an adventurer's zone graph: every node @@ -788,11 +812,19 @@ type HouseView struct { } // PetView is one pet slot. +// +// XP and XPNeeded are both in **centi-XP** — the engine's own unit, a hundredth +// of a point, because a pet earns 1.5 XP per action and the ledger is an int. +// Pete divides by 100 to show it and does no other arithmetic on either number: +// the curve behind XPNeeded is petXPToNextLevel's, per level band, and it is not +// Pete's business to know it. XPNeeded is 0 at the level cap, which is the only +// signal that there is nothing left to fill. type PetView struct { Type string `json:"type"` Name string `json:"name"` Level int `json:"level"` XP int `json:"xp,omitempty"` + XPNeeded int `json:"xp_needed,omitempty"` // 0 = at the level cap ArmorTier int `json:"armor_tier,omitempty"` } diff --git a/internal/plugin/adventure_pets.go b/internal/plugin/adventure_pets.go index f661e2f..03ecc46 100644 --- a/internal/plugin/adventure_pets.go +++ b/internal/plugin/adventure_pets.go @@ -15,6 +15,11 @@ import ( const petXPPerAction = 1.5 +// petMaxLevel is where the curve stops. Named because two things read it for +// different reasons: the level-up loop stops here, and the web push reports "no +// more to earn" from it. +const petMaxLevel = 10 + var petNameValid = regexp.MustCompile(`^[a-zA-Z0-9 '\-]+$`) // petXPToNextLevel returns XP needed for a given pet level. @@ -31,6 +36,19 @@ func petXPToNextLevel(level int) int { } } +// petXPNeededCenti is petXPToNextLevel in the unit the stored ledger actually +// uses — centi-XP — and 0 once the pet is capped, so a caller can tell "nothing +// left to earn" from "needs another 10 points". Every comparison against a +// stored PetXP multiplies by 100 (see advancePetLevelsFromXP); anything reading +// the curve for display has to do the same, and doing it in one place is how the +// web push avoids getting it wrong. +func petXPNeededCenti(level int) int { + if level >= petMaxLevel { + return 0 + } + return petXPToNextLevel(level) * 100 +} + // petGrantXP adds a per-action XP grant to the pet and handles level-ups. // Returns true if leveled up. Shares the level-up loop with the babysit trickle // via advancePetLevelsFromXP. @@ -90,13 +108,13 @@ func grantPetCombatXP(userID id.UserID) []string { // to the level-10 cap, stamping the level-10 date on first reaching it. Shared // by both pet slots (the babysit trickle). Returns true if the pet leveled. func advancePetLevelsFromXP(xp, level *int, level10Date *string, addCentiXP int) bool { - if *level >= 10 { + if *level >= petMaxLevel { return false } *xp += addCentiXP leveled := false - for *level < 10 { - needed := petXPToNextLevel(*level) * 100 + for *level < petMaxLevel { + needed := petXPNeededCenti(*level) if *xp < needed { break } @@ -104,7 +122,7 @@ func advancePetLevelsFromXP(xp, level *int, level10Date *string, addCentiXP int) *level++ leveled = true } - if *level >= 10 && *level10Date == "" { + if *level >= petMaxLevel && *level10Date == "" { *level10Date = time.Now().UTC().Format("2006-01-02") } return leveled diff --git a/internal/plugin/pete_detail_test.go b/internal/plugin/pete_detail_test.go index 77ab756..e8a44e8 100644 --- a/internal/plugin/pete_detail_test.go +++ b/internal/plugin/pete_detail_test.go @@ -226,3 +226,55 @@ func TestDetailSnapshotSkipsDeadPlayers(t *testing.T) { t.Fatalf("detail set = %+v, want just the living player", snap.Players) } } + +// TestPetXPRidesTheCurveNotACopyOfIt pins the unit and the cap, which are the two +// ways this can go quietly wrong on the web. XP is stored in centi-XP — a pet +// earns 1.5 points an action and the ledger is an int — so a page that read XP +// against a whole-number threshold would draw a bar 100x too full. And a capped +// pet must report 0 needed rather than the next band's number, or its bar sits +// forever short of a level it can never gain. +func TestPetXPRidesTheCurveNotACopyOfIt(t *testing.T) { + newMischiefTestDB(t) + uid := id.UserID("@quack:test") + seedDetailPlayer(t, uid, "Quack", 7) + + adv, err := loadAdvCharacter(uid) + if err != nil { + t.Fatalf("loadAdvCharacter: %v", err) + } + adv.PetType = "cat" + adv.PetName = "Mittens" + adv.PetLevel = 4 + adv.PetXP = 750 // 7.5 of the 20 points level 4 wants + adv.Pet2Type = "dog" + adv.Pet2Name = "Rex" + adv.Pet2Level = petMaxLevel + adv.Pet2XP = 0 + if err := saveAdvCharacter(adv); err != nil { + t.Fatalf("saveAdvCharacter: %v", err) + } + + snap, err := (&AdventurePlugin{}).buildDetailSnapshot(time.Now().UTC()) + if err != nil { + t.Fatalf("buildDetailSnapshot: %v", err) + } + pets := snap.Players[0].Pets + if len(pets) != 2 { + t.Fatalf("pets = %+v, want both slots", pets) + } + byName := map[string]int{} + for i, p := range pets { + byName[p.Name] = i + } + mittens := pets[byName["Mittens"]] + if mittens.XP != 750 { + t.Errorf("Mittens XP = %d, want the stored centi-XP 750", mittens.XP) + } + if want := petXPToNextLevel(4) * 100; mittens.XPNeeded != want { + t.Errorf("Mittens XPNeeded = %d, want %d — the curve is in centi-XP too", + mittens.XPNeeded, want) + } + if rex := pets[byName["Rex"]]; rex.XPNeeded != 0 { + t.Errorf("a capped pet needs %d more XP; want 0, meaning nothing left to earn", rex.XPNeeded) + } +} diff --git a/internal/plugin/pete_roster.go b/internal/plugin/pete_roster.go index 3cfb29d..39a3cc7 100644 --- a/internal/plugin/pete_roster.go +++ b/internal/plugin/pete_roster.go @@ -460,23 +460,84 @@ func equippedViews(uid id.UserID) []peteclient.ItemView { // petViews returns the player's live pet slots. A pet that was chased away is // omitted — it isn't with them right now, and the self-view shows the present. +// +// XPNeeded rides along so the web can draw the progress toward the next level. +// It is the engine's number, not Pete's: the curve steps by level band and a +// copy of it on the web side would be a second answer to "how close is my dog" +// that drifts the first time the band moves. func petViews(adv *AdventureCharacter) []peteclient.PetView { var out []peteclient.PetView if adv.PetType != "" && !adv.PetChasedAway { out = append(out, peteclient.PetView{ Type: adv.PetType, Name: adv.PetName, Level: adv.PetLevel, - XP: adv.PetXP, ArmorTier: adv.PetArmorTier, + XP: adv.PetXP, XPNeeded: petXPNeededCenti(adv.PetLevel), + ArmorTier: adv.PetArmorTier, }) } if adv.Pet2Type != "" && !adv.Pet2ChasedAway { out = append(out, peteclient.PetView{ Type: adv.Pet2Type, Name: adv.Pet2Name, Level: adv.Pet2Level, - XP: adv.Pet2XP, ArmorTier: adv.Pet2ArmorTier, + XP: adv.Pet2XP, XPNeeded: petXPNeededCenti(adv.Pet2Level), + ArmorTier: adv.Pet2ArmorTier, }) } return out } +// partySeatViews describes who is on this expedition for the public detail page. +// Returns nil for a solo run: expeditionParty always hands back at least the +// leader, and a "party" of one chair is a worse thing to draw than nothing. +// +// The opt-out rule is the Siege contributor's, not the realm occupant's: a seat +// belonging to an opted-out player is kept and anonymised. Deleting it would +// make a party of three read as a pair, and the numbers beside it — the supply +// burn, the threat, the enemy scaling — all felt three bodies. What an unnamed +// seat discloses is that somebody else is down there, which the zone and day on +// this same page already say about everyone in the party. +// +// Levels come from a per-seat character load. Parties cap at three and the +// companion needs no load at all, so this is at most two extra reads for a +// player who is actually in one. +func partySeatViews(exp *Expedition) []peteclient.PartySeatView { + seats, err := expeditionParty(exp.ID, exp.UserID) + if err != nil { + slog.Debug("pete: party seats unavailable", "expedition", exp.ID, "err", err) + return nil + } + if len(seats) < 2 { + return nil // solo, or a roster that only holds its leader + } + out := make([]peteclient.PartySeatView, 0, len(seats)) + for _, s := range seats { + if s.Kind == SeatCompanion { + // The hireling is named unconditionally: he is not a player, has no + // board row to link to and no privacy to protect. + out = append(out, peteclient.PartySeatView{ + Kind: "companion", Name: companionDisplayName, + }) + continue + } + kind := "member" + if s.Kind == SeatLeader { + kind = "leader" + } + v := peteclient.PartySeatView{Kind: kind} + if !isNewsOptedOut(s.UserID) { + v.Name = charName(s.UserID) + if v.Name != "" { + // Token only alongside a name: a link to a page that says who they are + // would undo the anonymising below all by itself. + v.Token = eventToken(s.UserID, "roster") + if c, cerr := LoadDnDCharacter(s.UserID); cerr == nil && c != nil { + v.Level = c.Level + } + } + } + out = append(out, v) + } + return out +} + // buildRosterSnapshot assembles the complete board. // // Complete is the contract: Pete *replaces* its board with this, so anyone we @@ -563,7 +624,13 @@ func buildRosterSnapshot(now time.Time, euro *EuroPlugin) (peteclient.RosterSnap e.Detail = rosterDetail(pl.uid, c) - if exp, _ := getActiveExpedition(pl.uid); exp != nil { + // activeExpeditionFor, not getActiveExpedition: the latter keys on + // dnd_expedition.user_id and is blind to members, so a player seated on + // somebody else's run has been reading as "idle in town" on the public board + // for the whole life of N3 parties — standing in a tier-4 dungeon. The + // expedition it resolves to is the leader's row, which is the right answer: + // a party shares one clock, one supply pool and one run. + if exp, _, _ := activeExpeditionFor(pl.uid); exp != nil { zone := zoneOrFallback(exp.ZoneID) e.Status = "expedition" e.Zone = zone.Display @@ -576,6 +643,7 @@ func buildRosterSnapshot(now time.Time, euro *EuroPlugin) (peteclient.RosterSnap if e.Detail != nil { e.Detail.Supplies = int(exp.Supplies.Current) e.Detail.ThreatLevel = exp.ThreatLevel + e.Detail.Party = partySeatViews(exp) if exp.RunID != "" { if run, rerr := getZoneRun(exp.RunID); rerr == nil && run != nil && run.TotalRooms > 0 { e.Detail.Room = fmt.Sprintf("%d / %d", run.CurrentRoom+1, run.TotalRooms) diff --git a/internal/plugin/pete_roster_party_test.go b/internal/plugin/pete_roster_party_test.go new file mode 100644 index 0000000..6aabb5b --- /dev/null +++ b/internal/plugin/pete_roster_party_test.go @@ -0,0 +1,197 @@ +package plugin + +import ( + "testing" + "time" + + "gogobee/internal/peteclient" + + "maunium.net/go/mautrix/id" +) + +// TestSeatedMemberIsNotIdleInTown is the gap W7 closes. The board resolved an +// expedition with getActiveExpedition, which keys on dnd_expedition.user_id — so +// a party member, who owns no row of their own, read as "idle in town" while +// standing in a dungeon. The regression is silent: the page renders fine, it just +// says the wrong thing about where somebody is. +func TestSeatedMemberIsNotIdleInTown(t *testing.T) { + newBoredomTestDB(t) + now := time.Now().UTC() + old := now.Add(-30 * time.Hour) + + leader := id.UserID("@leader:test") + member := id.UserID("@member:test") + seedRosterPlayer(t, leader, "Josie", &old, &old) + seedRosterPlayer(t, member, "Camcast", &old, &old) + + seedExpedition(t, "exp-shared", leader, "active") + seatLeaderFixture(t, "exp-shared") + if err := joinParty("exp-shared", member); err != nil { + t.Fatalf("joinParty: %v", err) + } + + snap, err := buildRosterSnapshot(now, nil) + if err != nil { + t.Fatalf("buildRosterSnapshot: %v", err) + } + byName := map[string]int{} + for i, a := range snap.Adventurers { + byName[a.Name] = i + } + for _, name := range []string{"Josie", "Camcast"} { + i, ok := byName[name] + if !ok { + t.Fatalf("%s is not on the board", name) + } + if got := snap.Adventurers[i].Status; got != "expedition" { + t.Errorf("%s status = %q, want expedition", name, got) + } + if snap.Adventurers[i].Zone == "" { + t.Errorf("%s is on an expedition with no zone named", name) + } + } +} + +// TestPartySeatsNameTheWholeRoster covers the shape of the seat list: leader +// first, every human named with a linkable token, and the hireling named without +// one (he has no board row to link to). +func TestPartySeatsNameTheWholeRoster(t *testing.T) { + newBoredomTestDB(t) + now := time.Now().UTC() + old := now.Add(-30 * time.Hour) + + leader := id.UserID("@leader:test") + member := id.UserID("@member:test") + seedRosterPlayer(t, leader, "Josie", &old, &old) + seedRosterPlayer(t, member, "Camcast", &old, &old) + + seedExpedition(t, "exp-shared", leader, "active") + seatLeaderFixture(t, "exp-shared") + if err := joinParty("exp-shared", member); err != nil { + t.Fatalf("joinParty: %v", err) + } + if err := joinParty("exp-shared", companionUserID()); err != nil { + t.Fatalf("hire companion: %v", err) + } + + seats := seatsForOwner(t, now, "Josie") + if len(seats) != 3 { + t.Fatalf("party has %d seats, want 3: %+v", len(seats), seats) + } + if seats[0].Kind != "leader" || seats[0].Name != "Josie" { + t.Errorf("first seat = %+v, want the leader Josie", seats[0]) + } + if seats[0].Token == "" || seats[0].Level == 0 { + t.Errorf("leader seat is unlinkable or levelless: %+v", seats[0]) + } + var companion, human int + for _, s := range seats { + switch s.Kind { + case "companion": + companion++ + if s.Name != companionDisplayName { + t.Errorf("companion seat named %q, want %q", s.Name, companionDisplayName) + } + if s.Token != "" { + t.Errorf("companion seat carries a board token %q; he has no board row", s.Token) + } + case "leader", "member": + human++ + if s.Name == "" || s.Token == "" { + t.Errorf("human seat %+v is missing its name/token pair", s) + } + default: + t.Errorf("unknown seat kind %q", s.Kind) + } + } + if companion != 1 || human != 2 { + t.Errorf("seats = %d human + %d companion, want 2 + 1", human, companion) + } +} + +// TestSoloRunPublishesNoParty: expeditionParty always hands back at least the +// leader, so a naive render would draw every solo player a party of one. +func TestSoloRunPublishesNoParty(t *testing.T) { + newBoredomTestDB(t) + now := time.Now().UTC() + old := now.Add(-30 * time.Hour) + + solo := id.UserID("@solo:test") + seedRosterPlayer(t, solo, "Josie", &old, &old) + seedExpedition(t, "exp-solo", solo, "active") + + if seats := seatsForOwner(t, now, "Josie"); seats != nil { + t.Errorf("solo run published a party of %d: %+v", len(seats), seats) + } + + // And with only the leader seated, which is what the roster table looks like + // between materialising and the first invite landing. + seatLeaderFixture(t, "exp-solo") + if seats := seatsForOwner(t, now, "Josie"); seats != nil { + t.Errorf("leader-only roster published a party of %d: %+v", len(seats), seats) + } +} + +// TestOptedOutSeatIsAnonymisedNotDropped is the privacy contract for this +// surface, and it is deliberately NOT the board's rule. The board omits an +// opted-out player outright; a party seat is anonymised, because a party of three +// that renders as a pair is a false statement about the run everyone can see the +// supply burn and threat level of. +func TestOptedOutSeatIsAnonymisedNotDropped(t *testing.T) { + newBoredomTestDB(t) + now := time.Now().UTC() + old := now.Add(-30 * time.Hour) + + leader := id.UserID("@leader:test") + hidden := id.UserID("@hidden:test") + seedRosterPlayer(t, leader, "Josie", &old, &old) + seedRosterPlayer(t, hidden, "Quack", &old, &old) + setNewsOptout(hidden, true) + + seedExpedition(t, "exp-shared", leader, "active") + seatLeaderFixture(t, "exp-shared") + if err := joinParty("exp-shared", hidden); err != nil { + t.Fatalf("joinParty: %v", err) + } + + seats := seatsForOwner(t, now, "Josie") + if len(seats) != 2 { + t.Fatalf("party has %d seats, want 2 — an opted-out seat was dropped, not anonymised: %+v", + len(seats), seats) + } + for _, s := range seats { + if s.Name == "Quack" { + t.Error("an opted-out player is named on a party roster") + } + } + var blank int + for _, s := range seats { + if s.Name == "" { + blank++ + if s.Token != "" || s.Level != 0 { + t.Errorf("anonymised seat still carries a token or level: %+v", s) + } + } + } + if blank != 1 { + t.Errorf("%d anonymous seats, want exactly 1", blank) + } +} + +// seatsForOwner pulls one named adventurer's published party out of a whole +// snapshot, which is the only way to reach it — Party rides RosterDetail, so this +// also proves the wiring in buildRosterSnapshot and not just partySeatViews. +func seatsForOwner(t *testing.T, now time.Time, name string) []peteclient.PartySeatView { + t.Helper() + snap, err := buildRosterSnapshot(now, nil) + if err != nil { + t.Fatalf("buildRosterSnapshot: %v", err) + } + for _, a := range snap.Adventurers { + if a.Name == name && a.Detail != nil { + return a.Detail.Party + } + } + t.Fatalf("%s is not on the board with a detail sheet", name) + return nil +}