Files
gogobee/internal/plugin/pete_runbeat_emit.go
prosolis a5b1961486 adventure: send Pete the run, room by room
The engine narrates every fight, trap and haul to one Matrix DM and then
discards the shape underneath it. This records that shape as it happens so
Pete can retell the run to somebody who wasn't in the room.

Beats ride the roster ticker (one extra request per two minutes, not one per
room) but on their own table, never pete_emit_queue: they are high-volume and
low-stakes, and a chatty run must not be able to spend the retry budget a
death dispatch depends on. Unlike the snapshots they ARE retried — a dropped
beat is a hole in a story, not a stale number the next tick corrects.

Recording is a leaf everywhere it is called. If a beat can't be written the
walk carries on exactly as it did before this existed.

Privacy is stricter here than on the board. The board omits an opted-out
player from a snapshot; a liveblog would be an account of where they are and
what is happening to them, so their beats never leave the box at all — and a
run whose owner can't be resolved is refused rather than published.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
2026-07-24 16:34:02 -07:00

242 lines
7.3 KiB
Go

package plugin
import (
"sort"
"gogobee/internal/peteclient"
"maunium.net/go/mautrix/id"
)
// The emit half of the run liveblog: the handful of places in the walk that
// know something worth telling, and the shape they tell it in.
//
// Every function here is a leaf. They read state, they append a row, they return
// nothing. None of them is allowed to change what the engine does or how long it
// takes to do it — if a beat can't be recorded, the run carries on exactly as it
// did before this file existed.
//
// The nouns are the payload and the numbers are the payload. No sentence
// assembled here ever reaches a reader: Pete writes the words, the same contract
// emitFact has always had.
// beatRunStart opens a run's story: who, where, and how far it goes. The token
// is the public board token, so Pete can hang the liveblog off the adventurer
// page the roster already links to.
//
// This is the only beat carrying identity. Every beat after it is keyed on the
// run id alone, which means a run whose start beat was dropped is anonymous
// rather than misattributed.
func beatRunStart(userID id.UserID, run *DungeonRun, zone ZoneDefinition) {
if run == nil {
return
}
b := peteclient.RunBeat{
Kind: "start",
Token: eventToken(userID, "roster"),
Zone: zone.Display,
TotalRooms: run.TotalRooms,
Room: 1,
RoomKind: string(RoomEntry),
}
if name := charName(userID); name != "" {
b.Name = name
}
if c, err := LoadDnDCharacter(userID); err == nil && c != nil && !c.PendingSetup {
b.Level = c.Level
}
recordRunBeat(run.RunID, b)
}
// beatRoom records an arrival. outcome distinguishes walking on from doubling
// back — the map on the who page already shows *where* the party is, and the
// difference between those two is most of what the log adds to it.
func beatRoom(run *DungeonRun, node string, idx int, outcome string) {
if run == nil {
return
}
b := peteclient.RunBeat{
Kind: "room",
Room: idx + 1,
TotalRooms: run.TotalRooms,
Outcome: outcome,
}
if g, ok := loadZoneGraph(run.ZoneID); ok {
if n, exists := g.Nodes[node]; exists {
b.RoomKind = string(nodeKindToRoomType(n.Kind))
}
}
recordRunBeat(run.RunID, b)
}
// beatCombat records one resolved fight: what it was, how it went, and what it
// cost. Amount is damage taken by the party's leader — the HP pair is the state
// after, so a reader can see the run getting thinner room by room, which is the
// tension the Matrix DM has and the web has never had.
func beatCombat(run *DungeonRun, monster string, elite, boss, won, timedOut bool,
preHP, postHP, maxHP, crits, fumbles int) {
if run == nil {
return
}
outcome := "won"
switch {
case won:
case timedOut:
outcome = "retreat" // outlasted, not killed: mechanically a withdrawal
default:
outcome = "down"
}
kind := string(RoomExploration)
switch {
case boss:
kind = string(RoomBoss)
case elite:
kind = string(RoomElite)
}
dmg := preHP - postHP
if dmg < 0 {
dmg = 0 // healed through the fight; "negative damage" is not a fact
}
recordRunBeat(run.RunID, peteclient.RunBeat{
Kind: "combat",
Room: run.CurrentRoom + 1,
TotalRooms: run.TotalRooms,
RoomKind: kind,
Target: monster,
Outcome: outcome,
Amount: dmg,
HP: postHP,
HPMax: maxHP,
Crits: crits,
Fumbles: fumbles,
})
}
// beatTrap records a sprung trap. A zero-damage trap is still worth a beat: the
// near-miss is part of the run, and the log reads wrong if the party walks
// through a trap room and nothing at all is said about it.
func beatTrap(userID id.UserID, run *DungeonRun, damage int) {
if run == nil {
return
}
hp, maxHP := dndHPSnapshot(userID)
outcome := "sprung"
if damage <= 0 {
outcome = "avoided"
}
recordRunBeat(run.RunID, peteclient.RunBeat{
Kind: "trap",
Room: run.CurrentRoom + 1,
TotalRooms: run.TotalRooms,
RoomKind: string(RoomTrap),
Outcome: outcome,
Amount: damage,
HP: hp,
HPMax: maxHP,
})
}
// beatTreasure records one thing found and kept. One beat per item rather than a
// count: an item is a name, and the name is the whole reason anybody reads a
// loot line.
func beatTreasure(run *DungeonRun, item, source string) {
if run == nil || item == "" {
return
}
recordRunBeat(run.RunID, peteclient.RunBeat{
Kind: "treasure",
Room: run.CurrentRoom + 1,
TotalRooms: run.TotalRooms,
Target: item,
Outcome: source, // "cache" | "boss" | "zone"
})
}
// beatHaul records a room's auto-harvest take, one beat for the room rather than
// one per resource — this is background gathering, and a per-resource beat would
// bury the fights it happens between. Target names the biggest single yield so
// the line has a noun in it; Amount is the total.
func beatHaul(run *DungeonRun, sum autoHarvestSummary) {
if run == nil || len(sum.Yields) == 0 {
return
}
total := 0
// Deterministic pick: biggest yield, ties broken by name, so re-running the
// same room can't produce two different beats from the same map.
keys := make([]string, 0, len(sum.Yields))
for k, v := range sum.Yields {
total += v
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
if sum.Yields[keys[i]] != sum.Yields[keys[j]] {
return sum.Yields[keys[i]] > sum.Yields[keys[j]]
}
return keys[i] < keys[j]
})
top := sum.Names[keys[0]]
if top == "" {
top = keys[0]
}
recordRunBeat(run.RunID, peteclient.RunBeat{
Kind: "haul",
Room: run.CurrentRoom + 1,
TotalRooms: run.TotalRooms,
Target: top,
Amount: total,
Count: len(sum.Yields),
})
}
// beatLock records a door the party had to deal with. Only the interesting
// outcomes reach here — an unlocked door is not an event.
func beatLock(run *DungeonRun, target, outcome string) {
if run == nil {
return
}
recordRunBeat(run.RunID, peteclient.RunBeat{
Kind: "lock",
Room: run.CurrentRoom + 1,
TotalRooms: run.TotalRooms,
Target: target,
Outcome: outcome, // "picked" | "sealed"
})
}
// beatRegion records a border crossing on a multi-region expedition. The run id
// changes at a crossing (each region gets its own run), so this beat closes one
// liveblog and the next run's start beat opens the next — naming the region
// ahead is what lets Pete stitch them into one journey.
func beatRegion(run *DungeonRun, from, to string) {
if run == nil {
return
}
recordRunBeat(run.RunID, peteclient.RunBeat{
Kind: "region",
Region: from,
Target: to,
Outcome: "crossed",
})
}
// beatRunEnd closes the story. outcome is the only field that matters and it is
// the one the whole log is read for.
//
// First writer wins, and that is load-bearing. A run ends once, but it passes
// through more than one place that could say so: a death in the combat resolver
// goes on to call abandonZoneRun, and a completed run gets retired by the
// expedition layer. The specific callers file first and know what happened; the
// generic funnels file "abandoned" and would otherwise overwrite them with the
// least informative answer available.
func beatRunEnd(run *DungeonRun, outcome string) {
if run == nil || runHasEndBeat(run.RunID) {
return
}
recordRunBeat(run.RunID, peteclient.RunBeat{
Kind: "end",
Room: run.CurrentRoom + 1,
TotalRooms: run.TotalRooms,
Outcome: outcome, // "cleared" | "died" | "retreated" | "abandoned"
})
}