mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 10:51:09 +00:00
adventure: tell Pete who else is on the expedition
Party seats ride the roster detail, beside the supply and threat numbers the same expedition already publishes. Leader first, the hireling named without a token he has no board row for, and an opted-out player's seat kept and anonymised — dropping it would make a party of three read as a pair while the burn rate and enemy scaling beside it still felt three bodies. The board itself was wrong about this: it resolved an expedition with a lookup keyed on dnd_expedition.user_id, which is blind to members, so for the whole life of N3 parties a seated player has shown as idle in town while standing in a dungeon. Pets also send the engine's XP threshold for their current level band, so the web can draw progress without keeping a copy of a curve that would drift.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user