package storage import ( "database/sql" "encoding/json" "sort" ) // The durable record of what has actually happened in the realm. // // Every other adventure table is a snapshot gogobee replaces wholesale. This one // accumulates, because the questions it answers are historical: what has this // adventurer killed, where have they been, how many times have they died. The // story feed technically holds the same information — but as English, inside a // headline, which you cannot count. // // Pete still computes nothing about the *game*. It only counts facts gogobee // already told it. No row here is ever authored by Pete or edited after insert. // AdvEvent is one game fact, kept as fact rather than as the sentence it was // rendered into. Mirrors web.AdvFact minus the transport-only fields (no_push, // class_race) that describe delivery rather than the event. Stakes is the one // former transport field kept here: for a treasure_found it carries the item's // name, which is the fact, not the delivery. type AdvEvent struct { GUID string `json:"guid"` EventType string `json:"event_type"` Tier string `json:"tier"` Subject string `json:"subject"` Opponent string `json:"opponent"` Boss string `json:"boss"` Zone string `json:"zone"` Region string `json:"region"` Level int `json:"level"` Tally int `json:"tally"` Outcome string `json:"outcome"` Milestone string `json:"milestone"` Stakes string `json:"stakes"` Actors []string `json:"actors"` OccurredAt int64 `json:"occurred_at"` } // InsertAdventureEvent records a fact. Idempotent on guid via INSERT OR IGNORE: // gogobee retries a fact whose ack it lost, and a retried siege must not add a // second kill to anybody's tally. The story insert upstream is guarded by // IsGUIDSeen for the same reason; this is the same guarantee enforced by the // table rather than by a check-then-act that a concurrent retry could race. func InsertAdventureEvent(e *AdvEvent) error { actors, err := json.Marshal(e.Actors) if err != nil { return err } _, err = Get().Exec(` INSERT OR IGNORE INTO adventure_events (guid, event_type, tier, subject, opponent, boss, zone, region, level, tally, outcome, milestone, stakes, actors, occurred_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, e.GUID, e.EventType, e.Tier, e.Subject, e.Opponent, e.Boss, e.Zone, e.Region, e.Level, e.Tally, e.Outcome, e.Milestone, e.Stakes, string(actors), e.OccurredAt) return err } // BossTally is one monster and how many times this adventurer has put it down. type BossTally struct { Boss string `json:"boss"` Kills int `json:"kills"` First bool `json:"first"` // they were the first in the realm to ever clear it } // ZoneTally is one zone and how many times this adventurer has cleared it. type ZoneTally struct { Zone string `json:"zone"` Region string `json:"region"` Clears int `json:"clears"` First bool `json:"first"` } // TrophyCase is an adventurer's whole history, counted. // // Treasure is counted only from the treasure_found fact, never from the vault // snapshot: a bought sword is not a trophy, and the snapshot is current-state // with no "found it in X on day 3". So a treasure tally that predates the first // treasure_found is a clean zero, not a back-derivation. type TrophyCase struct { Name string `json:"name"` Bosses []BossTally `json:"bosses,omitempty"` Zones []ZoneTally `json:"zones,omitempty"` Treasures []TreasureTally `json:"treasures,omitempty"` BossKills int `json:"boss_kills"` // total, including firsts BossFirsts int `json:"boss_firsts"` // realm-firsts among them ZoneClears int `json:"zone_clears"` ZoneFirsts int `json:"zone_firsts"` TreasuresFound int `json:"treasures_found"` // story-grade finds, total TreasureFirsts int `json:"treasure_firsts"` // realm-first hoards among them Deaths int `json:"deaths"` Retreats int `json:"retreats"` RivalWins int `json:"rival_wins"` Milestones []string `json:"milestones,omitempty"` Survived int `json:"survived"` // mischief contracts walked away from Downed int `json:"downed"` // mischief contracts that landed Events int `json:"events"` // total facts on file FirstSeen int64 `json:"first_seen"` LastSeen int64 `json:"last_seen"` } // TreasureTally is one story-grade find: the item's name, the zone it came out // of, and whether it was a realm-first hoard. Unlike bosses and zones there is no // repeat count — a named treasure is found once, so each is its own row. type TreasureTally struct { Item string `json:"item"` Zone string `json:"zone"` First bool `json:"first"` // first in the realm to pull this hoard } // EventsBySubject returns every fact about a character, newest first. This is the // timeline *and* the trophy source: one read, aggregated in Go, because the pool // is MaxOpenConns(1) and six COUNT queries against it would serialize behind each // other for an answer that fits comfortably in memory (a busy adventurer // accumulates tens of facts, not millions). // // limit <= 0 means no limit, and callers who count should use it. A limit here // silently truncates a *tally*, not just a list: read 40 facts for a display cap // and a veteran's kill count quietly stops at 40 and stays wrong forever. Cap the // trail in the caller, after the counting is done. // // Facts where the character is the *opponent* are included: a duel they lost is // part of their history, and only the subject field would otherwise carry it. func EventsBySubject(name string, limit int) ([]AdvEvent, error) { if name == "" { return nil, nil } q := ` SELECT guid, event_type, tier, subject, opponent, boss, zone, region, level, tally, outcome, milestone, stakes, actors, occurred_at FROM adventure_events WHERE subject = ? OR opponent = ? ORDER BY occurred_at DESC` args := []any{name, name} if limit > 0 { q += ` LIMIT ?` args = append(args, limit) } rows, err := Get().Query(q, args...) if err != nil { return nil, err } defer rows.Close() var out []AdvEvent for rows.Next() { var e AdvEvent var tier, subject, opponent, boss, zone, region, outcome, milestone, stakes, actors sql.NullString if err := rows.Scan(&e.GUID, &e.EventType, &tier, &subject, &opponent, &boss, &zone, ®ion, &e.Level, &e.Tally, &outcome, &milestone, &stakes, &actors, &e.OccurredAt); err != nil { return nil, err } e.Tier, e.Subject, e.Opponent = tier.String, subject.String, opponent.String e.Boss, e.Zone, e.Region = boss.String, zone.String, region.String e.Outcome, e.Milestone, e.Stakes = outcome.String, milestone.String, stakes.String if actors.String != "" { _ = json.Unmarshal([]byte(actors.String), &e.Actors) } out = append(out, e) } return out, rows.Err() } // BuildTrophyCase counts a character's history out of their facts. Pure: it takes // the rows EventsBySubject already read rather than querying again, so the who // page pays for one database read and gets both the timeline and the trophies. // // Only facts where the character is the *subject* count toward trophies. They // appear in an ally's dispatch as an opponent too, and crediting those would let // a duel someone lost show up as a kill in their own case. func BuildTrophyCase(name string, events []AdvEvent) TrophyCase { tc := TrophyCase{Name: name} bosses := map[string]*BossTally{} zones := map[string]*ZoneTally{} for _, e := range events { if tc.LastSeen == 0 || e.OccurredAt > tc.LastSeen { tc.LastSeen = e.OccurredAt } if tc.FirstSeen == 0 || e.OccurredAt < tc.FirstSeen { tc.FirstSeen = e.OccurredAt } tc.Events++ if e.Subject != name { continue // they're the other party in someone else's dispatch } switch e.EventType { case "boss_kill", "boss_first": tc.BossKills++ if e.Boss != "" { b := bosses[e.Boss] if b == nil { b = &BossTally{Boss: e.Boss} bosses[e.Boss] = b } b.Kills++ if e.EventType == "boss_first" { b.First = true } } if e.EventType == "boss_first" { tc.BossFirsts++ } case "zone_clear", "zone_first": tc.ZoneClears++ if e.Zone != "" { z := zones[e.Zone] if z == nil { z = &ZoneTally{Zone: e.Zone, Region: e.Region} zones[e.Zone] = z } z.Clears++ if e.EventType == "zone_first" { z.First = true } } if e.EventType == "zone_first" { tc.ZoneFirsts++ } case "treasure_found": tc.TreasuresFound++ // A realm-first hoard rides the priority tier, the same way zone_first // does; a plain story-grade find is a bulletin. first := e.Tier == "priority" if first { tc.TreasureFirsts++ } if e.Stakes != "" { tc.Treasures = append(tc.Treasures, TreasureTally{Item: e.Stakes, Zone: e.Zone, First: first}) } case "death": tc.Deaths++ case "retreat": tc.Retreats++ case "rival_result": tc.RivalWins++ // the fact's subject is the winner; see renderAdventure case "milestone": if e.Milestone != "" { tc.Milestones = append(tc.Milestones, e.Milestone) } case "mischief_survived": tc.Survived++ case "mischief_downed": tc.Downed++ } } for _, b := range bosses { tc.Bosses = append(tc.Bosses, *b) } for _, z := range zones { tc.Zones = append(tc.Zones, *z) } // Deterministic order: most-fought first, then alphabetical. The tiebreak is // not cosmetic — map iteration is randomized in Go, so without it the panel // reshuffles on every request and a cached page and a live one disagree. sort.Slice(tc.Bosses, func(i, j int) bool { if tc.Bosses[i].Kills != tc.Bosses[j].Kills { return tc.Bosses[i].Kills > tc.Bosses[j].Kills } return tc.Bosses[i].Boss < tc.Bosses[j].Boss }) sort.Slice(tc.Zones, func(i, j int) bool { if tc.Zones[i].Clears != tc.Zones[j].Clears { return tc.Zones[i].Clears > tc.Zones[j].Clears } return tc.Zones[i].Zone < tc.Zones[j].Zone }) return tc }