mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
N4/E3: town registries — !town, !graveyard, !rivals board
Surface social data the game already stores as three read-only boards: - !town — civic pride (top tax_ledger contributors), housing street (name + tier), pet showcase (name/type/level/barding). - !graveyard — recent deaths across the guild (death_source/location, still-resting vs recovered), St. Guildmore's caretaker voice. - !rivals board — room-wide duel standings aggregated from the directed adventure_rival_records ledger; bare !rivals keeps the per-user view. All read-only: no schema, no combat, golden byte-identical. Enumerate off player_meta / tax_ledger / adventure_rival_records with COALESCE'd display names. Render layer is client-free and unit-tested; a DB-backed loader test exercises the real schema (caught a MAX(datetime) affinity issue — parsed by hand via parseSQLiteTime). Leak-check (engagement plan §E3): the civic board ranks tax_ledger.total_paid, which is gambling/shop/arena rake only — Misty/Arina donations go through euro.Debit with their own reason strings and their counters live in separate player_meta columns, so no board can correlate donations with the hidden buffs. Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
This commit is contained in:
234
internal/plugin/adventure_town_test.go
Normal file
234
internal/plugin/adventure_town_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user