diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 0b8e8fb..56e1f45 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -180,6 +180,9 @@ func (p *AdventurePlugin) Commands() []CommandDef { {Name: "craft", Description: "Craft a discovered recipe at Thom Krooke (consumes ingredients)", Usage: "!craft [list|]", Category: "Games"}, {Name: "lore", Description: "Dig through Thom Krooke's lore stacks for a new recipe (sharp minds turn up more)", Usage: "!lore", Category: "Games"}, {Name: "give", Description: "Gift a consumable or non-magic item to another adventurer (5% handling fee to the community pot; 3/day)", Usage: "!give @user", Category: "Games"}, + {Name: "town", Description: "Town registry — civic pride, housing street, and the pet showcase", Usage: "!town", Category: "Games"}, + {Name: "graveyard", Description: "Recent deaths across the guild", Usage: "!graveyard", Category: "Games"}, + {Name: "rivals", Description: "Your rival duel record, or `!rivals board` for room-wide standings", Usage: "!rivals [board]", Category: "Games"}, } } @@ -421,6 +424,17 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error { return p.handleGiveCmd(ctx) } + // 0b. Town registries (E3) — read-only social boards (rooms and DMs) + if p.IsCommand(ctx.Body, "town") { + return p.handleTownCmd(ctx) + } + if p.IsCommand(ctx.Body, "graveyard") { + return p.handleGraveyardCmd(ctx) + } + if p.IsCommand(ctx.Body, "rivals") { + return p.handleRivalsTopCmd(ctx, p.GetArgs(ctx.Body, "rivals")) + } + // 1. Arena commands (work in rooms and DMs) if p.IsCommand(ctx.Body, "bail") { return p.handleArenaBail(ctx) diff --git a/internal/plugin/adventure_town.go b/internal/plugin/adventure_town.go new file mode 100644 index 0000000..a2c26fd --- /dev/null +++ b/internal/plugin/adventure_town.go @@ -0,0 +1,458 @@ +package plugin + +import ( + "database/sql" + "encoding/json" + "fmt" + "strings" + "time" + + "gogobee/internal/db" +) + +// E3 — Surfacing the buried social data. Three read-only registries that +// render data the game already stores: !town (civic pride + housing + pets), +// !graveyard (recent deaths), and !rivals board (room-wide duel standings). +// +// Leak-check (gogobee_engagement_plan.md §E3): the civic-pride board ranks +// tax_ledger.total_paid, which is dominated by gambling/shop/arena rake and +// carries ZERO Misty/Arina donation signal — those debits never touch +// tax_ledger (adventure_npcs.go routes them through euro.Debit with their own +// reason strings, counters live in player_meta.misty_*/arina_* columns). So no +// board here can let a player correlate donations with the hidden NPC buffs. + +const ( + // townCivicBoardLimit caps the civic-pride board. + townCivicBoardLimit = 10 + // townListCap caps the housing/pet showcase lists. + townListCap = 15 + // graveyardLimit caps the graveyard. + graveyardLimit = 12 + // rivalsBoardLimit caps the room-wide rivalry standings. + rivalsBoardLimit = 10 +) + +// ── Civic pride ────────────────────────────────────────────────────────────── + +type civicEntry struct { + Name string + TotalPaid int64 +} + +// loadCivicPrideBoard returns the top contributors to the community pot by +// lifetime tax paid. Safe to rank — see the leak-check note above. +func loadCivicPrideBoard(limit int) ([]civicEntry, error) { + rows, err := db.Get().Query( + `SELECT COALESCE(NULLIF(c.display_name, ''), t.user_id), t.total_paid + FROM tax_ledger t + LEFT JOIN player_meta c ON c.user_id = t.user_id + WHERE t.total_paid > 0 + ORDER BY t.total_paid DESC + LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []civicEntry + for rows.Next() { + var e civicEntry + if err := rows.Scan(&e.Name, &e.TotalPaid); err != nil { + return nil, err + } + out = append(out, e) + } + return out, rows.Err() +} + +// ── Housing street ─────────────────────────────────────────────────────────── + +type housingEntry struct { + Name string + Tier int +} + +// loadHousingStreet returns everyone who owns a house, best homes first. +func loadHousingStreet(limit int) ([]housingEntry, error) { + rows, err := db.Get().Query( + `SELECT COALESCE(NULLIF(display_name, ''), user_id), house_tier + FROM player_meta + WHERE house_tier > 0 + ORDER BY house_tier DESC, display_name + LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []housingEntry + for rows.Next() { + var e housingEntry + if err := rows.Scan(&e.Name, &e.Tier); err != nil { + return nil, err + } + out = append(out, e) + } + return out, rows.Err() +} + +// ── Pet showcase ───────────────────────────────────────────────────────────── + +type petShowcaseEntry struct { + Name string + Type string + Level int + ArmorName string +} + +// loadPetShowcase returns every active pet (arrived, not chased away). The +// arrived/chased flags live in pet_flags_json, so they're decoded in Go rather +// than in SQL. +func loadPetShowcase(limit int) ([]petShowcaseEntry, error) { + rows, err := db.Get().Query( + `SELECT COALESCE(NULLIF(display_name, ''), user_id), + pet_name, pet_type, pet_level, pet_armor_tier, pet_flags_json + FROM player_meta + WHERE pet_type != '' + ORDER BY pet_level DESC, pet_name`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []petShowcaseEntry + for rows.Next() { + var ( + owner, petName, petType, flagsRaw string + level, armorTier int + ) + if err := rows.Scan(&owner, &petName, &petType, &level, &armorTier, &flagsRaw); err != nil { + return nil, err + } + var flags petFlagsJSON + if flagsRaw != "" { + _ = json.Unmarshal([]byte(flagsRaw), &flags) + } + if !flags.Arrived || flags.ChasedAway { + continue + } + out = append(out, petShowcaseEntry{ + Name: firstNonEmpty(petName, "an unnamed companion") + " (" + owner + ")", + Type: petType, + Level: level, + ArmorName: petArmorName(petType, armorTier), + }) + if len(out) >= limit { + break + } + } + return out, rows.Err() +} + +// petArmorName maps a pet's armor tier to its barding name, or "" for none. +func petArmorName(petType string, tier int) string { + if tier <= 0 { + return "" + } + for _, a := range petArmorDefs(petType) { + if a.Tier == tier { + return a.Name + } + } + return "" +} + +// ── Graveyard ──────────────────────────────────────────────────────────────── + +type graveEntry struct { + Name string + Source string // "adventure" | "arena" | "" + Location string + LastDeathDate string // "2006-01-02" + StillDown bool // alive == 0 (inside the 6h respawn window) +} + +// loadGraveyard returns the most recent deaths across all players. Deaths are +// day-granularity (last_death_date), so ordering falls back to dead_until for +// the still-in-the-ground; a revived player keeps their last_death_date but +// clears dead_until. +func loadGraveyard(limit int) ([]graveEntry, error) { + rows, err := db.Get().Query( + `SELECT COALESCE(NULLIF(display_name, ''), user_id), + alive, death_source, death_location, last_death_date, dead_until + FROM player_meta + WHERE last_death_date != '' + ORDER BY last_death_date DESC, dead_until DESC + LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []graveEntry + for rows.Next() { + var ( + e graveEntry + aliveInt int + deadUntil sql.NullTime + ) + if err := rows.Scan(&e.Name, &aliveInt, &e.Source, &e.Location, &e.LastDeathDate, &deadUntil); err != nil { + return nil, err + } + e.StillDown = aliveInt == 0 + out = append(out, e) + } + return out, rows.Err() +} + +// ── Rivals board ───────────────────────────────────────────────────────────── + +type rivalBoardEntry struct { + Name string + Wins int + Losses int + LastDuelAt *time.Time +} + +// loadRivalsBoard aggregates adventure_rival_records into room-wide standings. +// The table is a directed per-pair ledger written from both duellists' sides, +// so summing a user_id's rows gives that player's total record. +func loadRivalsBoard(limit int) ([]rivalBoardEntry, error) { + rows, err := db.Get().Query( + `SELECT COALESCE(NULLIF(c.display_name, ''), r.user_id), + SUM(r.wins), SUM(r.losses), MAX(r.last_duel_at) + FROM adventure_rival_records r + LEFT JOIN player_meta c ON c.user_id = r.user_id + GROUP BY r.user_id + ORDER BY SUM(r.wins) DESC, SUM(r.losses) ASC + LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []rivalBoardEntry + for rows.Next() { + var ( + e rivalBoardEntry + lastDuel sql.NullString + ) + if err := rows.Scan(&e.Name, &e.Wins, &e.Losses, &lastDuel); err != nil { + return nil, err + } + // MAX() over a DATETIME column loses SQLite's type affinity and comes + // back as text, so parse it by hand rather than scanning a NullTime. + if lastDuel.Valid { + if t, ok := parseSQLiteTime(lastDuel.String); ok { + e.LastDuelAt = &t + } + } + out = append(out, e) + } + return out, rows.Err() +} + +// ── Rendering (client-free, unit-testable) ─────────────────────────────────── + +func renderTownRegistry(civic []civicEntry, houses []housingEntry, pets []petShowcaseEntry) string { + var sb strings.Builder + sb.WriteString("🏛️ **The Town Registry**\n\n") + + sb.WriteString("**Civic Pride** — the guild's most generous coffers-fillers\n") + if len(civic) == 0 { + sb.WriteString(" _The collection plate is empty. Tragic._\n") + } else { + for i, e := range civic { + sb.WriteString(fmt.Sprintf(" %2d. %-18s %s\n", i+1, truncName(e.Name, 18), fmtEuro(e.TotalPaid))) + } + } + sb.WriteString("\n") + + sb.WriteString("**Housing Street** — who's put down roots\n") + if len(houses) == 0 { + sb.WriteString(" _Not a single deed on file. A town of drifters._\n") + } else { + for _, e := range houses { + tierName := "a house" + if def := houseTierByTier(e.Tier); def != nil { + tierName = def.Name + } + sb.WriteString(fmt.Sprintf(" %-18s %s\n", truncName(e.Name, 18), tierName)) + } + } + sb.WriteString("\n") + + sb.WriteString("**Pet Showcase** — the good companions\n") + if len(pets) == 0 { + sb.WriteString(" _No pets in town. Somebody adopt something._\n") + } else { + for _, e := range pets { + line := fmt.Sprintf(" %-30s L%d %s", truncName(e.Name, 30), e.Level, e.Type) + if e.ArmorName != "" { + line += " · " + e.ArmorName + } + sb.WriteString(line + "\n") + } + } + + return strings.TrimRight(sb.String(), "\n") +} + +func renderGraveyard(deaths []graveEntry, now time.Time) string { + var sb strings.Builder + sb.WriteString("⚰️ **St. Guildmore's Memorial Garden**\n") + sb.WriteString("_The groundskeeper tips his hat. \"They gave their all. Well — they gave enough.\"_\n\n") + + if len(deaths) == 0 { + sb.WriteString("_Nobody's died lately. The garden's quiet. Enjoy it while it lasts._") + return sb.String() + } + + for _, e := range deaths { + place := e.Location + if place == "" { + place = "parts unknown" + } + verb := "fell" + if e.Source == "arena" { + verb = "fell in the arena" + } + when := humanizeDeathDate(e.LastDeathDate, now) + line := fmt.Sprintf(" ⚰️ **%s** — %s at %s (%s)", truncName(e.Name, 24), verb, place, when) + if e.StillDown { + line += " · _still resting_" + } else { + line += " · _back on their feet_" + } + sb.WriteString(line + "\n") + } + + return strings.TrimRight(sb.String(), "\n") +} + +func renderRivalsBoard(board []rivalBoardEntry, now time.Time) string { + var sb strings.Builder + sb.WriteString("⚔️ **Rivalry Standings** — duels across the guild\n\n") + + if len(board) == 0 { + sb.WriteString("_No duels on record. A suspiciously peaceful town._") + return sb.String() + } + + for i, e := range board { + last := "—" + if e.LastDuelAt != nil { + last = humanizeDaysAgo(*e.LastDuelAt, now) + } + sb.WriteString(fmt.Sprintf(" %2d. %-18s %dW - %dL last duel: %s\n", + i+1, truncName(e.Name, 18), e.Wins, e.Losses, last)) + } + + return strings.TrimRight(sb.String(), "\n") +} + +// ── Small render helpers ───────────────────────────────────────────────────── + +func truncName(s string, max int) string { + r := []rune(s) + if len(r) <= max { + return s + } + if max <= 1 { + return string(r[:max]) + } + return string(r[:max-1]) + "…" +} + +func firstNonEmpty(s, fallback string) string { + if strings.TrimSpace(s) == "" { + return fallback + } + return s +} + +// parseSQLiteTime parses a SQLite CURRENT_TIMESTAMP string ("2006-01-02 +// 15:04:05", UTC), tolerating an RFC3339 variant. Aggregates like MAX() strip +// the column's type affinity so the driver hands back text. +func parseSQLiteTime(s string) (time.Time, bool) { + for _, layout := range []string{"2006-01-02 15:04:05", time.RFC3339} { + if t, err := time.Parse(layout, s); err == nil { + return t.UTC(), true + } + } + return time.Time{}, false +} + +// humanizeDaysAgo renders a duel/event time as "today" / "1 day ago" / "N days +// ago", matching the inline style in handleRivalsCmd. +func humanizeDaysAgo(t, now time.Time) string { + days := int(now.Sub(t).Hours() / 24) + switch { + case days <= 0: + return "today" + case days == 1: + return "1 day ago" + default: + return fmt.Sprintf("%d days ago", days) + } +} + +// humanizeDeathDate renders a "2006-01-02" death date relative to now. Falls +// back to the raw string if it doesn't parse. +func humanizeDeathDate(dateStr string, now time.Time) string { + t, err := time.Parse("2006-01-02", dateStr) + if err != nil { + if dateStr == "" { + return "some time ago" + } + return dateStr + } + days := int(now.UTC().Truncate(24*time.Hour).Sub(t).Hours() / 24) + switch { + case days <= 0: + return "today" + case days == 1: + return "yesterday" + default: + return fmt.Sprintf("%d days ago", days) + } +} + +// ── Handlers ───────────────────────────────────────────────────────────────── + +func (p *AdventurePlugin) handleTownCmd(ctx MessageContext) error { + civic, err := loadCivicPrideBoard(townCivicBoardLimit) + if err != nil { + return err + } + houses, err := loadHousingStreet(townListCap) + if err != nil { + return err + } + pets, err := loadPetShowcase(townListCap) + if err != nil { + return err + } + return p.SendReply(ctx.RoomID, ctx.EventID, renderTownRegistry(civic, houses, pets)) +} + +func (p *AdventurePlugin) handleGraveyardCmd(ctx MessageContext) error { + deaths, err := loadGraveyard(graveyardLimit) + if err != nil { + return err + } + return p.SendReply(ctx.RoomID, ctx.EventID, renderGraveyard(deaths, time.Now().UTC())) +} + +// handleRivalsTopCmd routes the top-level !rivals command: "!rivals board" is +// the room-wide standings; anything else defers to the per-user record view. +func (p *AdventurePlugin) handleRivalsTopCmd(ctx MessageContext, args string) error { + if strings.EqualFold(strings.TrimSpace(args), "board") { + board, err := loadRivalsBoard(rivalsBoardLimit) + if err != nil { + return err + } + return p.SendReply(ctx.RoomID, ctx.EventID, renderRivalsBoard(board, time.Now().UTC())) + } + return p.handleRivalsCmd(ctx) +} diff --git a/internal/plugin/adventure_town_test.go b/internal/plugin/adventure_town_test.go new file mode 100644 index 0000000..a42e739 --- /dev/null +++ b/internal/plugin/adventure_town_test.go @@ -0,0 +1,234 @@ +package plugin + +import ( + "strings" + "testing" + "time" + + "gogobee/internal/db" + + "maunium.net/go/mautrix/id" +) + +func townTestDB(t *testing.T) { + t.Helper() + dir := t.TempDir() + db.Close() + if err := db.Init(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(db.Close) +} + +// TestTownLoaders_ExerciseRealSchema seeds each source table and runs every +// loader against a real DB, catching column typos / GROUP BY errors that the +// pure render tests can't. +func TestTownLoaders_ExerciseRealSchema(t *testing.T) { + townTestDB(t) + + alice := id.UserID("@alice:test.invalid") + bob := id.UserID("@bob:test.invalid") + + if err := upsertPlayerMetaDisplayName(alice, "Alice"); err != nil { + t.Fatal(err) + } + if err := upsertPlayerMetaDisplayName(bob, "Bob"); err != nil { + t.Fatal(err) + } + + // Civic pride. + trackTaxPaid(alice, 5000) + trackTaxPaid(bob, 100) + civic, err := loadCivicPrideBoard(townCivicBoardLimit) + if err != nil { + t.Fatalf("loadCivicPrideBoard: %v", err) + } + if len(civic) != 2 || civic[0].Name != "Alice" || civic[0].TotalPaid != 5000 { + t.Fatalf("civic board wrong: %+v", civic) + } + + // Housing street. + if err := upsertPlayerMetaHouseState(alice, HouseState{Tier: 4}); err != nil { + t.Fatal(err) + } + if err := upsertPlayerMetaHouseState(bob, HouseState{Tier: 1}); err != nil { + t.Fatal(err) + } + houses, err := loadHousingStreet(townListCap) + if err != nil { + t.Fatalf("loadHousingStreet: %v", err) + } + if len(houses) != 2 || houses[0].Tier != 4 { + t.Fatalf("housing street wrong: %+v", houses) + } + + // Pet showcase — only arrived, non-chased pets appear. + if err := upsertPlayerMetaPetState(alice, PetState{Type: "dog", Name: "Rex", Level: 5, ArmorTier: 2, Arrived: true}); err != nil { + t.Fatal(err) + } + if err := upsertPlayerMetaPetState(bob, PetState{Type: "cat", Name: "Ghost", Level: 3, Arrived: true, ChasedAway: true}); err != nil { + t.Fatal(err) + } + pets, err := loadPetShowcase(townListCap) + if err != nil { + t.Fatalf("loadPetShowcase: %v", err) + } + if len(pets) != 1 || !strings.Contains(pets[0].Name, "Rex") || pets[0].ArmorName != "Chain Dog Barding" { + t.Fatalf("pet showcase wrong (chased-away pet should be hidden): %+v", pets) + } + + // Graveyard. + deadUntil := time.Now().Add(6 * time.Hour).UTC() + if err := upsertPlayerMetaDeathState(alice, DeathState{ + Alive: false, DeadUntil: &deadUntil, LastDeathDate: "2026-07-10", + DeathSource: "arena", DeathLocation: "the Colosseum", + }); err != nil { + t.Fatal(err) + } + graves, err := loadGraveyard(graveyardLimit) + if err != nil { + t.Fatalf("loadGraveyard: %v", err) + } + if len(graves) != 1 || graves[0].Name != "Alice" || !graves[0].StillDown || graves[0].Source != "arena" { + t.Fatalf("graveyard wrong: %+v", graves) + } + + // Rivals board — directed pair ledger summed per user. + upsertRivalRecord(alice, bob, true) + upsertRivalRecord(alice, bob, true) + upsertRivalRecord(bob, alice, false) + board, err := loadRivalsBoard(rivalsBoardLimit) + if err != nil { + t.Fatalf("loadRivalsBoard: %v", err) + } + if len(board) != 2 || board[0].Name != "Alice" || board[0].Wins != 2 { + t.Fatalf("rivals board wrong: %+v", board) + } +} + +func TestRenderTownRegistry_AllThreeBoards(t *testing.T) { + civic := []civicEntry{{Name: "Alice", TotalPaid: 1234567}, {Name: "Bob", TotalPaid: 500}} + houses := []housingEntry{{Name: "Alice", Tier: 4}, {Name: "Bob", Tier: 1}} + pets := []petShowcaseEntry{{Name: "Rex (Alice)", Type: "dog", Level: 7, ArmorName: "Plate Dog Barding"}} + + out := renderTownRegistry(civic, houses, pets) + + for _, want := range []string{ + "Civic Pride", "€1,234,567", "Alice", + "Housing Street", "Established", "Base House", + "Pet Showcase", "Rex (Alice)", "L7 dog", "Plate Dog Barding", + } { + if !strings.Contains(out, want) { + t.Errorf("town registry missing %q\n---\n%s", want, out) + } + } +} + +func TestRenderTownRegistry_EmptyBoardsDontPanic(t *testing.T) { + out := renderTownRegistry(nil, nil, nil) + for _, want := range []string{"Civic Pride", "Housing Street", "Pet Showcase"} { + if !strings.Contains(out, want) { + t.Errorf("empty registry missing header %q", want) + } + } +} + +func TestPetArmorName(t *testing.T) { + if got := petArmorName("dog", 0); got != "" { + t.Errorf("tier 0 should be no armor, got %q", got) + } + if got := petArmorName("dog", 3); got != "Plate Dog Barding" { + t.Errorf("dog tier 3 = %q, want Plate Dog Barding", got) + } + if got := petArmorName("cat", 5); got != "Dragonscale Cat Armor" { + t.Errorf("cat tier 5 = %q, want Dragonscale Cat Armor", got) + } + if got := petArmorName("dog", 99); got != "" { + t.Errorf("out-of-range tier should be empty, got %q", got) + } +} + +func TestRenderGraveyard_StillDownVsRecovered(t *testing.T) { + now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) + deaths := []graveEntry{ + {Name: "Alice", Source: "arena", Location: "the Colosseum", LastDeathDate: "2026-07-10", StillDown: true}, + {Name: "Bob", Source: "adventure", Location: "", LastDeathDate: "2026-07-08", StillDown: false}, + } + out := renderGraveyard(deaths, now) + + if !strings.Contains(out, "fell in the arena") { + t.Errorf("arena death should read distinctly\n%s", out) + } + if !strings.Contains(out, "parts unknown") { + t.Errorf("blank location should fall back\n%s", out) + } + if !strings.Contains(out, "still resting") { + t.Errorf("Alice is still down\n%s", out) + } + if !strings.Contains(out, "back on their feet") { + t.Errorf("Bob has recovered\n%s", out) + } + if !strings.Contains(out, "today") || !strings.Contains(out, "2 days ago") { + t.Errorf("relative dates missing\n%s", out) + } +} + +func TestRenderGraveyard_Empty(t *testing.T) { + out := renderGraveyard(nil, time.Now()) + if !strings.Contains(out, "garden's quiet") { + t.Errorf("empty graveyard should be flavored\n%s", out) + } +} + +func TestRenderRivalsBoard_OrdersAndFormats(t *testing.T) { + now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) + yesterday := now.Add(-24 * time.Hour) + board := []rivalBoardEntry{ + {Name: "Alice", Wins: 9, Losses: 2, LastDuelAt: &yesterday}, + {Name: "Bob", Wins: 3, Losses: 5, LastDuelAt: nil}, + } + out := renderRivalsBoard(board, now) + + if !strings.Contains(out, "9W - 2L") { + t.Errorf("Alice's record missing\n%s", out) + } + if !strings.Contains(out, "1 day ago") { + t.Errorf("relative last-duel missing\n%s", out) + } + if !strings.Contains(out, "last duel: —") { + t.Errorf("Bob has no recorded duel time\n%s", out) + } + // Alice ranks above Bob. + if strings.Index(out, "Alice") > strings.Index(out, "Bob") { + t.Errorf("board should be ordered by wins\n%s", out) + } +} + +func TestHumanizeDeathDate(t *testing.T) { + now := time.Date(2026, 7, 10, 6, 0, 0, 0, time.UTC) + cases := map[string]string{ + "2026-07-10": "today", + "2026-07-09": "yesterday", + "2026-07-05": "5 days ago", + "": "some time ago", + "garbage": "garbage", + } + for in, want := range cases { + if got := humanizeDeathDate(in, now); got != want { + t.Errorf("humanizeDeathDate(%q) = %q, want %q", in, got, want) + } + } +} + +func TestTruncName(t *testing.T) { + if got := truncName("short", 18); got != "short" { + t.Errorf("short name unchanged, got %q", got) + } + long := truncName("this-is-a-very-long-display-name", 10) + if len([]rune(long)) != 10 { + t.Errorf("truncated to %d runes, want 10: %q", len([]rune(long)), long) + } + if !strings.HasSuffix(long, "…") { + t.Errorf("truncated name should end with ellipsis: %q", long) + } +}