mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 02:41: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:
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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