diff --git a/internal/db/db.go b/internal/db/db.go index ccd8695..22d919f 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -700,6 +700,11 @@ func RunMaintenance() { // weight the drain query already skips — drop it so a durable outage // can't accrete rows forever. {"pete_emit_queue_parked", `DELETE FROM pete_emit_queue WHERE sent_at IS NULL AND created_at < ?`, []interface{}{cutoff30d}}, + // Run beats — the local copy is a delivery buffer, not an archive. Pete + // keeps the run report; once a beat is a week old it has either shipped + // or missed its window entirely (the liveblog it feeds is about a run + // happening *now*), so both states reap on the same clock. + {"pete_run_beat", `DELETE FROM pete_run_beat WHERE occurred_at < ?`, []interface{}{cutoff7d}}, // Rate limits — purge entries older than today {"rate_limits", `DELETE FROM rate_limits WHERE date < ?`, []interface{}{today}}, @@ -1045,6 +1050,24 @@ CREATE TABLE IF NOT EXISTS pete_emit_queue ( sent_at INTEGER ); +-- Run beats: the room-by-room texture of an expedition, on its way to Pete's +-- liveblog. Deliberately NOT pete_emit_queue — these are high-volume and +-- low-stakes, and a run that generates forty beats must never be able to crowd +-- a death dispatch out of the retry budget. Ordering is the whole contract: +-- (run_id, seq) is the primary key and Pete is idempotent on the pair, so a +-- re-sent batch collapses and a re-ordered one still sorts right on arrival. +CREATE TABLE IF NOT EXISTS pete_run_beat ( + run_id TEXT NOT NULL, + seq INTEGER NOT NULL, + kind TEXT NOT NULL, + occurred_at INTEGER NOT NULL DEFAULT (unixepoch()), + payload TEXT NOT NULL DEFAULT '{}', + sent_at INTEGER, + PRIMARY KEY (run_id, seq) +); +CREATE INDEX IF NOT EXISTS idx_pete_run_beat_unsent + ON pete_run_beat(sent_at, run_id, seq); + -- Players who opted out of being named in Pete's adventure news. Enforced at -- emit time (anonymize, never delete). Mirrors shade_optout. CREATE TABLE IF NOT EXISTS news_optout ( @@ -1822,6 +1845,20 @@ CREATE TABLE IF NOT EXISTS equip_applied_orders ( applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); +-- The web ACTION queue's idempotency ledger — the same job as the table above, +-- for the verbs that play the game rather than dress the character (extract, a +-- Siege bout). Its own table because the two queues have their own guid spaces +-- and their own poll loops, and a shared ledger would make a bug in one able to +-- silence the other. The stakes are higher here than for equip: a replayed +-- extraction ends a run the player resumed, and a replayed bout spends a day the +-- player has not been given back. +CREATE TABLE IF NOT EXISTS adv_applied_orders ( + guid TEXT PRIMARY KEY, + status TEXT NOT NULL, -- the terminal verdict we filed, replayed on re-offer + detail TEXT NOT NULL DEFAULT '', + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + -- Babysitting Service CREATE TABLE IF NOT EXISTS adventure_babysit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/internal/peteclient/client.go b/internal/peteclient/client.go index dcf2911..144e2e0 100644 --- a/internal/peteclient/client.go +++ b/internal/peteclient/client.go @@ -48,6 +48,13 @@ type Fact struct { Milestone string `json:"milestone,omitempty"` OccurredAt int64 `json:"occurred_at"` NoPush bool `json:"no_push,omitempty"` // backfill: suppress Pete web-push + // RunID names the expedition this fact is the ENDING of, and only the three + // facts that are one carry it: a clear, a retreat, a death. It is what lets + // Pete's dispatch link back to the run's own report — the log, the numbers, + // the moment it turned — instead of leaving a paragraph about an outcome with + // no way back to what produced it. Empty everywhere else, and safe to be + // empty: Pete renders the dispatch exactly as it did before the report existed. + RunID string `json:"run_id,omitempty"` // Headline/Lede are LLM-authored prose for this fact, both optional. Pete // prefers them over its own template when present and past its prose-guard, // and falls back to the template otherwise — so an empty pair (LLM off, or @@ -277,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 @@ -373,6 +404,234 @@ func PushRoster(ctx context.Context, snap RosterSnapshot) error { return std.post(ctx, "/api/ingest/roster", payload) } +// SiegeDefender is one adventurer's standing in the current Siege muster. +// +// Token is the same public board token the roster uses, so Pete can link a +// defender to their page — and it is EMPTY for an opted-out player, with Name +// carrying anonName instead. That is the whole opt-out story here: their damage +// still counts and still holds its rank (the town's effort is the town's), but +// there is no name to click. It differs from the board's rule (omit entirely) +// on purpose — a defender board that silently dropped contributors would +// understate what the town actually did to the boss. +type SiegeDefender struct { + Token string `json:"token,omitempty"` + Name string `json:"name"` + Level int `json:"level,omitempty"` + Fights int `json:"fights"` + Damage int `json:"damage"` + FoughtToday bool `json:"fought_today"` +} + +// SiegePast is one closed-out Siege for the history table. +type SiegePast struct { + BossID int64 `json:"boss_id"` + BossName string `json:"boss_name"` + Tier int `json:"tier"` + Outcome string `json:"outcome"` // "defeated" | "survived" + HPRemaining int `json:"hp_remaining"` + HPMax int `json:"hp_max"` + Defenders int `json:"defenders"` + MVP string `json:"mvp,omitempty"` + MVPFights int `json:"mvp_fights,omitempty"` + EndedAt int64 `json:"ended_at"` +} + +// SiegeSnapshot is the complete war room: the live boss (if any), its muster, +// and every Siege that came before. Same complete-snapshot contract as the +// roster — Pete replaces its copy — so a defender omitted here leaves the board +// and a resolved Siege stops showing a live bar. +// +// Defenders carries EVERY alive adventurer, not just contributors. The zero-fight +// rows are the point: one bout per person per day means somebody who hasn't +// swung today is a hit the town hasn't taken, and the page can only show that gap +// if the people in it are on the wire. +type SiegeSnapshot struct { + SnapshotAt int64 `json:"snapshot_at"` + Active bool `json:"active"` + BossID int64 `json:"boss_id,omitempty"` + BossName string `json:"boss_name,omitempty"` + Tier int `json:"tier,omitempty"` + HPCurrent int `json:"hp_current"` + HPMax int `json:"hp_max"` + StartsAt int64 `json:"starts_at,omitempty"` + EndsAt int64 `json:"ends_at,omitempty"` + BoutsToday int `json:"bouts_today"` + Defenders []SiegeDefender `json:"defenders,omitempty"` + History []SiegePast `json:"history,omitempty"` +} + +// PushSiege sends the war room to Pete, synchronously, and drops it on failure — +// the same drop-the-lie semantics as PushRoster. A retried snapshot would claim +// a pool level that has since moved, and the next tick carries the truth anyway. +func PushSiege(ctx context.Context, snap SiegeSnapshot) error { + if !Enabled() { + return nil + } + payload, err := json.Marshal(snap) + if err != nil { + return err + } + return std.post(ctx, "/api/ingest/siege", payload) +} + +// RunBeat is one structured moment inside an expedition run: a room entered, a +// fight resolved, a trap sprung, a haul taken. Facts, never prose — Pete owns +// the words, exactly as it does for a Fact. The engine already narrates every +// one of these to Matrix and then throws the narration away; this carries the +// shape underneath it so Pete can retell the run to somebody who wasn't there. +// +// (RunID, Seq) is the identity. Seq is monotonic per run and assigned at record +// time, so Pete can order a batch that arrives out of order and drop a duplicate +// without comparing contents. +// +// Nothing here is player-identifying except Token, which rides the `start` beat +// only and is the same public board token the roster uses. An opted-out player's +// beats are never pushed at all — see pushRunBeats. +type RunBeat struct { + RunID string `json:"run_id"` + Seq int64 `json:"seq"` + Kind string `json:"kind"` // start|room|combat|trap|treasure|haul|lock|camp|region|end|summary + OccurredAt int64 `json:"occurred_at"` + + Token string `json:"token,omitempty"` // `start` only: whose run this is + Name string `json:"name,omitempty"` // `start` only: character name + Level int `json:"level,omitempty"` // `start` only + Zone string `json:"zone,omitempty"` + Region string `json:"region,omitempty"` + Room int `json:"room,omitempty"` // 1-based, as the player sees it + TotalRooms int `json:"total_rooms,omitempty"` // 0 when unknown + RoomKind string `json:"room_kind,omitempty"` // entry|exploration|trap|elite|boss|secret + Target string `json:"target,omitempty"` // monster, item, region, lock — the noun + Outcome string `json:"outcome,omitempty"` + Amount int `json:"amount,omitempty"` // damage taken, or a total quantity + Count int `json:"count,omitempty"` // how many distinct things Amount covers + HP int `json:"hp,omitempty"` + HPMax int `json:"hp_max,omitempty"` + Crits int `json:"crits,omitempty"` + Fumbles int `json:"fumbles,omitempty"` + // Prose is the single exception to "nouns and numbers only", and it is + // confined to the one kind that has any: `summary`, the three sentences the + // local model writes over a finished run. Pete guards it exactly as it guards + // a dispatch lede and drops the words (not the beat) on a rejection. Every + // other kind must leave this empty — Pete scrubs it if they don't. + Prose string `json:"prose,omitempty"` +} + +// RealmZone is one zone as the realm map draws it: what it is, who first got +// through it, how many have since, and who is inside it right now. +// +// FirstClearBy is a character name and FirstClearToken the public board token, +// exactly as the Siege muster pairs them — and the token is EMPTY for a player +// who has opted out, keeping the name off the page too (see buildRealmSnapshot: +// an opted-out first-clearer is anonymised, not deleted, because deleting the +// claim would make the zone read as never-cleared, which is a different and +// false statement about the realm). +type RealmZone struct { + ID string `json:"id"` + Display string `json:"display"` + Tier int `json:"tier"` + LevelMin int `json:"level_min"` + LevelMax int `json:"level_max"` + Faction string `json:"faction,omitempty"` + Atmosphere string `json:"atmosphere,omitempty"` + Postgame bool `json:"postgame,omitempty"` // T6 mythic: gated, drawn apart + + FirstClearBy string `json:"first_clear_by,omitempty"` + FirstClearToken string `json:"first_clear_token,omitempty"` + FirstClearAt int64 `json:"first_clear_at,omitempty"` + + Clears int `json:"clears"` // boss-defeated runs, all time + Clearers int `json:"clearers"` // distinct adventurers who have managed it + + Occupants []RealmOccupant `json:"occupants,omitempty"` // in there right now +} + +// RealmOccupant is somebody currently on an expedition in a zone. Same +// name+token pair as everywhere else, and an opted-out player is omitted +// outright rather than anonymised: unlike a first clear, presence is not part of +// a shared tally that stops adding up without them, and "who is in there right +// now" is exactly the live-location fact the liveblog is careful about. +type RealmOccupant struct { + Token string `json:"token,omitempty"` + Name string `json:"name"` + Level int `json:"level,omitempty"` + Day int `json:"day,omitempty"` +} + +// RealmFirst is one row of the hall of firsts: a thing that happened in the +// realm exactly once ever, and who it happened to. The ledger +// (news_realm_firsts) records only (kind, target, first_at) — the holder is +// recovered by gogobee at push time from the run history, which is why this is +// pushed rather than derived on Pete. +type RealmFirst struct { + Kind string `json:"kind"` // "zone" | "treasure" + Target string `json:"target"` // the zone id or treasure key + Display string `json:"display"` // the human name for it + Tier int `json:"tier,omitempty"` // zone tier, when kind is "zone" + Holder string `json:"holder,omitempty"` // character name, empty when unrecoverable + Token string `json:"token,omitempty"` // board token; empty when opted out + AtUnix int64 `json:"at_unix"` // when the realm first saw it +} + +// RealmStanding is one adventurer's line on the board. Every number here is a +// lifetime total from the game's own run history — nothing is a rate, an +// average, or anything that would move on its own while nobody played. +type RealmStanding struct { + Token string `json:"token,omitempty"` + Name string `json:"name"` + Level int `json:"level"` + ClassRace string `json:"class_race,omitempty"` + DeepestTier int `json:"deepest_tier"` // deepest zone tier actually cleared + Clears int `json:"clears"` + Zones int `json:"zones"` // distinct zones cleared + Firsts int `json:"firsts"` // realm-firsts held + SiegeDamage int `json:"siege_damage"` + SiegeFights int `json:"siege_fights"` +} + +// RealmSnapshot is the whole realm as one photograph: every zone, the hall of +// firsts, and the board. Snapshot semantics, like the roster and the Siege — +// Pete replaces its copy and a failed push is dropped, not retried. +// +// It is pushed on the roster ticker but NOT every tick: none of it moves fast +// enough to be worth the aggregate queries every two minutes, and the page's +// staleness window is generous for exactly that reason. See realmPushInterval. +type RealmSnapshot struct { + SnapshotAt int64 `json:"snapshot_at"` + Zones []RealmZone `json:"zones,omitempty"` + Firsts []RealmFirst `json:"firsts,omitempty"` + Standings []RealmStanding `json:"standings,omitempty"` +} + +// PushRealm sends the realm pages' backing data to Pete. Drop-on-failure, same +// as the other two snapshots. +func PushRealm(ctx context.Context, snap RealmSnapshot) error { + if !Enabled() { + return nil + } + payload, err := json.Marshal(snap) + if err != nil { + return err + } + return std.post(ctx, "/api/ingest/realm", payload) +} + +// PushRunBeats delivers a batch of beats. Unlike the snapshots this is +// append-only and IS retried — a dropped beat is a hole in a story, not a stale +// number that the next tick corrects. The caller only marks rows sent on success. +func PushRunBeats(ctx context.Context, beats []RunBeat) error { + if !Enabled() || len(beats) == 0 { + return nil + } + payload, err := json.Marshal(struct { + Beats []RunBeat `json:"beats"` + }{beats}) + if err != nil { + return err + } + return std.post(ctx, "/api/ingest/run", payload) +} + // PlayerDetail is the private, owner-only expansion for one player: inventory, // vault, house, and pets. Like MischiefBalance it is keyed by localpart (the // sign-in name), in its own keyspace on Pete — Pete only ever serves it back to @@ -396,6 +655,63 @@ type PlayerDetail struct { // No omitempty: a €0 balance is a real, informative fact (a broke player), not // an absent one — dropping it would let the confirm dialog show a stale amount. Balance float64 `json:"balance"` + // Zones is where this adventurer may go right now, priced. It is the offer + // list behind the web's "send on expedition" picker: level gating and the T6 + // postgame gate are resolved here, so a zone the player cannot enter is simply + // absent rather than shown and then refused. Empty while they are already out. + Zones []ZoneOffer `json:"zones,omitempty"` + // Resume is the extracted expedition waiting to be walked back into, priced + // the same way. Absent when there is nothing to resume. + Resume *ResumeOffer `json:"resume,omitempty"` + // Babysit is the pet-care subscription's standing and price. Always present + // for a live adventurer: "you already have one" is as useful to the page as a + // price is. + Babysit *BabysitOffer `json:"babysit,omitempty"` +} + +// ZoneOffer is one place the owner may set out for, with what the trip costs. +// Pete renders these and does no arithmetic — the prices are the game's, quoted +// at push time, and gogobee re-quotes them for real when the order lands. +type ZoneOffer struct { + ID string `json:"id"` + Display string `json:"display"` + Tier int `json:"tier"` + Hook string `json:"hook,omitempty"` + Postgame bool `json:"postgame,omitempty"` + Loadouts []LoadoutOffer `json:"loadouts,omitempty"` +} + +// LoadoutOffer is one supply preset for a zone: what it is called, what it +// costs, and roughly how long it lasts. Key is the token the order carries back. +type LoadoutOffer struct { + Key string `json:"key"` // lean|balanced|heavy + Name string `json:"name"` + Blurb string `json:"blurb,omitempty"` + Cost int `json:"cost"` + Days int `json:"days"` // provisions at the zone's daily burn +} + +// ResumeOffer is the extracted expedition the owner can still walk back into, +// with the same priced loadouts as a fresh departure. ExpiresAt is the end of +// the seven-day window, so the page can say how long is left rather than just +// that there is a way back. +type ResumeOffer struct { + ZoneID string `json:"zone_id"` + Display string `json:"display"` + Tier int `json:"tier"` + Day int `json:"day"` + ExpiresAt int64 `json:"expires_at,omitempty"` + Loadouts []LoadoutOffer `json:"loadouts,omitempty"` +} + +// BabysitOffer is the sitter's standing and price. WeekCost/MonthCost are the +// two durations the game sells; they scale with level, which is why they are +// pushed rather than hardcoded on Pete. +type BabysitOffer struct { + Active bool `json:"active"` + ExpiresAt int64 `json:"expires_at,omitempty"` + WeekCost int `json:"week_cost"` + MonthCost int `json:"month_cost"` } // EquipSlotView is one of the 5 standard equipment slots, carrying what the web @@ -496,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"` } @@ -748,6 +1072,88 @@ func VerdictEquip(ctx context.Context, guid, status, detail string) error { return std.post(ctx, "/api/equip/verdict", payload) } +// --------------------------------------------------------------------------- +// The action queue +// +// The equip queue's sibling, and the first one that plays the game rather than +// dressing the character. An owner clicks "Pull out" on their own adventurer +// page or "Take your bout" on the war room; Pete records the intent and we drain +// it here. Same non-idempotent problem, same answer: the poller guards on the +// order guid before it runs anything, because an extraction ends a run and a +// bout spends the day's only swing, and neither converges on a replay. +// +// Nothing in an order names a character. Pete resolves that from the session +// (one account, one localpart, one adventurer) so there is no id on the wire for +// a client to forge — the contrast with EquipOrder, which has to carry an item +// id and a slot, is deliberate. +// --------------------------------------------------------------------------- + +// AdvOrder is one requested action as Pete describes it. owner_localpart is the +// Matrix localpart whose adventurer acts; token and character_name are display +// copy Pete froze at order time and we ignore both. +type AdvOrder struct { + GUID string `json:"guid"` + OwnerLocalpart string `json:"owner_localpart"` + Token string `json:"token"` + CharacterName string `json:"character_name"` + Action string `json:"action"` + Status string `json:"status"` + CreatedAt int64 `json:"created_at"` + // Params is the verb's arguments, and only the verbs that take any carry it: + // which zone, which supply loadout, how many days of sitting. It never names + // an adventurer — that still comes from the session on Pete's side — and + // every field in it is re-resolved against the game's own tables before it + // means anything, so a forged zone or a forged price buys nothing. + Params *AdvOrderParams `json:"params,omitempty"` +} + +// AdvOrderParams is the union of every verb's arguments, flat rather than +// per-verb because there are three of them and each reads one or two fields. +// Anything a verb does not read is ignored rather than rejected. +type AdvOrderParams struct { + Zone string `json:"zone,omitempty"` // zone id, for expedition_start + Loadout string `json:"loadout,omitempty"` // lean|balanced|heavy, for expedition_start / resume + Days int `json:"days,omitempty"` // 7 or 30, for babysit +} + +// Action names, the wire contract's half of storage.AdvAction* on Pete. +const ( + AdvOrderExtract = "extract" + AdvOrderSiegeJoin = "siege_join" + AdvOrderExpedition = "expedition_start" + AdvOrderResume = "expedition_resume" + AdvOrderBabysit = "babysit" + // The three doors the web verbs' own refusal text used to name without + // offering: `!expedition abandon`, `!expedition leave`, `!adventure babysit + // cancel`. None of them takes an argument and none of them spends money. + AdvOrderAbandon = "expedition_abandon" + AdvOrderLeave = "expedition_leave" + AdvOrderBabysitCancel = "babysit_cancel" +) + +// PendingOrders asks Pete for web actions waiting on us. A Pete predating the +// queue answers 404, surfaced here as an error the poll loop logs quietly. +func PendingOrders(ctx context.Context) ([]AdvOrder, error) { + if !Enabled() { + return nil, nil + } + var out []AdvOrder + if err := std.getJSON(ctx, "/api/adventure/orders/pending", &out); err != nil { + return nil, err + } + return out, nil +} + +// VerdictOrder files our verdict on a web action. Idempotent on Pete, so a +// retried verdict is safe. +func VerdictOrder(ctx context.Context, guid, status, detail string) error { + payload, err := json.Marshal(map[string]string{"guid": guid, "status": status, "detail": detail}) + if err != nil { + return err + } + return std.post(ctx, "/api/adventure/orders/verdict", payload) +} + // getJSON does a bearer-authed GET and decodes the body. func (c *Client) getJSON(ctx context.Context, path string, out any) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.IngestURL+path, nil) diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index bfeca23..51e1411 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -253,6 +253,12 @@ func (p *AdventurePlugin) Init() error { // deaths, single-holder achievements) the first boot the seam is live, so // launch doesn't open onto an empty section. One-shot, kept (see gap #7). p.bootstrapPeteNewsBackfill() + // Repair the zone half of news_realm_firsts: the original one-shot filtered + // on `abandoned = 0` (which does not mean anybody gave up) and dated every + // row to the minute it ran. Seeds only, emits nothing. Runs regardless of the + // news switches — a ledger that is right only while emission is on mis-tiers + // the first dispatch after somebody flips it. One-shot, kept. + bootstrapRealmFirstsReseed() // Phase R1 orphan-archive used to run here on every Init, but it // over-archived: it treats any active dnd_zone_run row not linked to // an active expedition as a legacy `!adventure dungeon` orphan, which @@ -292,6 +298,7 @@ func (p *AdventurePlugin) Init() error { go p.mischiefTicker() go p.peteMischiefTicker() go p.peteEquipTicker() + go p.peteAdvOrderTicker() // Auto-cashout any arena runs left in 'awaiting' from a prior restart p.arenaCleanupStaleRuns() diff --git a/internal/plugin/adventure_babysit.go b/internal/plugin/adventure_babysit.go index 83e7047..8c48124 100644 --- a/internal/plugin/adventure_babysit.go +++ b/internal/plugin/adventure_babysit.go @@ -1,6 +1,7 @@ package plugin import ( + "errors" "fmt" "log/slog" "strings" @@ -100,33 +101,91 @@ func (p *AdventurePlugin) handleBabysitCmd(ctx MessageContext, args string) erro } } -func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) error { - userMu := p.advUserLock(ctx.Sender) +// Sentinels for the ways hiring a sitter can be refused, so the web action queue +// (pete_orders.go) can pick a verdict without parsing prose. Each is returned +// inside an advRefusal carrying the finished sentence, so `!adventure babysit` +// keeps the copy it always sent. +var ( + errBabysitNoCharacter = errors.New("babysit: no adventurer") + errBabysitActive = errors.New("babysit: a sitter is already engaged") + errBabysitDead = errors.New("babysit: adventurer is dead") + errBabysitBroke = errors.New("babysit: cannot cover the fee") + errBabysitFailed = errors.New("babysit: could not engage a sitter") +) + +// babysitOutcome is what hiring did, for a caller describing it somewhere other +// than a DM. +type babysitOutcome struct { + Days int + Cost int + PetName string + PetLine string + Confirm string +} + +// performBabysitPurchase is `!adventure babysit week|month` minus the command +// framing. Shared with the web action queue so hiring a sitter from a phone +// engages the same one, on the same clock, with the same log reset. +// +// idemKey, when set, is the web order's guid and moves the fee onto DebitIdem so +// a re-offered order cannot charge twice. +func (p *AdventurePlugin) performBabysitPurchase(uid id.UserID, days int, idemKey string) (babysitOutcome, error) { + userMu := p.advUserLock(uid) userMu.Lock() defer userMu.Unlock() - char, err := loadAdvCharacter(ctx.Sender) + char, err := loadAdvCharacter(uid) if err != nil { - return p.SendDM(ctx.Sender, "No adventurer found. Type `!adventure` to create one.") + return babysitOutcome{}, refuseAdv(errBabysitNoCharacter, "No adventurer found. Type `!adventure` to create one.") } if char.BabysitActive { - return p.SendDM(ctx.Sender, "🍼 The babysitter is already here. They're not leaving until the job is done.") + // A web order that already paid and already engaged the sitter lands here + // on the re-offer. The settled fee is what tells that apart from somebody + // who really does already have one. + if idemKey != "" && p.euro != nil && p.euro.HasExternalTx(idemKey) { + // Re-quote the fee rather than leaving it zero: the verdict this + // feeds prints the coin figure, and "0 coins" would be a false + // receipt for a hire the player did pay for. + return babysitOutcome{ + Days: days, + Cost: babysitDailyCost(dndLevelForUser(char.UserID)) * days, + PetName: char.PetName, + }, nil + } + return babysitOutcome{}, refuseAdv(errBabysitActive, "🍼 The babysitter is already here. They're not leaving until the job is done.") } if !char.Alive { - return p.SendDM(ctx.Sender, "Your adventurer is dead. The babysitter does not work with corpses.") + return babysitOutcome{}, refuseAdv(errBabysitDead, "Your adventurer is dead. The babysitter does not work with corpses.") } daily := babysitDailyCost(dndLevelForUser(char.UserID)) totalCost := daily * days - balance := p.euro.GetBalance(char.UserID) - if balance < float64(totalCost) { - return p.SendDM(ctx.Sender, fmt.Sprintf("🍼 The babysitting service costs %s for %d days. You have %s. The service has standards. Not many, but some.", fmtEuro(totalCost), days, fmtEuro(balance))) + if p.euro == nil { + return babysitOutcome{}, refuseAdv(errBabysitFailed, "Coin system unavailable — try again later.") + } + // Skip the affordability gate on a re-offer that already paid: the fee is a + // settled fact, and re-reading the now-lower balance would bounce a sitter the + // player has bought. + if !(idemKey != "" && p.euro.HasExternalTx(idemKey)) { + balance := p.euro.GetBalance(char.UserID) + if balance < float64(totalCost) { + return babysitOutcome{}, refuseAdv(errBabysitBroke, + "🍼 The babysitting service costs %s for %d days. You have %s. The service has standards. Not many, but some.", + fmtEuro(totalCost), days, fmtEuro(balance)) + } } - if !p.euro.Debit(char.UserID, float64(totalCost), "babysit_purchase") { - return p.SendDM(ctx.Sender, "Payment failed. The babysitter looked at your wallet and walked away.") + debited := false + if idemKey != "" { + ok, _, err := p.euro.DebitIdem(char.UserID, float64(totalCost), "babysit_purchase", idemKey) + debited = err == nil && ok + } else { + debited = p.euro.Debit(char.UserID, float64(totalCost), "babysit_purchase") + } + if !debited { + return babysitOutcome{}, refuseAdv(errBabysitFailed, "Payment failed. The babysitter looked at your wallet and walked away.") } clearBabysitLogs(char.UserID) @@ -138,23 +197,40 @@ func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) er if err := saveAdvCharacter(char); err != nil { slog.Error("babysit: failed to save character", "user", char.UserID, "err", err) - p.euro.Credit(char.UserID, float64(totalCost), "babysit_refund") - return p.SendDM(ctx.Sender, "Something went wrong activating the service. Your gold has been refunded.") + if idemKey != "" { + if _, _, err := p.euro.CreditIdem(char.UserID, float64(totalCost), "babysit_refund", idemKey+":refund"); err != nil { + slog.Error("babysit: refund failed", "user", char.UserID, "order", idemKey, "err", err) + } + } else { + p.euro.Credit(char.UserID, float64(totalCost), "babysit_refund") + } + return babysitOutcome{}, refuseAdv(errBabysitFailed, "Something went wrong activating the service. Your gold has been refunded.") } if err := upsertPlayerMetaBabysitState(char.UserID, babysitStateFromAdvChar(char)); err != nil { slog.Error("player_meta: babysit start dual-write failed", "user", char.UserID, "err", err) } - confirm := pickBabysitFlavor(babysitConfirmLines) - durLabel := "1 week" - if days == 30 { - durLabel = "1 month" - } - petLine := "No pet to tend yet — the babysitter will keep that in mind." if char.HasPet() { petLine = fmt.Sprintf("Pet: %s (L%d) — daily care included", char.PetName, char.PetLevel) } + return babysitOutcome{ + Days: days, Cost: totalCost, PetName: char.PetName, PetLine: petLine, + Confirm: pickBabysitFlavor(babysitConfirmLines), + }, nil +} + +// handleBabysitPurchase is the command framing around performBabysitPurchase. +func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) error { + out, err := p.performBabysitPurchase(ctx.Sender, days, "") + if err != nil { + return p.SendDM(ctx.Sender, err.Error()) + } + + durLabel := "1 week" + if days == 30 { + durLabel = "1 month" + } text := fmt.Sprintf("🍼 **Adventurer Babysitting Service — Activated**\n\n"+ "Duration: %s (%d days)\n"+ @@ -162,7 +238,7 @@ func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) er "%s\n"+ "Camp safety: standard camps now rest like fortified ones\n"+ "Rival duels: declined on your behalf\n\n"+ - "_%s_", durLabel, days, totalCost, petLine, confirm) + "_%s_", durLabel, days, out.Cost, out.PetLine, out.Confirm) return p.SendDM(ctx.Sender, text) } @@ -209,25 +285,49 @@ func (p *AdventurePlugin) handleBabysitStatus(ctx MessageContext) error { return p.SendDM(ctx.Sender, text) } -func (p *AdventurePlugin) handleBabysitCancel(ctx MessageContext) error { - userMu := p.advUserLock(ctx.Sender) +// babysitCancelOutcome is what dismissing the sitter did. Summary is the Matrix +// block of what they got through while they were here — a paragraph of counts, +// which is right under a DM and too much for a one-line web verdict, so the +// caller decides whether to print it. +type babysitCancelOutcome struct { + Summary string + PetName string +} + +// errBabysitNoSitter is the one way cancelling can be refused. There is no +// "already cancelled" race to worry about: the check and the write are both under +// the per-user lock this takes. +var errBabysitNoSitter = errors.New("babysit: no sitter to dismiss") + +// performBabysitCancel is `!adventure babysit cancel` minus the command framing. +// Shared with the web action queue. +// +// This one TAKES the per-user lock, unlike the abandon/leave twins beside it in +// the order path — because its Matrix caller does not hold it (handleBabysitCmd +// dispatches straight here, where `!expedition` holds the lock across its whole +// switch). Its web wrapper must therefore NOT take it. The asymmetry is per verb +// and is worth checking against the Matrix caller every time one is added. +// +// No refund, by design: the sitter was already here. +func (p *AdventurePlugin) performBabysitCancel(uid id.UserID) (babysitCancelOutcome, error) { + userMu := p.advUserLock(uid) userMu.Lock() defer userMu.Unlock() - char, err := loadAdvCharacter(ctx.Sender) + char, err := loadAdvCharacter(uid) if err != nil { - return p.SendDM(ctx.Sender, "No adventurer found.") + return babysitCancelOutcome{}, refuseAdv(errBabysitNoCharacter, "No adventurer found.") } if !char.BabysitActive { - return p.SendDM(ctx.Sender, "🍼 There's nothing to cancel. The babysitter isn't here.") + return babysitCancelOutcome{}, refuseAdv(errBabysitNoSitter, "🍼 There's nothing to cancel. The babysitter isn't here.") } logs, err := loadBabysitLogs(char.UserID) if err != nil { slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err) } - summary := renderBabysitSummary(char, logs) + out := babysitCancelOutcome{Summary: renderBabysitSummary(char, logs), PetName: char.PetName} char.BabysitActive = false char.BabysitExpiresAt = nil @@ -239,7 +339,15 @@ func (p *AdventurePlugin) handleBabysitCancel(ctx MessageContext) error { slog.Error("player_meta: babysit cancel dual-write failed", "user", char.UserID, "err", err) } - return p.SendDM(ctx.Sender, "🍼 Service cancelled. No refund. The babysitter was already there.\n\n"+summary) + return out, nil +} + +func (p *AdventurePlugin) handleBabysitCancel(ctx MessageContext) error { + out, err := p.performBabysitCancel(ctx.Sender) + if err != nil { + return p.SendDM(ctx.Sender, err.Error()) + } + return p.SendDM(ctx.Sender, "🍼 Service cancelled. No refund. The babysitter was already there.\n\n"+out.Summary) } // ── Expiry Check ──────────────────────────────────────────────────────────── diff --git a/internal/plugin/adventure_pets.go b/internal/plugin/adventure_pets.go index f661e2f..03ecc46 100644 --- a/internal/plugin/adventure_pets.go +++ b/internal/plugin/adventure_pets.go @@ -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 diff --git a/internal/plugin/adventure_worldboss.go b/internal/plugin/adventure_worldboss.go index 3eda294..b634126 100644 --- a/internal/plugin/adventure_worldboss.go +++ b/internal/plugin/adventure_worldboss.go @@ -2,6 +2,7 @@ package plugin import ( "database/sql" + "errors" "fmt" "hash/fnv" "log/slog" @@ -394,6 +395,7 @@ func (p *AdventurePlugin) spawnWorldBoss(eventKey string) (*worldBossState, erro return nil, err } p.announceWorldBossSpawn(boss, activeN) + emitSiegeStart(boss) slog.Info("worldboss: spawned", "id", bossID, "name", name, "tier", tier, "hp", hpMax, "activeN", activeN) return boss, nil } @@ -496,6 +498,7 @@ func (p *AdventurePlugin) resolveWorldBossDefeated(boss *worldBossState) { } } p.announceWorldBossDefeated(boss, payouts) + emitSiegeWin(boss, len(payouts)) slog.Info("worldboss: defeated", "id", boss.ID, "contributors", len(payouts)) } @@ -516,6 +519,7 @@ func (p *AdventurePlugin) resolveWorldBossSurvived(boss *worldBossState) { paid = tribute } p.announceWorldBossSurvived(boss, paid) + emitSiegeLoss(boss) slog.Info("worldboss: survived", "id", boss.ID, "tribute", paid) } @@ -604,42 +608,58 @@ func (p *AdventurePlugin) worldBossOperatorSpawn(ctx MessageContext) error { boss.Name, boss.Tier, groupInt(boss.HPMax))) } -// fightWorldBoss runs one player's daily bout against the Siege: an arena-style +// Sentinels for the four ways a bout can be refused, so a headless caller (the +// web action queue, pete_orders.go) can turn each into its own verdict instead of +// parsing a DM. `!adventure worldboss fight` maps them back to the prose it +// always sent. +var ( + errSiegeNoBoss = errors.New("siege: nothing camped outside town") + errSiegeNoCharacter = errors.New("siege: no adventurer") + errSiegeDead = errors.New("siege: adventurer is dead") + errSiegeAlreadyFought = errors.New("siege: today's bout already spent") +) + +// takeSiegeBout runs one player's daily bout against the Siege: an arena-style // solo fight whose damage is subtracted from the shared pool win or lose. Real // HP cost, no death — a loss leaves the fighter battered (floored at 1 HP) but // standing. The per-user lock serialises a player's own repeat submits, so the -// once-per-day gate can't be raced by a double-tap. -func (p *AdventurePlugin) fightWorldBoss(ctx MessageContext) error { - userMu := p.advUserLock(ctx.Sender) +// once-per-day gate can't be raced by a double-tap — and that same lock is what +// makes it safe for the web queue and a Matrix command to reach for the bout at +// the same moment. +// +// The combat narration is DM'd from here whichever door the bout came through: a +// fight is thirty lines of blow-by-blow and belongs in Matrix, not in a one-line +// verdict on a web page. The caller gets the boss and the result to describe. +func (p *AdventurePlugin) takeSiegeBout(uid id.UserID) (worldBossBoutResult, *worldBossState, error) { + userMu := p.advUserLock(uid) userMu.Lock() defer userMu.Unlock() boss, err := loadActiveWorldBoss() if err != nil { - return p.SendDM(ctx.Sender, "Something went wrong reaching the Siege. Try again in a moment.") + return worldBossBoutResult{}, nil, fmt.Errorf("reaching the Siege: %w", err) } if boss == nil { - return p.SendDM(ctx.Sender, "No Siege is camped outside town right now.") + return worldBossBoutResult{}, nil, errSiegeNoBoss } - char, err := loadAdvCharacter(ctx.Sender) + char, err := loadAdvCharacter(uid) if err != nil || char == nil { - return p.SendDM(ctx.Sender, "You need an adventurer first — type `!adventure` to begin.") + return worldBossBoutResult{}, boss, errSiegeNoCharacter } if !char.Alive { - return p.SendDM(ctx.Sender, "You're dead. The Siege will have to wait until you're back on your feet.") + return worldBossBoutResult{}, boss, errSiegeDead } today := time.Now().UTC().Format("2006-01-02") - if worldBossBoutUsedToday(boss.ID, ctx.Sender, today) { - return p.SendDM(ctx.Sender, fmt.Sprintf( - "You've already taken your bout against **%s** today. Come back tomorrow — one fight per day.", boss.Name)) + if worldBossBoutUsedToday(boss.ID, uid, today) { + return worldBossBoutResult{}, boss, errSiegeAlreadyFought } - bout, err := p.resolveWorldBossBout(ctx.Sender, boss, today) + bout, err := p.resolveWorldBossBout(uid, boss, today) if err != nil { - slog.Error("worldboss: bout failed", "user", ctx.Sender, "err", err) - return p.SendDM(ctx.Sender, "The Siege combat hit an error. Try again in a moment.") + slog.Error("worldboss: bout failed", "user", uid, "err", err) + return worldBossBoutResult{}, boss, fmt.Errorf("running the bout: %w", err) } // Resolve a defeat BEFORE streaming the (multi-second) narration. The pool is @@ -650,7 +670,7 @@ func (p *AdventurePlugin) fightWorldBoss(ctx MessageContext) error { p.resolveWorldBossDefeated(boss) } - playerName, _ := loadDisplayName(ctx.Sender) + playerName, _ := loadDisplayName(uid) if playerName == "" { playerName = "You" } @@ -658,18 +678,45 @@ func (p *AdventurePlugin) fightWorldBoss(ctx MessageContext) error { fmt.Sprintf("⚔️ **The Siege — %s** (Tier %d)", boss.Name, boss.Tier), }, RenderCombatLog(bout.Combat, playerName, boss.Name)...) + <-p.sendZoneCombatMessages(uid, phaseMessages, siegeBoutFooter(bout, boss)) + return bout, boss, nil +} + +// siegeBoutFooter is the one-line result of a bout — the damage dealt and what +// the pool looks like now. It closes the Matrix narration and doubles as the web +// verdict, so the two doors can't drift into describing the same fight +// differently. +func siegeBoutFooter(bout worldBossBoutResult, boss *worldBossState) string { var footer string if bout.Killed { - footer = fmt.Sprintf("💥 You deal **%d** damage — the killing blow! **%s** falls!", bout.Damage, boss.Name) + footer = fmt.Sprintf("💥 You deal **%d** damage: the killing blow! **%s** falls!", bout.Damage, boss.Name) } else { footer = fmt.Sprintf("💥 You deal **%d** damage. **%s** has **%s / %s HP** left.", bout.Damage, boss.Name, groupInt(bout.Remaining), groupInt(boss.HPMax)) } if bout.Battered { - footer += "\nYou stagger out of the fight at 1 HP — rest up before your next outing." + footer += "\nYou stagger out of the fight at 1 HP. Rest up before your next outing." } + return footer +} - <-p.sendZoneCombatMessages(ctx.Sender, phaseMessages, footer) +// fightWorldBoss is `!adventure worldboss fight`: the command framing around +// takeSiegeBout, which does the fight and the narration. +func (p *AdventurePlugin) fightWorldBoss(ctx MessageContext) error { + _, boss, err := p.takeSiegeBout(ctx.Sender) + switch { + case errors.Is(err, errSiegeNoBoss): + return p.SendDM(ctx.Sender, "No Siege is camped outside town right now.") + case errors.Is(err, errSiegeNoCharacter): + return p.SendDM(ctx.Sender, "You need an adventurer first — type `!adventure` to begin.") + case errors.Is(err, errSiegeDead): + return p.SendDM(ctx.Sender, "You're dead. The Siege will have to wait until you're back on your feet.") + case errors.Is(err, errSiegeAlreadyFought): + return p.SendDM(ctx.Sender, fmt.Sprintf( + "You've already taken your bout against **%s** today. Come back tomorrow — one fight per day.", boss.Name)) + case err != nil: + return p.SendDM(ctx.Sender, "Something went wrong reaching the Siege. Try again in a moment.") + } return nil } diff --git a/internal/plugin/bootstrap_pete_news.go b/internal/plugin/bootstrap_pete_news.go index baa19f8..20e83b3 100644 --- a/internal/plugin/bootstrap_pete_news.go +++ b/internal/plugin/bootstrap_pete_news.go @@ -58,34 +58,119 @@ func (p *AdventurePlugin) bootstrapPeteNewsBackfill() { "zone_firsts", firsts, "deaths", deaths, "achievements", achv) } -// backfillZoneFirsts seeds news_realm_firsts from history and emits one PRIORITY -// realm-first dispatch per zone, attributed to its earliest boss-defeating -// clearer. SQLite returns the user_id from the same row as MIN(completed_at) -// (bare-column min/max rule), so the (zone, first clearer, time) triple is -// consistent. Returns the count emitted. -func (p *AdventurePlugin) backfillZoneFirsts() int { +// zoneFirstClear is one zone's earliest boss kill: who did it and when. +type zoneFirstClear struct { + zoneID, userID, completedAt string +} + +// zoneFirstClears reads the earliest boss-defeating run of every zone. +// +// The filter is `boss_defeated = 1` and NOTHING else, and that is the whole +// point. `abandoned` does not mean anybody gave up — abandonZoneRunByID exists +// to retire a run whose boss is ALREADY DEAD when the expedition travels onward +// (dnd_zone_run.go), so in prod 30 of 32 boss kills carry abandoned = 1. An +// `AND abandoned = 0` here drew a realm where 2 zones had ever been beaten +// instead of 9. It is the same filter loadRealmClearStats documents; do not +// reintroduce it. +// +// SQLite returns the user_id from the same row as MIN(completed_at) (bare-column +// min/max rule), so the (zone, first clearer, time) triple is internally +// consistent. +// ok is false only when the read itself failed. A caller that is about to mark +// a one-shot job complete has to be able to tell "no zone has ever been cleared" +// from "the query fell over", or a transient DB fault at boot retires the repair +// permanently. +func zoneFirstClears() (firsts []zoneFirstClear, ok bool) { rows, err := db.Get().Query( `SELECT zone_id, user_id, MIN(completed_at) FROM dnd_zone_run - WHERE boss_defeated = 1 AND completed_at IS NOT NULL AND abandoned = 0 + WHERE boss_defeated = 1 AND completed_at IS NOT NULL GROUP BY zone_id`) if err != nil { slog.Error("backfill: zone-firsts query", "err", err) - return 0 + return nil, false } - type first struct { - zoneID, userID, completedAt string - } - var firsts []first + defer rows.Close() + for rows.Next() { - var f first + var f zoneFirstClear if err := rows.Scan(&f.zoneID, &f.userID, &f.completedAt); err != nil { slog.Error("backfill: zone-firsts scan", "err", err) continue } firsts = append(firsts, f) } - rows.Close() + if err := rows.Err(); err != nil { + slog.Error("backfill: zone-firsts rows", "err", err) + return nil, false + } + return firsts, true +} + +// bootstrapRealmFirstsReseed repairs the zone half of news_realm_firsts. +// +// The ledger is what claimRealmFirst tiers live dispatches against, so a zone +// whose first clear the original one-shot missed is a spurious PRIORITY "realm +// first" waiting to fire the next time somebody clears it, months after the +// fact. Two things were wrong with what got seeded: +// +// 1. The `abandoned = 0` filter above, which is why prod holds 6 zones where +// the run history knows 9. +// 2. first_at is claimRealmFirst's unixepoch() — when the claim was RECORDED, +// not when the clear happened. Every backfilled prod row carries the one +// minute the job ran. +// +// This is a re-SEED, not a re-run: it writes the ledger and emits nothing at +// all, so no historical realm-first dispatch reaches the room. It has its own +// job name because the original one-shot's gate is already marked, and per +// feedback_loader_rewire_needs_bootstrap it stays in place afterwards — a fresh +// deploy runs it as an ordinary bootstrap. +// +// It runs unconditionally on the news seam's switches, unlike the backfill: a +// ledger that is correct only when emission happens to be on is a ledger that +// mis-tiers the first dispatch after somebody flips it. +// +// A zone claim with no surviving run behind it is left exactly as it is. The run +// history is the better record of both who and when, but only where it has one. +func bootstrapRealmFirstsReseed() { + const jobName = "pete_realm_firsts_reseed_v1" + if db.JobCompleted(jobName, "once") { + return + } + + clears, ok := zoneFirstClears() + if !ok { + // The read failed. Leave the job unmarked so the next boot tries again — + // marking it here would retire the repair on the strength of a transient + // DB fault and leave the ledger wrong forever. + return + } + + seeded := 0 + for _, f := range clears { + ts, ok := parseSQLiteTime(f.completedAt) + if !ok { + slog.Warn("reseed: unparseable clear time", "zone", f.zoneID, "at", f.completedAt) + continue + } + // Upsert, not INSERT OR IGNORE: the six rows that already exist carry the + // wrong date and correcting them is half of what this job is for. + db.Exec("realm-firsts reseed", + `INSERT INTO news_realm_firsts (kind, target, first_at) VALUES ('zone', ?, ?) + ON CONFLICT(kind, target) DO UPDATE SET first_at = excluded.first_at`, + f.zoneID, ts.Unix()) + seeded++ + } + + db.MarkJobCompleted(jobName, "once") + slog.Warn("bootstrap: realm-firsts ledger reseeded", "zones", seeded) +} + +// backfillZoneFirsts seeds news_realm_firsts from history and emits one +// dispatch per zone, attributed to its earliest boss-defeating clearer. +// Returns the count emitted. +func (p *AdventurePlugin) backfillZoneFirsts() int { + firsts, _ := zoneFirstClears() n := 0 for _, f := range firsts { diff --git a/internal/plugin/bootstrap_realm_firsts_test.go b/internal/plugin/bootstrap_realm_firsts_test.go new file mode 100644 index 0000000..662c685 --- /dev/null +++ b/internal/plugin/bootstrap_realm_firsts_test.go @@ -0,0 +1,160 @@ +package plugin + +import ( + "testing" + "time" + + "gogobee/internal/db" +) + +// ledgerFirstAt reads a zone's recorded claim time, or -1 if the ledger has no +// row for it at all. +func ledgerFirstAt(t *testing.T, zoneID string) int64 { + t.Helper() + var at int64 + err := db.Get().QueryRow( + `SELECT first_at FROM news_realm_firsts WHERE kind = 'zone' AND target = ?`, + zoneID).Scan(&at) + if err != nil { + return -1 + } + return at +} + +func unixOf(t *testing.T, sqliteTime string) int64 { + t.Helper() + ts, ok := parseSQLiteTime(sqliteTime) + if !ok { + t.Fatalf("parseSQLiteTime(%q) failed", sqliteTime) + } + return ts.Unix() +} + +// TestReseedClaimsAZoneWhoseClearsWereAllRetired is the regression for the bug +// that made this job necessary: every clear of forest_shadows carries +// abandoned = 1, which is how the game stores a kill the expedition walked on +// from, and the original one-shot's `abandoned = 0` filter therefore never saw +// the zone at all. An unclaimed zone is a spurious PRIORITY "realm first" +// waiting to fire the next time somebody clears it, months after the fact — so +// the assertion that matters is the claimRealmFirst one at the end. +func TestReseedClaimsAZoneWhoseClearsWereAllRetired(t *testing.T) { + seedRealmFixture(t) + + db.Exec("seed retired-only zone", `INSERT INTO dnd_zone_run + (run_id, user_id, zone_id, total_rooms, boss_defeated, abandoned, completed_at) + VALUES ('r7', '@josie:x', 'forest_shadows', 6, 1, 1, '2026-02-14 09:00:00')`) + + if got := ledgerFirstAt(t, "forest_shadows"); got != -1 { + t.Fatalf("forest_shadows already claimed before the reseed (first_at=%d) — fixture drift", got) + } + + bootstrapRealmFirstsReseed() + + if got := ledgerFirstAt(t, "forest_shadows"); got != unixOf(t, "2026-02-14 09:00:00") { + t.Errorf("forest_shadows first_at = %d, want %d (the real clear, not the minute the job ran)", + got, unixOf(t, "2026-02-14 09:00:00")) + } + // The point of the whole job. Before the reseed this returns true and the + // next clear of a zone beaten in February announces itself as a realm first. + if claimRealmFirst("zone", "forest_shadows") { + t.Error("forest_shadows was still unclaimed after the reseed — the next clear would fire a spurious realm-first") + } +} + +// TestReseedCorrectsTheBackfillsDates pins the second half. claimRealmFirst +// stamps unixepoch(), so every row the original one-shot wrote carries the one +// minute that job ran — in prod, all six share the identical timestamp. The +// reseed has to overwrite an existing row, not INSERT OR IGNORE past it. +func TestReseedCorrectsTheBackfillsDates(t *testing.T) { + seedRealmFixture(t) + + // The fixture claims both zones the way the backfill did: at claim time. + before := ledgerFirstAt(t, "goblin_warrens") + if before < time.Now().Unix()-300 { + t.Fatalf("fixture claim for goblin_warrens is not a now-stamp (%d) — fixture drift", before) + } + + bootstrapRealmFirstsReseed() + + // Josie's r1, January, not r2 or r3 and not today. + if got, want := ledgerFirstAt(t, "goblin_warrens"), unixOf(t, "2026-01-10 12:00:00"); got != want { + t.Errorf("goblin_warrens first_at = %d, want %d (earliest real clear)", got, want) + } + // crypt_valdris has a clean clear (r4) and a retired-but-won one (r5). The + // earliest is r4. + if got, want := ledgerFirstAt(t, "crypt_valdris"), unixOf(t, "2026-05-01 12:00:00"); got != want { + t.Errorf("crypt_valdris first_at = %d, want %d", got, want) + } +} + +// TestReseedEmitsNothing is the reason this is a re-seed and not a re-run of the +// backfill. The ledger has to be repaired without any historical realm-first +// dispatch reaching the room; a zone beaten in February is not news in July. +func TestReseedEmitsNothing(t *testing.T) { + seedRealmFixture(t) + db.Exec("seed retired-only zone", `INSERT INTO dnd_zone_run + (run_id, user_id, zone_id, total_rooms, boss_defeated, abandoned, completed_at) + VALUES ('r7', '@josie:x', 'forest_shadows', 6, 1, 1, '2026-02-14 09:00:00')`) + + bootstrapRealmFirstsReseed() + + var queued int + if err := db.Get().QueryRow(`SELECT COUNT(*) FROM pete_emit_queue`).Scan(&queued); err != nil { + t.Fatalf("count pete_emit_queue: %v", err) + } + if queued != 0 { + t.Errorf("reseed queued %d dispatches, want 0 — the ledger repair must be silent", queued) + } +} + +// TestReseedIsAOneShot. It is a bootstrap kept in place for fresh deploys (per +// feedback_loader_rewire_needs_bootstrap), so it runs on every start and must +// cost nothing after the first — and, more importantly, must not undo a +// later live claim by rewriting the ledger from stale history on every boot. +func TestReseedIsAOneShot(t *testing.T) { + seedRealmFixture(t) + bootstrapRealmFirstsReseed() + + // A zone cleared after the reseed, claimed live. + if !claimRealmFirst("zone", "sunken_temple") { + t.Fatal("sunken_temple should have been an unclaimed realm-first") + } + live := ledgerFirstAt(t, "sunken_temple") + + bootstrapRealmFirstsReseed() + + if got := ledgerFirstAt(t, "sunken_temple"); got != live { + t.Errorf("second reseed moved a live claim: %d -> %d", live, got) + } + if claimRealmFirst("zone", "goblin_warrens") { + t.Error("second reseed dropped an existing claim") + } +} + +// TestZoneFirstClearsCountsRetiredKills guards the shared query itself, which +// the kept backfill also uses. `abandoned` means the run row was retired, not +// that anybody gave up. +func TestZoneFirstClearsCountsRetiredKills(t *testing.T) { + seedRealmFixture(t) + db.Exec("seed retired-only zone", `INSERT INTO dnd_zone_run + (run_id, user_id, zone_id, total_rooms, boss_defeated, abandoned, completed_at) + VALUES ('r7', '@josie:x', 'forest_shadows', 6, 1, 1, '2026-02-14 09:00:00')`) + + clears, ok := zoneFirstClears() + if !ok { + t.Fatal("zoneFirstClears reported a read failure") + } + byZone := map[string]zoneFirstClear{} + for _, f := range clears { + byZone[f.zoneID] = f + } + if len(byZone) != 3 { + t.Fatalf("zoneFirstClears returned %d zones, want 3 (a regression to `abandoned = 0` gives 2)", len(byZone)) + } + if got := byZone["forest_shadows"].userID; got != "@josie:x" { + t.Errorf("forest_shadows first clearer = %q, want @josie:x", got) + } + if _, ok := byZone["arena"]; ok { + t.Error("an unfinished run counted as a clear") + } +} diff --git a/internal/plugin/dnd_combat.go b/internal/plugin/dnd_combat.go index c2c38fb..578a245 100644 --- a/internal/plugin/dnd_combat.go +++ b/internal/plugin/dnd_combat.go @@ -607,6 +607,7 @@ func emitDeathNews(userID id.UserID, location string) { Zone: location, Level: lvl, Outcome: "lost", + RunID: latestRunIDForNews(userID), OccurredAt: ts, }, userID, "") } @@ -655,6 +656,7 @@ func emitRetreatNews(userID id.UserID, reason string, zoneID ZoneID, day int) { Level: charLevel(userID), Count: day, // the day they got to before it fell apart Outcome: "retreated", + RunID: latestRunIDForNews(userID), OccurredAt: ts, }, userID, "") } diff --git a/internal/plugin/dnd_expedition_cmd.go b/internal/plugin/dnd_expedition_cmd.go index 07f8d88..239f464 100644 --- a/internal/plugin/dnd_expedition_cmd.go +++ b/internal/plugin/dnd_expedition_cmd.go @@ -1,6 +1,7 @@ package plugin import ( + "errors" "fmt" "log/slog" "math" @@ -32,6 +33,23 @@ import ( // in E1e. !advance / !search / !rest / !extract are out-of-scope for E1. func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string) error { + args = strings.TrimSpace(args) + sub, rest := splitFirstWord(args) + + // Two subcommands are aliases for top-level commands that take the per-user + // lock themselves. Dispatch them BEFORE we take it: advUserLock is a plain + // sync.Mutex, so grabbing it here and again in there does not merely block — + // it wedges the lock forever, because the deferred Unlock below never runs. + // Every later `!adventure` / `!expedition` / `!zone` command from that player + // then hangs too. Both aliases load their own character, so nothing below is + // being skipped. + switch strings.ToLower(sub) { + case "extract": + return p.handleExtractCmd(ctx, "") + case "resume": + return p.handleResumeCmd(ctx, rest) + } + userMu := p.advUserLock(ctx.Sender) userMu.Lock() defer userMu.Unlock() @@ -45,8 +63,6 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string "No Adv 2.0 character yet — run `!setup` (or just enter combat and we'll auto-build one).") } - args = strings.TrimSpace(args) - sub, rest := splitFirstWord(args) switch strings.ToLower(sub) { case "": // If active, show status; otherwise help. A party member is on an @@ -100,10 +116,6 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string return p.expeditionCmdHire(ctx, rest) case "dismiss": return p.expeditionCmdDismiss(ctx) - case "extract": - return p.handleExtractCmd(ctx, "") - case "resume": - return p.handleResumeCmd(ctx, rest) case "map", "m": return p.handleExpeditionMapCmd(ctx, "") case "run", "explore", "advance": @@ -332,83 +344,14 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter if err != nil { return p.SendDM(ctx.Sender, "Couldn't parse supply packs: "+err.Error()) } - if err := purchase.Validate(zoneForCaps.Tier); err != nil { - return p.SendDM(ctx.Sender, "Invalid pack selection: "+err.Error()) - } - // Reject if any expedition or zone run already active. This runs before the - // price quote: a player who cannot leave doesn't need to hear what leaving - // would have cost. - // - // The seat check spans `extracting` as well as `active` — a member of an - // extracting party is still seated for the seven-day resume window, and - // letting them outfit a rival expedition double-books them the moment their - // leader types `!resume`. - if seated, _ := seatedExpeditionFor(ctx.Sender); seated != nil { - zone, _ := getZone(seated.ZoneID) - return p.SendDM(ctx.Sender, fmt.Sprintf( - "You're riding a party expedition in **%s** (Day %d). `!expedition leave` before starting your own.", - zone.Display, seated.CurrentDay)) - } - if existing, _ := getActiveExpedition(ctx.Sender); existing != nil { - zone, _ := getZone(existing.ZoneID) - return p.SendDM(ctx.Sender, fmt.Sprintf( - "You're already on expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.", - zone.Display, existing.CurrentDay)) - } - // A leader who extracted still holds their roster for the resume window, and - // `!resume` only ever reaches the *newest* extracted row. Starting fresh on - // top of one would orphan it: unreachable, un-reapable until the sweeper - // catches it, with every member still seated and refused a run of their own. - // - // Only a row with a roster blocks. A solo extraction strands nobody, so - // walking away from it stays a normal thing to do. - if pending, _ := getResumableExpedition(ctx.Sender); pending != nil { - switch { - case extractionLapsed(pending, time.Now().UTC()): - // Past the window — reap it here rather than make them wait an hour - // for the sweeper, and let the new expedition proceed. Route through - // the shared reap so the freed members hear about it, same as the - // sweeper and `!expedition abandon` do. - if err := p.reapLapsedExtraction(pending); err != nil { - slog.Warn("expedition: reap lapsed on start", "expedition", pending.ID, "err", err) - } - default: - // A roster still holds; block. On a roster-read error, assume it is - // occupied and refuse — proceeding would orphan a party we could not - // confirm was empty, the one outcome this guard exists to prevent. A - // solo extraction (n == 1) strands nobody, so walking away is fine. - n, err := partySize(pending.ID) - if err != nil || n > 1 { - zone, _ := getZone(pending.ZoneID) - return p.SendDM(ctx.Sender, fmt.Sprintf( - "You extracted from **%s** on Day %d and your party is still waiting on you. `!resume` to lead them back in, or `!expedition abandon` to let it go — until you do one or the other, none of them can start a run of their own.", - zone.Display, pending.CurrentDay)) - } - } - } - cost := float64(purchase.Cost()) - if p.euro == nil { - return p.SendDM(ctx.Sender, "Coin system unavailable — try again later.") - } - if balance := p.euro.GetBalance(ctx.Sender); balance < cost { - return p.SendDM(ctx.Sender, fmt.Sprintf( - "Not enough coins. Outfitting costs **%d** but you have **%.0f**.", - int(cost), balance)) - } - if existing, _ := getActiveZoneRun(ctx.Sender); existing != nil { - zone, _ := getZone(existing.ZoneID) - return p.SendDM(ctx.Sender, fmt.Sprintf( - "You have an active single-session zone run in **%s**. Finish or `!zone abandon` before starting an expedition.", - zone.Display)) - } - - zone := zoneForCaps - _, supplies, startLine, err := p.beginExpedition(ctx.Sender, c.Level, zone, purchase, "expedition outfitting") + out, err := p.performExpeditionStart(ctx.Sender, c, zoneForCaps, purchase, "") if err != nil { + // Every refusal below carries its own finished sentence — see the sentinel + // block on performExpeditionStart — so the command only has to say it. return p.SendDM(ctx.Sender, err.Error()) } - markActedToday(ctx.Sender) + zone, supplies, startLine := out.Zone, out.Supplies, out.StartLine var b strings.Builder b.WriteString(fmt.Sprintf("🗺 **Expedition begins — %s** _(T%d)_\n\n", zone.Display, int(zone.Tier))) @@ -429,6 +372,182 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter return p.SendDM(ctx.Sender, b.String()) } +// ── the headless twin of `!expedition start` ──────────────────────────────── + +// Sentinels for the ways outfitting can be refused, so the web action queue +// (pete_orders.go) can pick a verdict without parsing prose. Every refusal is +// returned as an advRefusal, which wraps one of these AND carries the +// finished player-facing sentence — that is how `!expedition start` keeps the +// exact copy it always sent while the web gets a machine-readable answer. +var ( + errExpStartResting = errors.New("expedition start: still resting") + errExpStartZoneLocked = errors.New("expedition start: zone not available at this level") + errExpStartBadPacks = errors.New("expedition start: invalid pack selection") + errExpStartBusy = errors.New("expedition start: already adventuring") + errExpStartBroke = errors.New("expedition start: cannot cover outfitting") + errExpStartFailed = errors.New("expedition start: could not outfit") +) + +// advRefusal is a refusal that is both classifiable and quotable: errors.Is +// picks the verdict, Error() is the sentence the command has always sent. Shared +// by every headless twin in the web action family (start, resume, babysit). +type advRefusal struct { + kind error + msg string +} + +func (e advRefusal) Error() string { return e.msg } +func (e advRefusal) Unwrap() error { return e.kind } + +func refuseAdv(kind error, format string, args ...any) error { + return advRefusal{kind: kind, msg: fmt.Sprintf(format, args...)} +} + +// expStartOutcome is what outfitting did, for a caller describing it somewhere +// other than a DM. +type expStartOutcome struct { + Zone ZoneDefinition + Supplies ExpeditionSupplies + Cost int + Days int + StartLine string +} + +// performExpeditionStart is `!expedition start` minus the command framing: the +// eligibility guards, the price gate, the debit, and the expedition row. It is +// shared with the web action queue so that leaving town from a phone is the +// *same* departure — same guards, same supplies, same opening log line. +// +// idemKey, when set, is the web order's guid: the debit then goes through +// DebitIdem so a re-offered order that already paid cannot pay twice. The Matrix +// command passes "" and keeps the plain debit, which is right — a Matrix message +// arrives exactly once. +// +// LOCKING, and this is the one asymmetry in the headless-twin family: this +// function does NOT take the per-user lock. performExtraction, takeSiegeBout and +// performBabysitPurchase all take it themselves, because their command framings +// do not hold it — but handleDnDExpeditionCmd holds it across its whole switch, +// so taking it here would wedge advUserLock permanently (it is a plain +// sync.Mutex, and the deferred unlock up there would never run). The web caller +// takes it explicitly instead; see applyAdvOrder. +func (p *AdventurePlugin) performExpeditionStart(uid id.UserID, c *DnDCharacter, zone ZoneDefinition, purchase SupplyPurchase, idemKey string) (expStartOutcome, error) { + if remaining := restingLockoutRemaining(c); remaining > 0 { + return expStartOutcome{}, refuseAdv(errExpStartResting, + "🛌 You're still resting — %s remaining. Pack up after.", + formatRespecDuration(remaining)) + } + // Re-resolve availability against the game's own tables rather than trusting + // the caller. The web resolves a zone from an offer list gogobee itself + // pushed, but that snapshot can be minutes old and is not a permission. + if _, ok := resolveZoneInput(string(zone.ID), availableZonesFor(uid, c.Level)); !ok { + if reason := postgameLockReason(string(zone.ID), uid, c.Level); reason != "" { + return expStartOutcome{}, refuseAdv(errExpStartZoneLocked, "%s", reason) + } + return expStartOutcome{}, refuseAdv(errExpStartZoneLocked, + "Unknown zone for your level. Try `!expedition list`.") + } + if err := purchase.Validate(zone.Tier); err != nil { + return expStartOutcome{}, refuseAdv(errExpStartBadPacks, + "Invalid pack selection: %s", err.Error()) + } + // Reject if any expedition or zone run already active. This runs before the + // price quote: a player who cannot leave doesn't need to hear what leaving + // would have cost. + // + // The seat check spans `extracting` as well as `active` — a member of an + // extracting party is still seated for the seven-day resume window, and + // letting them outfit a rival expedition double-books them the moment their + // leader types `!resume`. + if seated, _ := seatedExpeditionFor(uid); seated != nil { + z, _ := getZone(seated.ZoneID) + return expStartOutcome{}, refuseAdv(errExpStartBusy, + "You're riding a party expedition in **%s** (Day %d). `!expedition leave` before starting your own.", + z.Display, seated.CurrentDay) + } + if existing, _ := getActiveExpedition(uid); existing != nil { + // A web order that already paid and already started this expedition on an + // earlier tick lands here on the re-offer. Saying "you're already on + // expedition" would be a rejection for the thing the order in fact did, so + // the settled debit is what tells the two apart. + if idemKey != "" && p.euro != nil && p.euro.HasExternalTx(idemKey) && existing.ZoneID == zone.ID { + z, _ := getZone(existing.ZoneID) + return expStartOutcome{Zone: z, Supplies: existing.Supplies, + Cost: purchase.Cost(), + Days: estimateDays(existing.Supplies.Max, existing.Supplies.DailyBurn)}, nil + } + z, _ := getZone(existing.ZoneID) + return expStartOutcome{}, refuseAdv(errExpStartBusy, + "You're already on expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.", + z.Display, existing.CurrentDay) + } + // A leader who extracted still holds their roster for the resume window, and + // `!resume` only ever reaches the *newest* extracted row. Starting fresh on + // top of one would orphan it: unreachable, un-reapable until the sweeper + // catches it, with every member still seated and refused a run of their own. + // + // Only a row with a roster blocks. A solo extraction strands nobody, so + // walking away from it stays a normal thing to do. + if pending, _ := getResumableExpedition(uid); pending != nil { + switch { + case extractionLapsed(pending, time.Now().UTC()): + // Past the window — reap it here rather than make them wait an hour + // for the sweeper, and let the new expedition proceed. Route through + // the shared reap so the freed members hear about it, same as the + // sweeper and `!expedition abandon` do. + if err := p.reapLapsedExtraction(pending); err != nil { + slog.Warn("expedition: reap lapsed on start", "expedition", pending.ID, "err", err) + } + default: + // A roster still holds; block. On a roster-read error, assume it is + // occupied and refuse — proceeding would orphan a party we could not + // confirm was empty, the one outcome this guard exists to prevent. A + // solo extraction (n == 1) strands nobody, so walking away is fine. + n, err := partySize(pending.ID) + if err != nil || n > 1 { + z, _ := getZone(pending.ZoneID) + return expStartOutcome{}, refuseAdv(errExpStartBusy, + "You extracted from **%s** on Day %d and your party is still waiting on you. `!resume` to lead them back in, or `!expedition abandon` to let it go — until you do one or the other, none of them can start a run of their own.", + z.Display, pending.CurrentDay) + } + } + } + + cost := float64(purchase.Cost()) + if p.euro == nil { + return expStartOutcome{}, refuseAdv(errExpStartFailed, "Coin system unavailable — try again later.") + } + // Skip the affordability gate on a re-offer that already paid: the debit is a + // settled fact and re-reading the now-lower balance would bounce a departure + // the player has bought. Same reasoning as purchaseEquipmentTier's. + if !(idemKey != "" && p.euro.HasExternalTx(idemKey)) { + if balance := p.euro.GetBalance(uid); balance < cost { + return expStartOutcome{}, refuseAdv(errExpStartBroke, + "Not enough coins. Outfitting costs **%d** but you have **%.0f**.", + int(cost), balance) + } + } + if existing, _ := getActiveZoneRun(uid); existing != nil { + z, _ := getZone(existing.ZoneID) + return expStartOutcome{}, refuseAdv(errExpStartBusy, + "You have an active single-session zone run in **%s**. Finish or `!zone abandon` before starting an expedition.", + z.Display) + } + + _, supplies, startLine, err := p.beginExpeditionIdem(uid, c.Level, zone, purchase, "expedition outfitting", idemKey) + if err != nil { + // beginExpedition refunds and tears down on every failure path, so the + // player owes nothing and this is permanent rather than retryable — a retry + // after a refund would find the guid-keyed debit already settled and hand + // them the expedition for free. + return expStartOutcome{}, refuseAdv(errExpStartFailed, "%s", err.Error()) + } + markActedToday(uid) + return expStartOutcome{ + Zone: zone, Supplies: supplies, Cost: purchase.Cost(), + Days: estimateDays(supplies.Max, supplies.DailyBurn), StartLine: startLine, + }, nil +} + // beginExpedition performs the non-interactive half of starting an expedition: // supply freebies, the coin debit, persistence, the starting region's run, and // the opening log entry. It refunds and tears down on every failure path, so a @@ -442,10 +561,38 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter // It deliberately does NOT call markActedToday — an expedition the player did // not ask for must not spend their daily action or count as them showing up. func (p *AdventurePlugin) beginExpedition(uid id.UserID, charLevel int, zone ZoneDefinition, purchase SupplyPurchase, reason string) (*Expedition, ExpeditionSupplies, string, error) { + return p.beginExpeditionIdem(uid, charLevel, zone, purchase, reason, "") +} + +// beginExpeditionIdem is beginExpedition with the money keyed to an idempotency +// id. idemKey empty keeps the plain Debit/Credit pair, which is correct for the +// two callers that arrive exactly once (a Matrix command, the boredom ticker). +// +// A non-empty key comes from the web action queue, whose wire retries: the debit +// then lands at most once however many times the order is re-offered, and the +// refunds are keyed too so a torn-down start cannot refund on every tick. Note +// what this means for the caller — once a refund has happened, retrying is +// *unsafe*, because the guid-keyed debit will not charge again and the player +// would get the expedition for free. performExpeditionStart therefore treats +// every error from here as permanent. +func (p *AdventurePlugin) beginExpeditionIdem(uid id.UserID, charLevel int, zone ZoneDefinition, purchase SupplyPurchase, reason, idemKey string) (*Expedition, ExpeditionSupplies, string, error) { if p.euro == nil { return nil, ExpeditionSupplies{}, "", fmt.Errorf("Coin system unavailable — try again later.") } cost := float64(purchase.Cost()) + debit := func(why string) bool { return p.euro.Debit(uid, cost, why) } + refund := func(why, suffix string) { p.euro.Credit(uid, cost, why) } + if idemKey != "" { + debit = func(why string) bool { + ok, _, err := p.euro.DebitIdem(uid, cost, why, idemKey) + return err == nil && ok + } + refund = func(why, suffix string) { + if _, _, err := p.euro.CreditIdem(uid, cost, why, idemKey+":refund"+suffix); err != nil { + slog.Error("expedition: outfitting refund failed", "user", uid, "order", idemKey, "err", err) + } + } + } // Holiday perk: a complimentary standard pack is added to the supplies // snapshot without inflating the coin cost. Bypasses the per-tier cap @@ -460,14 +607,14 @@ func (p *AdventurePlugin) beginExpedition(uid id.UserID, charLevel int, zone Zon supplies := makeSupplies(zone.Tier, suppliesPurchase) // Debit coins; bail on debit failure (race / cap). - if !p.euro.Debit(uid, cost, reason+": "+string(zone.ID)) { + if !debit(reason + ": " + string(zone.ID)) { return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't debit outfitting cost (try again).") } exp, err := startExpedition(uid, zone.ID, "", supplies) if err != nil { // Refund on persistence failure. - p.euro.Credit(uid, cost, "expedition outfitting refund") + refund("expedition outfitting refund", "") return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't start expedition: %s", err) } @@ -478,7 +625,7 @@ func (p *AdventurePlugin) beginExpedition(uid id.UserID, charLevel int, zone Zon // Refund and tear the expedition row back down — without a // linked run, harvest and rooms can't function. _ = abandonExpedition(uid) - p.euro.Credit(uid, cost, "expedition outfitting refund (run-spawn failed)") + refund("expedition outfitting refund (run-spawn failed)", ":region") return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't outfit the first region: %s", err) } @@ -697,65 +844,102 @@ func formatLogTimestamp(t time.Time) string { // ── abandon ───────────────────────────────────────────────────────────────── -func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error { - exp, isLeader, err := activeExpeditionFor(ctx.Sender) +// Sentinels for the two ways abandoning can be refused, so the web action queue +// can pick a verdict without reading prose. Same contract as the start/resume +// family above: errors.Is classifies, Error() is the sentence Matrix has always +// sent. +var ( + errAbandonNothing = errors.New("expedition abandon: nothing to abandon") + errAbandonNotLeader = errors.New("expedition abandon: only the leader may call it") +) + +// abandonOutcome is what closing the expedition did, for a caller describing it +// somewhere other than a DM. +type abandonOutcome struct { + Zone ZoneDefinition + Day int + Extracted bool // it was already out and standing in town: loot and XP are kept +} + +// performExpeditionAbandon is `!expedition abandon` minus the command framing. +// Shared with the web action queue so closing a run from a phone disbands the +// same roster, retires the same region runs, writes the same log line and tells +// the same party — the members hear it from their leader either way, because +// that is a fact about the expedition and not about which door was used. +// +// Like performExpeditionStart this does NOT take the per-user lock: its Matrix +// caller already holds it across the whole `!expedition` switch. applyWebAbandon +// takes it instead. Getting that backwards does not fail loudly — it wedges the +// player's lock forever and every later adventure command from them hangs. +func (p *AdventurePlugin) performExpeditionAbandon(uid id.UserID) (abandonOutcome, error) { + exp, isLeader, err := activeExpeditionFor(uid) if err != nil { - return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error()) + return abandonOutcome{}, err } if exp == nil { // An extracted expedition is still the owner's to close — it holds the // roster until the resume window lapses. Without this, a leader who // wanted out had to pay to `!resume` first just to abandon. - if exp, err = getResumableExpedition(ctx.Sender); err != nil { - return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error()) + if exp, err = getResumableExpedition(uid); err != nil { + return abandonOutcome{}, err } isLeader = exp != nil } if exp == nil { - return p.SendDM(ctx.Sender, "No active expedition to abandon.") + return abandonOutcome{}, refuseAdv(errAbandonNothing, "No active expedition to abandon.") } if !isLeader { // Abandoning throws away everyone's day. A member leaves alone. - return p.SendDM(ctx.Sender, + return abandonOutcome{}, refuseAdv(errAbandonNotLeader, "Only your party leader can abandon the expedition. `!expedition leave` to walk out alone.") } zone, _ := getZone(exp.ZoneID) - extracted := exp.Status == ExpeditionStatusExtracting + out := abandonOutcome{Zone: zone, Day: exp.CurrentDay, Extracted: exp.Status == ExpeditionStatusExtracting} audience := expeditionAudience(exp) // read before abandonExpedition disbands the roster - if err := abandonExpedition(ctx.Sender); err != nil { - return p.SendDM(ctx.Sender, "Couldn't abandon: "+err.Error()) + if err := abandonExpedition(uid); err != nil { + return abandonOutcome{}, err } - markActedToday(ctx.Sender) + markActedToday(uid) _ = retireAllRegionRuns(exp) _ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "expedition abandoned", "") + // The roster is being disbanded out from under the members; they hear it from + // their leader rather than discovering it the next time a command works again. + for _, member := range audience { + if member == uid { + continue + } + if err := p.SendDM(member, fmt.Sprintf( + "Your leader called off the expedition in **%s** on Day %d. You're free to start a run of your own.", + zone.Display, exp.CurrentDay)); err != nil { + slog.Warn("expedition: abandon DM failed", "user", member, "expedition", exp.ID, "err", err) + } + } + // Emergence seam: see maybeRollPetArrivalOnEmerge. Inside the twin because + // walking out of a dungeon is what rolls it, not saying so in a room. + p.maybeRollPetArrivalOnEmerge(uid) + return out, nil +} + +func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error { + out, err := p.performExpeditionAbandon(ctx.Sender) + if err != nil { + var refusal advRefusal + if errors.As(err, &refusal) { + return p.SendDM(ctx.Sender, refusal.Error()) + } + return p.SendDM(ctx.Sender, "Couldn't abandon: "+err.Error()) + } // An extracted party is standing in town, not in the dungeon: their supplies // are already spent and their loot is already banked. Say the true thing. body := fmt.Sprintf( "Expedition in **%s** abandoned on Day %d. Supplies are forfeit. The dungeon remembers.", - zone.Display, exp.CurrentDay) - if extracted { + out.Zone.Display, out.Day) + if out.Extracted { body = fmt.Sprintf( "You let the expedition in **%s** go. Day %d is where it ends — loot, XP, and coins are kept. The dungeon remembers.", - zone.Display, exp.CurrentDay) + out.Zone.Display, out.Day) } - // The roster is being disbanded out from under the members; they hear it from - // their leader rather than discovering it the next time a command works again. - for _, uid := range audience { - if uid == ctx.Sender { - continue - } - if err := p.SendDM(uid, fmt.Sprintf( - "Your leader called off the expedition in **%s** on Day %d. You're free to start a run of your own.", - zone.Display, exp.CurrentDay)); err != nil { - slog.Warn("expedition: abandon DM failed", "user", uid, "expedition", exp.ID, "err", err) - } - } - if err := p.SendDM(ctx.Sender, body); err != nil { - return err - } - // Emergence seam: see maybeRollPetArrivalOnEmerge. - p.maybeRollPetArrivalOnEmerge(ctx.Sender) - return nil + return p.SendDM(ctx.Sender, body) } // helper: ensure we don't shadow id.UserID import in test harness. @@ -931,6 +1115,7 @@ func (p *AdventurePlugin) autoPickStaleFork(exp *Expedition, run *DungeonRun, pf if err := removeAdvInventoryItem(spendTool); err != nil { slog.Warn("expedition: autopilot tools spend", "user", run.UserID, "err", err) } + beatLock(run, chosen.Label, "picked") } fireGraphRegionTransition(run.UserID, g.Nodes[run.CurrentNode], g.Nodes[chosen.To]) if exp != nil { @@ -993,6 +1178,7 @@ func (p *AdventurePlugin) backtrackFromDeadFork(exp *Expedition, run *DungeonRun slog.Warn("expedition: backtrack clear fork", "run", run.RunID, "err", err) return false } + beatLock(run, "", "sealed") if _, err := revisitZoneRun(run.RunID, target, run.VisitedNodes); err != nil { slog.Warn("expedition: backtrack from dead fork", "run", run.RunID, "err", err) return false diff --git a/internal/plugin/dnd_expedition_extract.go b/internal/plugin/dnd_expedition_extract.go index 55a101e..a81c709 100644 --- a/internal/plugin/dnd_expedition_extract.go +++ b/internal/plugin/dnd_expedition_extract.go @@ -352,32 +352,54 @@ func resumeExpedition(expID string, supplies ExpeditionSupplies) error { // ── !extract command ──────────────────────────────────────────────────────── -func (p *AdventurePlugin) handleExtractCmd(ctx MessageContext, _ string) error { - userMu := p.advUserLock(ctx.Sender) +// Sentinels for the two ways an extraction can be refused. They exist so the +// headless caller (the web action queue, pete_orders.go) can turn a refusal into +// its own verdict without parsing a DM. `!extract` maps them straight back to the +// prose it always sent. +var ( + errExtractNoRun = errors.New("extract: no active expedition") + errExtractNotLeader = errors.New("extract: not the party leader") +) + +// extractOutcome is what an extraction did, for a caller that has to describe it +// somewhere other than a DM. +type extractOutcome struct { + Zone string // display name + Day int +} + +// performExtraction is the whole of `!extract` minus the command framing: the +// per-user lock, the leader check, the state flip, the log line, the party +// fan-out and the emergence pet roll. It is shared with the web action queue so +// that pulling out from a phone is the *same* extraction, not a second +// implementation of one — the party still gets DM'd, the log still gets its +// line, and the resume window is the same window. +func (p *AdventurePlugin) performExtraction(uid id.UserID) (extractOutcome, error) { + userMu := p.advUserLock(uid) userMu.Lock() defer userMu.Unlock() - exp, isLeader, err := activeExpeditionFor(ctx.Sender) + exp, isLeader, err := activeExpeditionFor(uid) if err != nil { - return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error()) + return extractOutcome{}, fmt.Errorf("reading expedition state: %w", err) } if exp == nil { - return p.SendDM(ctx.Sender, "No active expedition to extract from.") + return extractOutcome{}, errExtractNoRun } if !isLeader { // Extraction ends the expedition for the whole roster, so it is the // leader's call — the same reasoning that makes `!flee` leader-only. - return p.SendDM(ctx.Sender, "Only your party leader can call the extraction. Ask them to `!extract`, or `!expedition leave` to walk out alone.") + return extractOutcome{}, errExtractNotLeader } zone, _ := getZone(exp.ZoneID) - updated, err := voluntaryExtractExpedition(ctx.Sender) + updated, err := voluntaryExtractExpedition(uid) if err != nil { - return p.SendDM(ctx.Sender, "Couldn't extract: "+err.Error()) + return extractOutcome{}, err } line := flavor.Pick(flavor.ExtractionVoluntary) _ = appendExpeditionLog(updated.ID, updated.CurrentDay, "narrative", "voluntary extraction", line) - markActedToday(ctx.Sender) + markActedToday(uid) var b strings.Builder b.WriteString(fmt.Sprintf("🚪 **Extraction — %s, Day %d**\n\n", @@ -401,84 +423,167 @@ func (p *AdventurePlugin) handleExtractCmd(ctx MessageContext, _ string) error { // Emergence seam: surfacing from a run is when an animal may have moved // into the empty house. Every member surfaced, so every member rolls. - for _, uid := range expeditionAudience(updated) { - p.maybeRollPetArrivalOnEmerge(uid) + for _, member := range expeditionAudience(updated) { + p.maybeRollPetArrivalOnEmerge(member) + } + return extractOutcome{Zone: zone.Display, Day: updated.CurrentDay}, nil +} + +// handleExtractCmd is `!extract`: the command framing around performExtraction. +// The extraction itself, including the DM everyone in the party gets, happens in +// there — so this only has to turn a refusal back into the prose it always sent. +func (p *AdventurePlugin) handleExtractCmd(ctx MessageContext, _ string) error { + _, err := p.performExtraction(ctx.Sender) + switch { + case errors.Is(err, errExtractNoRun): + return p.SendDM(ctx.Sender, "No active expedition to extract from.") + case errors.Is(err, errExtractNotLeader): + return p.SendDM(ctx.Sender, "Only your party leader can call the extraction. Ask them to `!extract`, or `!expedition leave` to walk out alone.") + case err != nil: + return p.SendDM(ctx.Sender, "Couldn't extract: "+err.Error()) } return nil } // ── !resume command ───────────────────────────────────────────────────────── -func (p *AdventurePlugin) handleResumeCmd(ctx MessageContext, args string) error { - userMu := p.advUserLock(ctx.Sender) +// Sentinels for the ways going back in can be refused. Same contract as +// performExpeditionStart's: each one comes back inside an advRefusal that also +// carries the finished sentence, so `!resume` keeps its copy verbatim. +// +// errResumeNeedLoadout is the odd one out and deliberately so: it is not a +// refusal at all but the loadout prompt, returned as one so the whole decision +// stays inside the lock. The web never triggers it — it always names a loadout. +var ( + errResumeNeedLoadout = errors.New("resume: no loadout named") + errResumeBusy = errors.New("resume: already on an expedition") + errResumeNothing = errors.New("resume: no extracted expedition") + errResumeLapsed = errors.New("resume: past the 7-day window") + errResumeBadPacks = errors.New("resume: invalid pack selection") + errResumeBroke = errors.New("resume: cannot cover outfitting") + errResumeFailed = errors.New("resume: could not resume") +) + +// resumeOutcome is what going back in did, for a caller describing it somewhere +// other than a DM. +type resumeOutcome struct { + Zone ZoneDefinition + Day int + Supplies ExpeditionSupplies + Purchase SupplyPurchase + Threat int + Stack int + Line string +} + +// performResume is `!resume` minus the command framing: the leader check, the +// lapse check, the re-outfitting purchase and the fresh region run. Shared with +// the web action queue so that walking back in from a phone is the same walk. +// +// loadoutTok is the raw `Ns Md` / preset token; empty asks for the prompt. +// idemKey, when set, is the web order's guid and moves the money onto the +// idempotent variants — see beginExpeditionIdem for why a refund then makes a +// retry unsafe, which is why every error here is permanent for the web caller. +func (p *AdventurePlugin) performResume(uid id.UserID, loadoutTok, idemKey string) (resumeOutcome, error) { + userMu := p.advUserLock(uid) userMu.Lock() defer userMu.Unlock() - c, err := LoadDnDCharacter(ctx.Sender) + c, err := LoadDnDCharacter(uid) if err != nil { - return p.SendDM(ctx.Sender, "Couldn't load your character: "+err.Error()) + return resumeOutcome{}, fmt.Errorf("Couldn't load your character: %s", err) } if c == nil || c.PendingSetup { - return p.SendDM(ctx.Sender, "No Adv 2.0 character yet — run `!setup` first.") + return resumeOutcome{}, refuseAdv(errResumeNothing, "No Adv 2.0 character yet — run `!setup` first.") } - if existing, isLeader, _ := activeExpeditionFor(ctx.Sender); existing != nil { + if existing, isLeader, _ := activeExpeditionFor(uid); existing != nil { zone, _ := getZone(existing.ZoneID) if !isLeader { - return p.SendDM(ctx.Sender, fmt.Sprintf( + return resumeOutcome{}, refuseAdv(errResumeBusy, "You're riding a party expedition in **%s** (Day %d). Only its leader can `!resume`.", - zone.Display, existing.CurrentDay)) + zone.Display, existing.CurrentDay) } - return p.SendDM(ctx.Sender, fmt.Sprintf( + // A web order that already paid and already resumed lands here on the + // re-offer; the settled debit is what tells that apart from a player who + // really is already out. Same tell as performExpeditionStart's. + if idemKey != "" && p.euro != nil && p.euro.HasExternalTx(idemKey) { + out := resumeOutcome{Zone: zone, Day: existing.CurrentDay, + Supplies: existing.Supplies, Threat: existing.ThreatLevel} + // Re-price the same loadout at the same tier so the verdict this feeds + // can still say what it cost. Leaving Purchase zero would file a + // "re-outfitted for 0 coins" receipt for a trip that was paid for. + if pp, perr := resolveLoadoutOrParse(strings.TrimSpace(loadoutTok), zone.Tier); perr == nil { + out.Purchase = pp + } + return out, nil + } + return resumeOutcome{}, refuseAdv(errResumeBusy, "You already have an active expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.", - zone.Display, existing.CurrentDay)) + zone.Display, existing.CurrentDay) } - exp, err := getResumableExpedition(ctx.Sender) + exp, err := getResumableExpedition(uid) if err != nil { - return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error()) + return resumeOutcome{}, fmt.Errorf("Couldn't read expedition state: %s", err) } if exp == nil { - return p.SendDM(ctx.Sender, "No extracted expedition to resume. Use `!expedition start ` to begin a new one.") + return resumeOutcome{}, refuseAdv(errResumeNothing, + "No extracted expedition to resume. Use `!expedition start ` to begin a new one.") } if extractionLapsed(exp, time.Now().UTC()) { // Expire it so it doesn't keep resurfacing. The hourly sweeper would get // here on its own; this keeps the refusal and the reap in one breath. _ = completeExpedition(exp.ID, ExpeditionStatusFailed) - return p.SendDM(ctx.Sender, + return resumeOutcome{}, refuseAdv(errResumeLapsed, "That extraction is past its 7-day resume window — the dungeon has reshaped without you. Start a new expedition.") } - resumeZone, _ := getZone(exp.ZoneID) + zone, _ := getZone(exp.ZoneID) // D5-b: prompt for a preset loadout on empty args. - if strings.TrimSpace(args) == "" { - return p.SendDM(ctx.Sender, renderLoadoutPrompt(resumeZone, "resume")) + if strings.TrimSpace(loadoutTok) == "" { + return resumeOutcome{}, refuseAdv(errResumeNeedLoadout, "%s", renderLoadoutPrompt(zone, "resume")) } - purchase, err := resolveLoadoutOrParse(strings.TrimSpace(args), resumeZone.Tier) + purchase, err := resolveLoadoutOrParse(strings.TrimSpace(loadoutTok), zone.Tier) if err != nil { - return p.SendDM(ctx.Sender, "Couldn't parse supply packs: "+err.Error()) + return resumeOutcome{}, refuseAdv(errResumeBadPacks, "Couldn't parse supply packs: %s", err.Error()) } - if err := purchase.Validate(resumeZone.Tier); err != nil { - return p.SendDM(ctx.Sender, "Invalid pack selection: "+err.Error()) + if err := purchase.Validate(zone.Tier); err != nil { + return resumeOutcome{}, refuseAdv(errResumeBadPacks, "Invalid pack selection: %s", err.Error()) } cost := float64(purchase.Cost()) if p.euro == nil { - return p.SendDM(ctx.Sender, "Coin system unavailable — try again later.") + return resumeOutcome{}, refuseAdv(errResumeFailed, "Coin system unavailable — try again later.") } - if balance := p.euro.GetBalance(ctx.Sender); balance < cost { - return p.SendDM(ctx.Sender, fmt.Sprintf( - "Not enough coins. Outfitting costs **%d** but you have **%.0f**.", - int(cost), balance)) + paid := idemKey != "" && p.euro.HasExternalTx(idemKey) + if !paid { + if balance := p.euro.GetBalance(uid); balance < cost { + return resumeOutcome{}, refuseAdv(errResumeBroke, + "Not enough coins. Outfitting costs **%d** but you have **%.0f**.", + int(cost), balance) + } } - if !p.euro.Debit(ctx.Sender, cost, "expedition resume outfitting: "+string(exp.ZoneID)) { - return p.SendDM(ctx.Sender, "Couldn't debit outfitting cost (try again).") + debit := func(why string) bool { return p.euro.Debit(uid, cost, why) } + refund := func(why, suffix string) { p.euro.Credit(uid, cost, why) } + if idemKey != "" { + debit = func(why string) bool { + ok, _, err := p.euro.DebitIdem(uid, cost, why, idemKey) + return err == nil && ok + } + refund = func(why, suffix string) { + if _, _, err := p.euro.CreditIdem(uid, cost, why, idemKey+":refund"+suffix); err != nil { + slog.Error("expedition: resume refund failed", "user", uid, "order", idemKey, "err", err) + } + } + } + if !debit("expedition resume outfitting: " + string(exp.ZoneID)) { + return resumeOutcome{}, refuseAdv(errResumeFailed, "Couldn't debit outfitting cost (try again).") } - zone, _ := getZone(exp.ZoneID) supplies := makeSupplies(zone.Tier, purchase) if err := resumeExpedition(exp.ID, supplies); err != nil { - p.euro.Credit(ctx.Sender, cost, "expedition resume refund") - return p.SendDM(ctx.Sender, "Couldn't resume: "+err.Error()) + refund("expedition resume refund", "") + return resumeOutcome{}, refuseAdv(errResumeFailed, "Couldn't resume: %s", err.Error()) } exp.Status = ExpeditionStatusActive exp.Supplies = supplies @@ -489,25 +594,40 @@ func (p *AdventurePlugin) handleResumeCmd(ctx MessageContext, args string) error exp.RegionState[regionStateRegionRuns] = map[string]string{} _ = persistRegionState(exp) if _, err := ensureRegionRun(exp, c.Level); err != nil { - p.euro.Credit(ctx.Sender, cost, "expedition resume refund (run-spawn failed)") - return p.SendDM(ctx.Sender, "Couldn't outfit the resumed region: "+err.Error()) + refund("expedition resume refund (run-spawn failed)", ":region") + return resumeOutcome{}, refuseAdv(errResumeFailed, "Couldn't outfit the resumed region: %s", err.Error()) } line := flavor.Pick(flavor.ExpeditionResume) _ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "expedition resumed", line) + return resumeOutcome{ + Zone: zone, Day: exp.CurrentDay, Supplies: supplies, Purchase: purchase, + Threat: exp.ThreatLevel, Stack: exp.TemporalStack, Line: line, + }, nil +} + +// handleResumeCmd is `!resume`: the command framing around performResume. +func (p *AdventurePlugin) handleResumeCmd(ctx MessageContext, args string) error { + out, err := p.performResume(ctx.Sender, args, "") + if err != nil { + // Every refusal — and the loadout prompt, which travels as one — arrives + // as a finished sentence, so this only has to say it. + return p.SendDM(ctx.Sender, err.Error()) + } + var b strings.Builder b.WriteString(fmt.Sprintf("🚪 **Expedition resumed — %s, Day %d**\n\n", - zone.Display, exp.CurrentDay)) - if line != "" { - b.WriteString(line) + out.Zone.Display, out.Day)) + if out.Line != "" { + b.WriteString(out.Line) b.WriteString("\n\n") } b.WriteString(fmt.Sprintf("**Re-outfitted:** %.0f SU (%d standard, %d deluxe) — %d coins\n", - supplies.Max, purchase.StandardPacks, purchase.DeluxePacks, purchase.Cost())) - b.WriteString(fmt.Sprintf("**Threat:** %d / 100 (resumed at extraction value)\n", exp.ThreatLevel)) - if exp.TemporalStack != 0 { - b.WriteString(fmt.Sprintf("**Zone stack:** %d (resumed)\n", exp.TemporalStack)) + out.Supplies.Max, out.Purchase.StandardPacks, out.Purchase.DeluxePacks, out.Purchase.Cost())) + b.WriteString(fmt.Sprintf("**Threat:** %d / 100 (resumed at extraction value)\n", out.Threat)) + if out.Stack != 0 { + b.WriteString(fmt.Sprintf("**Zone stack:** %d (resumed)\n", out.Stack)) } b.WriteString("\nUse `!expedition status` for the daily briefing.") return p.SendDM(ctx.Sender, b.String()) diff --git a/internal/plugin/dnd_expedition_extract_test.go b/internal/plugin/dnd_expedition_extract_test.go index aae426b..3c3624b 100644 --- a/internal/plugin/dnd_expedition_extract_test.go +++ b/internal/plugin/dnd_expedition_extract_test.go @@ -175,3 +175,28 @@ func TestResume_WindowExpired(t *testing.T) { time.Since(*got.CompletedAt), extractResumeWindow) } } + +// `!expedition extract` and `!expedition resume` are aliases for two top-level +// commands that take the per-user lock themselves. If the alias dispatcher takes +// that lock first the handler blocks on it forever and, because the deferred +// unlock never runs, every later adventure command from that player wedges too. +// This does not fail on regression — it hangs — so the timeout is the assertion. +func TestExpeditionAliasesDoNotWedgeTheUserLock(t *testing.T) { + setupEmptyTestDB(t) + uid := id.UserID("@exp-alias-lock:example") + t.Cleanup(func() { cleanupExpeditions(uid) }) + + for _, sub := range []string{"extract", "resume"} { + done := make(chan struct{}) + go func() { + defer close(done) + p := &AdventurePlugin{euro: &EuroPlugin{}} + _ = p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, sub) + }() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatalf("!expedition %s never returned: the alias re-took advUserLock", sub) + } + } +} diff --git a/internal/plugin/dnd_expedition_region_cmd.go b/internal/plugin/dnd_expedition_region_cmd.go index 6ebe9a3..ad6156c 100644 --- a/internal/plugin/dnd_expedition_region_cmd.go +++ b/internal/plugin/dnd_expedition_region_cmd.go @@ -170,6 +170,13 @@ func (p *AdventurePlugin) advanceToNextRegion(userID id.UserID, exp *Expedition, tc := resolveTransitWanderingCheck(exp, charClass, nil) _ = processTransitWanderingCheck(exp, tc) + // The liveblog beat goes on the *outgoing* run, and it has to be filed before + // the run is retired — a region crossing is the last thing that happens in + // the region being left, and it is what explains why that run's log stops. + if outgoing, _ := getZoneRun(exp.RunID); outgoing != nil { + beatRegion(outgoing, cur.Name, next.Name) + } + // R2 — retire the outgoing region's DungeonRun before mutating // CurrentRegion so retireRegionRun keys the right region. if err := retireRegionRun(exp, cur.ID); err != nil { diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index 2d41531..b1062a3 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -893,6 +893,7 @@ func (p *AdventurePlugin) runHarvestForAdvance( if herr != nil { return autoHarvestResult{}, "" } + beatHaul(fresh, hr.Summary) return hr, renderAutoHarvestFooter(hr.Summary) } @@ -1061,7 +1062,8 @@ func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone Zo case RoomEntry: return case RoomTrap: - _, narration := p.resolveTrapRoom(userID, run, zone) + damage, narration := p.resolveTrapRoom(userID, run, zone) + beatTrap(userID, run, damage) outcome = narration return case RoomExploration: @@ -1125,6 +1127,12 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z result := pres.Seats[0] postHP, maxHP := dndHPSnapshot(userID) nat20s, nat1s := scanMoodEventsFromEvents(run.RunID, pres.Events) + // One beat for the fight, filed here rather than on each of the three + // outcome branches below — every one of them passes through this point with + // the result already decided, and a single site can't drift out of step with + // the others. + beatCombat(run, monster.Name, elite, isBoss, result.PlayerWon, result.TimedOut, + preHP, postHP, maxHP, nat20s, nat1s) // Compact mode: skip TwinBee banter, skip the multi-beat play-by-play. // Render a single outcome line. Still records kills, threat, and drops. @@ -1226,6 +1234,14 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z // tryPatrolEncounter); see retreatThreatBump in // dnd_expedition_combat.go. _, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath) + // Ahead of abandonZoneRun, which would close the story as "abandoned" — + // the difference between being killed and running out of clock is the + // most interesting fact in the whole log. + if result.TimedOut { + beatRunEnd(run, "retreated") + } else { + beatRunEnd(run, "died") + } _ = abandonZoneRun(userID) // Timeout loss = retreat; the fighters took wounds but nobody actually // died. Don't fire markAdventureDead — that would trigger the 6h respawn diff --git a/internal/plugin/dnd_zone_combat.go b/internal/plugin/dnd_zone_combat.go index 50b5e49..4f1073c 100644 --- a/internal/plugin/dnd_zone_combat.go +++ b/internal/plugin/dnd_zone_combat.go @@ -238,6 +238,7 @@ func (p *AdventurePlugin) resolveSecretRoom(userID id.UserID, run *DungeonRun, z it := item if line := p.grantZoneItem(userID, &it, "🧪"); line != "" { rewards = append(rewards, line) + beatTreasure(run, it.Name, "cache") } } @@ -392,6 +393,11 @@ func (p *AdventurePlugin) rollZoneLoot(userID id.UserID, run *DungeonRun, zone Z slog.Error("zone: addLoot audit", "user", userID, "item", entry.ItemID, "err", err) } granted = append(granted, entry.ItemID) + source := "zone" + if bossCleared { + source = "boss" + } + beatTreasure(run, item.Name, source) } return granted } diff --git a/internal/plugin/dnd_zone_run.go b/internal/plugin/dnd_zone_run.go index 706fb10..0998520 100644 --- a/internal/plugin/dnd_zone_run.go +++ b/internal/plugin/dnd_zone_run.go @@ -295,6 +295,7 @@ func startZoneRun(userID id.UserID, zoneID ZoneID, dndLevel int, rng *rand.Rand) ); err != nil { return nil, fmt.Errorf("insert zone run: %w", err) } + beatRunStart(userID, run, zone) return run, nil } @@ -581,6 +582,7 @@ func abandonZoneRun(userID id.UserID) error { if r == nil { return ErrNoActiveRun } + beatRunEnd(r, "abandoned") _, err = db.Get().Exec(` UPDATE dnd_zone_run SET abandoned = 1, @@ -599,6 +601,12 @@ func abandonZoneRunByID(runID string) error { if runID == "" { return nil } + // The generic funnel: it fires for an idle reap, a region retirement, and a + // completed run being tidied up alike. beatRunEnd is first-writer-wins, so + // this only ever supplies the outcome nothing more specific already did. + if r, _ := getZoneRun(runID); r != nil { + beatRunEnd(r, "abandoned") + } _, err := db.Get().Exec(` UPDATE dnd_zone_run SET abandoned = 1, diff --git a/internal/plugin/expedition_party_cmd.go b/internal/plugin/expedition_party_cmd.go index dc7bf03..d6f1768 100644 --- a/internal/plugin/expedition_party_cmd.go +++ b/internal/plugin/expedition_party_cmd.go @@ -280,47 +280,77 @@ func (p *AdventurePlugin) expeditionCmdParty(ctx MessageContext) error { return p.SendDM(ctx.Sender, b.String()) } -// expeditionCmdLeave walks a member out. The leader cannot leave — their row is -// the expedition — so they are pointed at `!extract`, which ends it for all. -func (p *AdventurePlugin) expeditionCmdLeave(ctx MessageContext) error { +// Sentinels for the two ways walking out can be refused, so the web action queue +// can pick a verdict without reading prose. errLeaveIsLeader is deliberately not +// errAbandonNotLeader inverted-and-reused: they are opposite facts about the same +// person and a verdict that conflated them would tell a leader they weren't one. +var ( + errLeaveNothing = errors.New("expedition leave: no expedition to leave") + errLeaveIsLeader = errors.New("expedition leave: the leader's row is the expedition") +) + +// performExpeditionLeave is `!expedition leave` minus the command framing. +// Shared with the web action queue, so a member walking out from a phone unseats +// the same way and the leader is told either way. +// +// Like performExpeditionAbandon this does NOT take the per-user lock — its +// Matrix caller holds it across the whole `!expedition` switch, and applyWebLeave +// takes it instead. See the comment on performExpeditionAbandon for what getting +// that backwards costs. +func (p *AdventurePlugin) performExpeditionLeave(uid id.UserID) error { // Resolve the seat the way the guards that trap them do. seatedExpeditionFor // spans `extracting`, which activeExpeditionFor does not: a leader who // extracts and never resumes would otherwise leave their members seated — // refused a new adventure by the guard, and told "no active expedition" by // the very command the guard points them at. The exit has to see every state // the gate sees. It already excludes leaders, so they fall through below. - seated, err := seatedExpeditionFor(ctx.Sender) + seated, err := seatedExpeditionFor(uid) if err != nil { - return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error()) + return err } if seated != nil { - return p.leaveSeatedParty(ctx, seated) + return p.leaveSeatedParty(uid, seated) } - exp, isLeader, err := activeExpeditionFor(ctx.Sender) + exp, isLeader, err := activeExpeditionFor(uid) if err != nil { - return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error()) + return err } if exp == nil { - return p.SendDM(ctx.Sender, "No active expedition.") + return refuseAdv(errLeaveNothing, "No active expedition.") } if isLeader { - return p.SendDM(ctx.Sender, + return refuseAdv(errLeaveIsLeader, "You're leading this one — `!extract` ends it for everyone, or `!expedition abandon` to walk away from it.") } - return p.leaveSeatedParty(ctx, exp) + return p.leaveSeatedParty(uid, exp) } -// leaveSeatedParty unseats a member and tells both ends. Shared by the two ways -// a member's seat resolves: the `extracting` limbo and the plain active party. -func (p *AdventurePlugin) leaveSeatedParty(ctx MessageContext, exp *Expedition) error { - if err := leaveParty(exp.ID, ctx.Sender); err != nil { +// expeditionCmdLeave walks a member out. The leader cannot leave — their row is +// the expedition — so they are pointed at `!extract`, which ends it for all. +func (p *AdventurePlugin) expeditionCmdLeave(ctx MessageContext) error { + if err := p.performExpeditionLeave(ctx.Sender); err != nil { + var refusal advRefusal + if errors.As(err, &refusal) { + return p.SendDM(ctx.Sender, refusal.Error()) + } return p.SendDM(ctx.Sender, "Couldn't leave: "+err.Error()) } + return p.SendDM(ctx.Sender, "You turn back for town. Your supplies stay with the party.") +} + +// leaveSeatedParty unseats a member and tells the leader. Shared by the two ways +// a member's seat resolves: the `extracting` limbo and the plain active party. +// The *member's* own confirmation is the caller's, because that is the one line +// that differs between a DM and a web verdict. +func (p *AdventurePlugin) leaveSeatedParty(uid id.UserID, exp *Expedition) error { + if err := leaveParty(exp.ID, uid); err != nil { + return err + } // Supplies stay in the pool. They were spent on the expedition, not lent to // it, and clawing them back would let a member starve the party on their way // out of the door. _ = p.SendDM(id.UserID(exp.UserID), fmt.Sprintf( - "**%s** turned back. Their supplies stay with the party.", p.DisplayName(ctx.Sender))) - return p.SendDM(ctx.Sender, "You turn back for town. Your supplies stay with the party.") + "**%s** turned back. Their supplies stay with the party.", p.DisplayName(uid))) + return nil } diff --git a/internal/plugin/pete.go b/internal/plugin/pete.go index e8c123c..df20d29 100644 --- a/internal/plugin/pete.go +++ b/internal/plugin/pete.go @@ -279,8 +279,12 @@ func claimRealmFirst(kind, target string) bool { // *started* — every dispatch was an outcome — which is why the two live boredom // runs produced no news at all. // -// The event_type must be one Pete already knows: an unknown type is a 400, which -// retries and then parks the bulletin forever. Deploy Pete first. +// The event_type no longer has to be one Pete already knows. It used to: an +// unknown type was a 400, which retried to the cap and then parked the bulletin +// forever, so shipping a new event type meant remembering to deploy Pete first. +// Pete now publishes an untemplated type on a neutral fallback and counts it for +// the operator, so the ordering rule is a property of the system rather than +// something a human has to hold. func emitBoredomDeparture(userID id.UserID, zone ZoneDefinition, level int) { if !peteclient.Enabled() || !newsEmissionOn() { return @@ -339,6 +343,7 @@ func emitZoneClearNews(userID id.UserID, exp *Expedition) { Boss: zone.Boss.Name, Level: lvl, Outcome: "cleared", + RunID: latestRunIDForNews(userID), OccurredAt: ts, }, userID, "") } diff --git a/internal/plugin/pete_detail_test.go b/internal/plugin/pete_detail_test.go index 77ab756..e8a44e8 100644 --- a/internal/plugin/pete_detail_test.go +++ b/internal/plugin/pete_detail_test.go @@ -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) + } +} diff --git a/internal/plugin/pete_dispatch_voice.go b/internal/plugin/pete_dispatch_voice.go index 6f45f49..21aa831 100644 --- a/internal/plugin/pete_dispatch_voice.go +++ b/internal/plugin/pete_dispatch_voice.go @@ -54,7 +54,7 @@ func authorDispatch(f peteclient.Fact) (headline, lede string) { } prompt := buildDispatchPrompt(f) - raw, err := callOllamaDispatch(host, model, prompt) + raw, err := callOllamaDispatch(dispatchHTTP, host, model, prompt) if err != nil { slog.Warn("pete dispatch: LLM authoring failed, Pete will template", "guid", f.GUID, "err", err) return "", "" @@ -129,9 +129,11 @@ The event: } // callOllamaDispatch posts a single non-streaming generation and returns the raw -// completion (think-tags stripped). Its own bounded client, separate from the -// interactive callOllama, because the game loop cannot wait 120s on the news. -func callOllamaDispatch(host, model, prompt string) (string, error) { +// completion (think-tags stripped). The client is a parameter because the two +// callers have genuinely different patience: a dispatch is authored on a game +// chokepoint and must not stall it, while a run summary rides a background +// ticker and can afford to wait for a bigger model. See runSummaryHTTP. +func callOllamaDispatch(client *http.Client, host, model, prompt string) (string, error) { payload := map[string]interface{}{ "model": model, "prompt": prompt, @@ -146,7 +148,7 @@ func callOllamaDispatch(host, model, prompt string) (string, error) { return "", fmt.Errorf("marshal payload: %w", err) } apiURL := strings.TrimRight(host, "/") + "/api/generate" - resp, err := dispatchHTTP.Post(apiURL, "application/json", bytes.NewReader(data)) + resp, err := client.Post(apiURL, "application/json", bytes.NewReader(data)) if err != nil { return "", fmt.Errorf("ollama request: %w", err) } diff --git a/internal/plugin/pete_offers.go b/internal/plugin/pete_offers.go new file mode 100644 index 0000000..d2ef309 --- /dev/null +++ b/internal/plugin/pete_offers.go @@ -0,0 +1,129 @@ +package plugin + +// The offer half of the web action queue: what a signed-in owner is allowed to +// ask for, and what it costs. +// +// W5a's two verbs needed none of this — "pull out" and "take your bout" have no +// arguments and no price. W5b's three do, and the page cannot invent either: the +// zone list is level-gated and postgame-gated per player, and every price scales +// with level. So gogobee quotes them here, on the self-detail push that already +// carries the owner's private panels, and Pete renders the quote without doing +// any arithmetic of its own. +// +// A quote is NOT a permission. It is up to two minutes stale by the time anybody +// clicks it, so every one of these is re-resolved against the game's own tables +// when the order lands (performExpeditionStart re-runs availableZonesFor, +// performBabysitPurchase re-reads the level). What the offer list buys is a page +// that does not show a button which is certain to be refused. + +import ( + "time" + + "gogobee/internal/peteclient" + "maunium.net/go/mautrix/id" +) + +// advLoadoutKeys is the order the three presets are offered in — cheapest first, +// which is also how renderLoadoutPrompt lists them in Matrix. +var advLoadoutKeys = []SupplyLoadout{LoadoutLean, LoadoutBalanced, LoadoutHeavy} + +// loadoutOffersFor prices the three supply presets at a tier. Days is the +// provisions estimate at that tier's calm daily burn — the same number +// `!expedition start` prints, and deliberately the pessimistic one: the holiday +// and Omen freebie packs are added at departure, so a run can outlast its quote +// but never fall short of it. +func loadoutOffersFor(tier ZoneTier) []peteclient.LoadoutOffer { + out := make([]peteclient.LoadoutOffer, 0, len(advLoadoutKeys)) + for _, l := range advLoadoutKeys { + pp := loadoutPurchase(tier, l) + sup := makeSupplies(tier, pp) + out = append(out, peteclient.LoadoutOffer{ + Key: loadoutName(l), + Name: loadoutName(l), + Blurb: loadoutBlurb(l), + Cost: pp.Cost(), + Days: estimateDays(sup.Max, sup.DailyBurn), + }) + } + return out +} + +// zoneOffersFor is where this adventurer may set out for right now, priced. +// +// It returns nothing at all when they cannot leave — already on an expedition, +// seated in somebody else's, or mid zone-run. That is not a second permission +// check duplicating performExpeditionStart's; it is what stops the page offering +// a departure it can already tell will be refused. +func zoneOffersFor(uid id.UserID) []peteclient.ZoneOffer { + if seated, _ := seatedExpeditionFor(uid); seated != nil { + return nil + } + if existing, _ := getActiveExpedition(uid); existing != nil { + return nil + } + if run, _ := getActiveZoneRun(uid); run != nil { + return nil + } + zones := availableZonesFor(uid, dndLevelForUser(uid)) + out := make([]peteclient.ZoneOffer, 0, len(zones)) + for _, z := range zones { + out = append(out, peteclient.ZoneOffer{ + ID: string(z.ID), + Display: z.Display, + Tier: int(z.Tier), + Hook: z.Hook, + Postgame: z.Tier == ZoneTierMythic, + Loadouts: loadoutOffersFor(z.Tier), + }) + } + return out +} + +// resumeOfferFor is the extracted expedition still waiting to be walked back +// into. Nil when there is none, when the window has already lapsed, or when the +// player is out again — a lapsed row is left for the sweeper to reap rather than +// reaped here, because a push builder should not be quietly ending expeditions. +func resumeOfferFor(uid id.UserID) *peteclient.ResumeOffer { + if existing, _ := getActiveExpedition(uid); existing != nil { + return nil + } + exp, err := getResumableExpedition(uid) + if err != nil || exp == nil { + return nil + } + if extractionLapsed(exp, time.Now().UTC()) { + return nil + } + zone, _ := getZone(exp.ZoneID) + off := &peteclient.ResumeOffer{ + ZoneID: string(exp.ZoneID), + Display: zone.Display, + Tier: int(zone.Tier), + Day: exp.CurrentDay, + Loadouts: loadoutOffersFor(zone.Tier), + } + if exp.CompletedAt != nil { + off.ExpiresAt = exp.CompletedAt.Add(extractResumeWindow).Unix() + } + return off +} + +// babysitOfferFor is the sitter's standing and the two prices they charge. It is +// pushed even when a sitter is already engaged: "somebody is already looking +// after your pet until Tuesday" is exactly what the page should say instead of a +// buy button. +func babysitOfferFor(adv *AdventureCharacter) *peteclient.BabysitOffer { + if adv == nil { + return nil + } + daily := babysitDailyCost(dndLevelForUser(adv.UserID)) + off := &peteclient.BabysitOffer{ + Active: adv.BabysitActive, + WeekCost: daily * 7, + MonthCost: daily * 30, + } + if adv.BabysitActive && adv.BabysitExpiresAt != nil { + off.ExpiresAt = adv.BabysitExpiresAt.Unix() + } + return off +} diff --git a/internal/plugin/pete_orders.go b/internal/plugin/pete_orders.go new file mode 100644 index 0000000..cfda1de --- /dev/null +++ b/internal/plugin/pete_orders.go @@ -0,0 +1,486 @@ +package plugin + +// The web action queue's game-side loop — the equip queue's sibling, and the +// first one where the web plays the game rather than dressing the character. +// +// An owner, signed in on Pete, asks for something: pull out of a run, take +// today's swing at the Siege, set out for a zone, walk back into the run they +// extracted from, hire the pet sitter. Pete records the intent; we poll for it, +// run the real command path (the same one `!extract`, `!expedition start`, +// `!resume` and the rest run — not a second implementation of it), and file a +// verdict Pete shows them. +// +// Same non-idempotency problem as equip, with higher stakes: replaying an +// extraction would end a run the player had already resumed, and replaying a bout +// would spend a day's swing they never got back. So before touching anything we +// check the adv_applied_orders ledger — if this order's guid is there, the +// mutation landed on an earlier tick and we only lost the verdict-ack, so we +// re-file the stored verdict and mutate nothing. +// +// The poll is faster than equip's (15s vs 30s) for one reason: an extraction is +// the answer to something the player is *watching* go wrong on the who page. A +// minute of silence there reads as a button that didn't work. + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + "time" + + "gogobee/internal/db" + "gogobee/internal/peteclient" + "maunium.net/go/mautrix/id" +) + +const ( + advOrderPollInterval = 15 * time.Second + // A Siege bout runs a full combat and streams its narration to Matrix before + // takeSiegeBout returns, so this budget is minutes, not seconds — the equip + // path's 20s would abandon a fight that was going fine. + advOrderPollTimeout = 5 * time.Minute +) + +// peteAdvOrderTicker polls Pete for web actions and fulfils them. Started +// alongside the other adventure tickers; exits on stopCh. +func (p *AdventurePlugin) peteAdvOrderTicker() { + if !peteclient.Enabled() { + return // no Pete wire configured; the action queue is simply off + } + ticker := time.NewTicker(advOrderPollInterval) + defer ticker.Stop() + for { + select { + case <-p.stopCh: + return + case <-ticker.C: + p.pollAdvOrders() + } + } +} + +func (p *AdventurePlugin) pollAdvOrders() { + ctx, cancel := context.WithTimeout(context.Background(), advOrderPollTimeout) + defer cancel() + + orders, err := peteclient.PendingOrders(ctx) + if err != nil { + // A Pete predating the queue answers 404; a wire blip looks the same. Quiet + // on purpose — this must not spam while Pete hasn't shipped the endpoint. + slog.Debug("orders: poll failed", "err", err) + return + } + for _, order := range orders { + p.fulfilAdvOrder(ctx, order) + } +} + +// fulfilAdvOrder applies one action and files its verdict. A transient failure is +// left pending for the next poll (no verdict); a permanent one gets a specific +// rejection. The guid ledger makes a re-offer after a lost ack a no-op that +// simply re-files the verdict. +func (p *AdventurePlugin) fulfilAdvOrder(ctx context.Context, order peteclient.AdvOrder) { + // Already applied on an earlier tick? Re-file the stored verdict, mutate nothing. + if status, detail, ok := advOrderAlreadyApplied(order.GUID); ok { + if err := peteclient.VerdictOrder(ctx, order.GUID, status, detail); err != nil { + slog.Warn("orders: re-file verdict push failed, will retry next poll", + "order", order.GUID, "status", status, "err", err) + } + return + } + + owner, ok := p.equipOwnerMXID(order.OwnerLocalpart) + if !ok { + // The client isn't up (tests) or the localpart is empty. Not our order to + // fail permanently — leave it pending and try again once we can name the owner. + slog.Debug("orders: cannot resolve owner, leaving pending", "order", order.GUID, "owner", order.OwnerLocalpart) + return + } + + status, detail, retry := p.applyAdvOrder(owner, order) + if retry { + return // transient; leave pending for the next tick + } + + // Record the verdict BEFORE pushing it, so a crash after the mutation still + // short-circuits next tick and re-files rather than re-applying. Same ordering + // and the same reasoning as the equip poller. + if err := recordAdvApplied(order.GUID, status, detail); err != nil { + slog.Warn("orders: failed to record applied order, leaving pending", + "order", order.GUID, "status", status, "err", err) + return + } + if err := peteclient.VerdictOrder(ctx, order.GUID, status, detail); err != nil { + slog.Warn("orders: verdict push failed, will re-file next poll", + "order", order.GUID, "status", status, "err", err) + return + } + slog.Info("orders: web action fulfilled", "order", order.GUID, "action", order.Action, "status", status) +} + +// applyAdvOrder runs the real action. It returns the terminal status and a human +// note for Pete, or retry=true for a transient fault that should leave the order +// pending. It records nothing and pushes nothing — the caller does both. +// +// Note what is NOT here: a per-user lock. Almost every verb takes it inside its +// own shared helper (performExtraction, takeSiegeBout, performResume, +// performBabysitPurchase), which is what serialises them against the Matrix +// commands running the very same code. Taking it here too would deadlock on a +// non-reentrant mutex. +// +// The exceptions are the three twins whose Matrix caller already holds the lock +// across the whole `!expedition` switch and so cannot take it themselves — +// performExpeditionStart, performExpeditionAbandon and performExpeditionLeave. +// Their apply wrappers below take it instead. That asymmetry is written down in +// both places because getting it wrong does not fail loudly: it wedges the +// player's lock forever and every later adventure command from them hangs. The +// rule for a new verb is not "web wrappers take the lock" — it is "look at what +// the Matrix caller does", and the two babysit verbs go the other way. +func (p *AdventurePlugin) applyAdvOrder(owner id.UserID, order peteclient.AdvOrder) (status, detail string, retry bool) { + switch order.Action { + case peteclient.AdvOrderExtract: + out, err := p.performExtraction(owner) + switch { + case errors.Is(err, errExtractNoRun): + return "rejected_not_running", "You weren't on an expedition.", false + case errors.Is(err, errExtractNotLeader): + return "rejected_not_leader", "Only the party leader can call the extraction.", false + case err != nil: + // Every remaining failure here is a DB fault. Leave it pending: nothing + // has been written, so the next tick retries cleanly. + slog.Warn("orders: extraction failed", "order", order.GUID, "user", owner, "err", err) + return "", "", true + } + return "applied", fmt.Sprintf( + "Out of %s on day %d. Loot, XP and coins kept. Say !resume within 7 days to go back in.", + out.Zone, out.Day), false + + case peteclient.AdvOrderSiegeJoin: + bout, boss, err := p.takeSiegeBout(owner) + switch { + case errors.Is(err, errSiegeNoBoss): + return "rejected_no_siege", "No Siege is camped outside town right now.", false + case errors.Is(err, errSiegeNoCharacter): + return "rejected_unavailable", "You don't have an adventurer yet.", false + case errors.Is(err, errSiegeDead): + return "rejected_unavailable", "You're dead. The Siege will have to wait.", false + case errors.Is(err, errSiegeAlreadyFought): + return "rejected_already_fought", "You've already taken your bout today. One fight per day.", false + case err != nil: + // A combat that errored persisted nothing terminal, but it may have + // written HP. Retry is still right: the once-per-day gate is stamped by + // the contribution row, which only lands on a bout that completed. + slog.Warn("orders: siege bout failed", "order", order.GUID, "user", owner, "err", err) + return "", "", true + } + // The blow-by-blow went to Matrix; the web gets the same one-line result the + // narration closed with, minus its markdown. + return "applied", advOrderPlainText(siegeBoutFooter(bout, boss)), false + + case peteclient.AdvOrderExpedition: + return p.applyWebExpeditionStart(owner, order) + + case peteclient.AdvOrderResume: + return p.applyWebResume(owner, order) + + case peteclient.AdvOrderBabysit: + return p.applyWebBabysit(owner, order) + + case peteclient.AdvOrderAbandon: + return p.applyWebAbandon(owner) + + case peteclient.AdvOrderLeave: + return p.applyWebLeave(owner) + + case peteclient.AdvOrderBabysitCancel: + return p.applyWebBabysitCancel(owner) + + default: + // Pete validates the action before it ever queues an order, so this is a + // contract breach, not a user mistake. Reject permanently rather than spin. + return "rejected_unavailable", "Unknown action.", false + } +} + +// ---- the three verbs that take arguments and spend coins ------------------------ +// +// Everything below re-resolves its own arguments against the game's own tables. +// Pete only ever offers what gogobee quoted it (see pete_offers.go), but a quote +// is up to two minutes stale and is not a permission — so the zone is looked up +// again in availableZonesFor, the loadout is priced again at the real tier, and +// the fee is read again at the real level. A forged param buys nothing. +// +// All three are money moves on a retrying wire, so each hands the order guid down +// as the idempotency key. Nothing here refunds-then-retries: once a refund has +// happened the guid-keyed debit will not charge again, so a retry would hand over +// the goods for free. Every failure past the debit is therefore permanent. + +// applyWebExpeditionStart sends the owner's adventurer out of town. +func (p *AdventurePlugin) applyWebExpeditionStart(owner id.UserID, order peteclient.AdvOrder) (status, detail string, retry bool) { + if order.Params == nil || order.Params.Zone == "" { + return "rejected_unavailable", "That order didn't say where to.", false + } + // performExpeditionStart is the one headless twin that does NOT take the + // per-user lock (its Matrix caller already holds it across the whole + // `!expedition` switch), so this is the one order case that has to. + userMu := p.advUserLock(owner) + userMu.Lock() + defer userMu.Unlock() + + c, err := LoadDnDCharacter(owner) + if err != nil { + return "", "", true // a DB fault; nothing written, so the next tick retries cleanly + } + if c == nil || c.PendingSetup { + return "rejected_unavailable", "You don't have an adventurer yet.", false + } + zoneID, ok := resolveZoneInput(order.Params.Zone, availableZonesFor(owner, c.Level)) + if !ok { + if reason := postgameLockReason(order.Params.Zone, owner, c.Level); reason != "" { + return "rejected_zone_locked", advOrderPlainText(reason), false + } + return "rejected_zone_locked", "That zone isn't open to you right now.", false + } + zone, _ := getZone(zoneID) + // An unknown loadout is refused rather than defaulted. A default here would + // spend coins on a pack size the player never picked. + loadout, ok := parseLoadoutToken(order.Params.Loadout) + if !ok { + return "rejected_unavailable", "That isn't a loadout I sell.", false + } + out, err := p.performExpeditionStart(owner, c, zone, loadoutPurchase(zone.Tier, loadout), order.GUID) + if err != nil { + status := "rejected_unavailable" + switch { + case errors.Is(err, errExpStartZoneLocked): + status = "rejected_zone_locked" + case errors.Is(err, errExpStartBusy): + status = "rejected_busy" + case errors.Is(err, errExpStartBroke): + status = "rejected_insufficient_funds" + } + // Everything else — still resting, a bad pack count, a start that tore + // itself down and refunded — is rejected_unavailable, and the detail line + // carries the specifics. + return status, advOrderPlainText(err.Error()), false + } + return "applied", fmt.Sprintf( + "Out of town, bound for %s, with the %s loadout: %d coins, about %d days of provisions.", + out.Zone.Display, loadoutName(loadout), out.Cost, out.Days), false +} + +// applyWebResume walks the owner back into the run they extracted from. +func (p *AdventurePlugin) applyWebResume(owner id.UserID, order peteclient.AdvOrder) (status, detail string, retry bool) { + // The loadout is required here, unlike in Matrix: an empty one asks + // performResume for the pick-a-loadout prompt, which is a DM, not a verdict. + if order.Params == nil || order.Params.Loadout == "" { + return "rejected_unavailable", "That order didn't say what to pack.", false + } + if _, ok := parseLoadoutToken(order.Params.Loadout); !ok { + return "rejected_unavailable", "That isn't a loadout I sell.", false + } + out, err := p.performResume(owner, order.Params.Loadout, order.GUID) + if err != nil { + var refusal advRefusal + if !errors.As(err, &refusal) { + // Not a refusal at all — a DB fault reading expedition state. Nothing + // has been written, so leave it pending. + slog.Warn("orders: resume failed", "order", order.GUID, "user", owner, "err", err) + return "", "", true + } + status := "rejected_unavailable" + switch { + case errors.Is(err, errResumeBusy): + status = "rejected_busy" + case errors.Is(err, errResumeNothing), errors.Is(err, errResumeLapsed): + status = "rejected_nothing_to_resume" + case errors.Is(err, errResumeBroke): + status = "rejected_insufficient_funds" + } + return status, advOrderPlainText(err.Error()), false + } + return "applied", fmt.Sprintf( + "Back into %s on day %d, re-outfitted for %d coins.", + out.Zone.Display, out.Day, out.Purchase.Cost()), false +} + +// applyWebBabysit engages the pet sitter for a week or a month. +func (p *AdventurePlugin) applyWebBabysit(owner id.UserID, order peteclient.AdvOrder) (status, detail string, retry bool) { + // The two durations the game sells. Anything else is a contract breach rather + // than a user mistake, since Pete offers exactly these two. + days := 0 + if order.Params != nil { + days = order.Params.Days + } + if days != 7 && days != 30 { + return "rejected_unavailable", "The sitter works by the week or by the month.", false + } + out, err := p.performBabysitPurchase(owner, days, order.GUID) + if err != nil { + status := "rejected_unavailable" + switch { + case errors.Is(err, errBabysitActive): + status = "rejected_busy" + case errors.Is(err, errBabysitBroke): + status = "rejected_insufficient_funds" + } + return status, advOrderPlainText(err.Error()), false + } + label := "a week" + if out.Days == 30 { + label = "a month" + } + note := fmt.Sprintf("Sitter engaged for %s, %d coins.", label, out.Cost) + if out.PetName != "" { + note += fmt.Sprintf(" %s is in good hands.", out.PetName) + } + return "applied", note, false +} + +// ---- the three verbs that take no arguments and spend nothing ------------------- +// +// Each of these was already named inside a verdict the web shows: "!expedition +// abandon first", "!expedition leave to walk out alone", "cancel early (no +// refund)". A page that tells somebody to go and type a command it could have +// offered them is a page with a hole in it, and these three close it. +// +// None of them touches money, so none of them needs an idempotency key — the +// guid ledger in fulfilAdvOrder is the whole guard, and a replay it somehow got +// past would be refused honestly ("nothing to abandon") rather than charging +// anybody twice. + +// applyWebAbandon closes the owner's expedition down for good. +func (p *AdventurePlugin) applyWebAbandon(owner id.UserID) (status, detail string, retry bool) { + // performExpeditionAbandon does NOT take the per-user lock (its Matrix caller + // holds it across the whole `!expedition` switch), so this has to. See the + // note on applyAdvOrder. + userMu := p.advUserLock(owner) + userMu.Lock() + defer userMu.Unlock() + + out, err := p.performExpeditionAbandon(owner) + if err != nil { + var refusal advRefusal + if !errors.As(err, &refusal) { + // A DB fault reading or writing expedition state. Nothing partial is + // left behind that a retry would double up, so leave it pending. + slog.Warn("orders: abandon failed", "user", owner, "err", err) + return "", "", true + } + status := "rejected_unavailable" + switch { + case errors.Is(err, errAbandonNothing): + status = "rejected_not_running" + case errors.Is(err, errAbandonNotLeader): + status = "rejected_not_leader" + } + return status, advOrderPlainText(err.Error()), false + } + // The extracted case keeps loot and XP, so saying "supplies are forfeit" there + // would be a straight lie. Same split the DM makes. + if out.Extracted { + return "applied", fmt.Sprintf( + "You let %s go on day %d. Loot, XP and coins are kept.", out.Zone.Display, out.Day), false + } + return "applied", fmt.Sprintf( + "Expedition in %s abandoned on day %d. Supplies are forfeit.", out.Zone.Display, out.Day), false +} + +// applyWebLeave walks a party member out of somebody else's expedition. +func (p *AdventurePlugin) applyWebLeave(owner id.UserID) (status, detail string, retry bool) { + // Same lock asymmetry as applyWebAbandon. + userMu := p.advUserLock(owner) + userMu.Lock() + defer userMu.Unlock() + + if err := p.performExpeditionLeave(owner); err != nil { + var refusal advRefusal + if !errors.As(err, &refusal) { + slog.Warn("orders: leave failed", "user", owner, "err", err) + return "", "", true + } + status := "rejected_unavailable" + switch { + case errors.Is(err, errLeaveNothing): + status = "rejected_not_running" + case errors.Is(err, errLeaveIsLeader): + status = "rejected_is_leader" + } + return status, advOrderPlainText(err.Error()), false + } + return "applied", "You turn back for town. Your supplies stay with the party.", false +} + +// applyWebBabysitCancel dismisses the pet sitter early. +func (p *AdventurePlugin) applyWebBabysitCancel(owner id.UserID) (status, detail string, retry bool) { + // No lock here, and that is not an oversight: performBabysitCancel takes it + // itself, because ITS Matrix caller does not. The opposite of the two above. + out, err := p.performBabysitCancel(owner) + if err != nil { + status := "rejected_unavailable" + switch { + case errors.Is(err, errBabysitNoSitter): + status = "rejected_nothing_to_cancel" + } + return status, advOrderPlainText(err.Error()), false + } + // The DM prints the sitter's whole record of the stay; a verdict is one line + // under a button, so the web gets the fact and the page keeps its shape. + note := "Sitter dismissed. No refund — they were already here." + if out.PetName != "" { + note = fmt.Sprintf("Sitter dismissed. No refund. %s is back in your care.", out.PetName) + } + return "applied", note, false +} + +// advOrderPlainText strips the Matrix markdown out of a line reused as a web +// verdict. Pete renders the detail as text, so asterisks and backticks would show +// up literally. The command hints inside those backticks stay — a verdict that +// says to type `!expedition abandon` is telling the truth about where the other +// door is, and the web has no button for it yet. +func advOrderPlainText(s string) string { + out := make([]rune, 0, len(s)) + for _, r := range s { + switch r { + case '*', '`': + continue + case '\n': + out = append(out, ' ') + default: + out = append(out, r) + } + } + return string(out) +} + +// ---- the applied-order ledger -------------------------------------------------- + +// advOrderAlreadyApplied reports the verdict we filed for an order, if we have +// already applied it. This is the short-circuit that keeps a re-offered order from +// re-running its non-idempotent mutation. +func advOrderAlreadyApplied(guid string) (status, detail string, ok bool) { + err := db.Get().QueryRow( + `SELECT status, detail FROM adv_applied_orders WHERE guid = ?`, guid, + ).Scan(&status, &detail) + if errors.Is(err, sql.ErrNoRows) { + return "", "", false + } + if err != nil { + // A read failure sends us down the mutation path and risks re-running an + // extraction or a bout, so it is logged loudly. A transient one self-heals: + // the mutation is guarded by this same table, so the next poll reads it. + slog.Error("orders: applied-ledger read failed", "order", guid, "err", err) + return "", "", false + } + return status, detail, true +} + +// recordAdvApplied stamps an order as applied with the verdict we're about to +// file. OR IGNORE so a re-file that somehow reaches here can't error on the guid. +func recordAdvApplied(guid, status, detail string) error { + _, err := db.Get().Exec( + `INSERT OR IGNORE INTO adv_applied_orders (guid, status, detail) VALUES (?, ?, ?)`, + guid, status, detail) + return err +} diff --git a/internal/plugin/pete_orders_test.go b/internal/plugin/pete_orders_test.go new file mode 100644 index 0000000..663d5d2 --- /dev/null +++ b/internal/plugin/pete_orders_test.go @@ -0,0 +1,385 @@ +package plugin + +import ( + "strings" + "testing" + "time" + + "gogobee/internal/db" + "gogobee/internal/peteclient" + "maunium.net/go/mautrix/id" +) + +// W5: the web action queue's game-side half. These pin the two things that would +// actually hurt in prod — a replayed order re-running a non-idempotent action, +// and a refusal being reported as a transient fault (which parks the order and +// leaves the player staring at "asked for…" forever). + +// TestAdvOrderLedgerShortCircuitsAReoffer is the regression for the whole class +// of bug this ledger exists for. A verdict-ack lost on the wire means Pete +// re-offers an order we have already applied; if that re-offer reached +// applyAdvOrder it would extract a run the player had resumed, or spend a bout +// they were saving. +func TestAdvOrderLedgerShortCircuitsAReoffer(t *testing.T) { + newMischiefTestDB(t) + + if _, _, ok := advOrderAlreadyApplied("guid-never-seen"); ok { + t.Fatal("an unknown guid reported as already applied") + } + if err := recordAdvApplied("guid-1", "applied", "Out of the Goblin Warrens on day 3."); err != nil { + t.Fatalf("recordAdvApplied: %v", err) + } + status, detail, ok := advOrderAlreadyApplied("guid-1") + if !ok || status != "applied" || !strings.Contains(detail, "Goblin Warrens") { + t.Fatalf("ledger read = %q/%q ok=%v, want the stored verdict back", status, detail, ok) + } + // A second stamp on the same guid must not error or overwrite — the re-file + // path can reach it. + if err := recordAdvApplied("guid-1", "rejected_not_running", "nonsense"); err != nil { + t.Fatalf("re-record: %v", err) + } + if status, _, _ := advOrderAlreadyApplied("guid-1"); status != "applied" { + t.Fatalf("verdict changed under a re-record: %q", status) + } +} + +// TestExtractOrderRefusalsAreTerminal: neither refusal may come back as retry. +// A retried refusal never reaches a verdict, so the order sits pending forever +// and Pete's strip never stops saying "asked for…". +func TestExtractOrderRefusalsAreTerminal(t *testing.T) { + // W9: was setupZoneRunTestDB, which copies data/gogobee.db and t.Skip()s when + // it is missing — and that file is deleted after every local run, so this and + // the extraction test below have been green-by-skipping since W5a. Neither + // needs a prod row; startExpedition builds everything they touch. + setupEmptyTestDB(t) + uid := id.UserID("@web-extract-none:example.org") + defer cleanupExpeditions(uid) + p := &AdventurePlugin{} + + status, detail, retry := p.applyAdvOrder(uid, peteclient.AdvOrder{ + GUID: "g", Action: peteclient.AdvOrderExtract, + }) + if retry { + t.Fatal("no-expedition extract asked for a retry; it must be terminal") + } + if status != "rejected_not_running" || detail == "" { + t.Fatalf("status = %q detail = %q, want rejected_not_running with prose", status, detail) + } +} + +// TestExtractOrderIsTheSameExtraction: the web verb must run the game's own +// extraction, not a lookalike. The proof is the state the row lands in — +// 'extracting' (a resumable limbo) rather than 'abandoned' — plus the day burn +// and the log line the DM path writes. +func TestExtractOrderIsTheSameExtraction(t *testing.T) { + setupEmptyTestDB(t) + uid := id.UserID("@web-extract-live:example.org") + defer cleanupExpeditions(uid) + p := &AdventurePlugin{} + + if _, err := startExpedition(uid, ZoneGoblinWarrens, "", ExpeditionSupplies{ + Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1, PacksStandard: 1, + }); err != nil { + t.Fatalf("startExpedition: %v", err) + } + + status, detail, retry := p.applyAdvOrder(uid, peteclient.AdvOrder{ + GUID: "g-extract", Action: peteclient.AdvOrderExtract, + }) + if retry || status != "applied" { + t.Fatalf("extract = %q retry=%v, want applied", status, retry) + } + if !strings.Contains(detail, "resume") { + t.Fatalf("verdict %q never mentions the resume window, which is the whole point of an extraction", detail) + } + + var dbStatus string + var day int + if err := db.Get().QueryRow( + `SELECT status, current_day FROM dnd_expedition WHERE user_id = ?`, string(uid), + ).Scan(&dbStatus, &day); err != nil { + t.Fatalf("read expedition: %v", err) + } + if dbStatus != ExpeditionStatusExtracting { + t.Fatalf("expedition status = %q, want %q — a web extract must be resumable like the command's", + dbStatus, ExpeditionStatusExtracting) + } + if day != 2 { + t.Fatalf("current_day = %d, want 2 — extraction burns the day", day) + } +} + +// TestSiegeJoinRefusalsAreTerminal covers the two refusals a bout can hit without +// running any combat: nothing camped, and a bout already spent today. Both must +// be terminal for the same reason as the extract refusals, and "already fought" +// especially — it is the one a double-click produces. +func TestSiegeJoinRefusalsAreTerminal(t *testing.T) { + newMischiefTestDB(t) + uid := id.UserID("@web-siege:example.org") + p := &AdventurePlugin{} + + status, _, retry := p.applyAdvOrder(uid, peteclient.AdvOrder{ + GUID: "g1", Action: peteclient.AdvOrderSiegeJoin, + }) + if retry || status != "rejected_no_siege" { + t.Fatalf("no-boss bout = %q retry=%v, want rejected_no_siege", status, retry) + } + + // Camp a boss and spend the day's bout, then ask again. + now := time.Now().UTC() + bossID, err := insertWorldBoss("Grelloth", 3, 18000, now.Add(-time.Hour), now.Add(48*time.Hour)) + if err != nil { + t.Fatalf("insertWorldBoss: %v", err) + } + if err := createAdvCharacter(uid, "Rurina"); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + today := now.Format("2006-01-02") + if err := upsertWorldBossContrib(bossID, uid, 250, today); err != nil { + t.Fatalf("upsertWorldBossContrib: %v", err) + } + + status, detail, retry := p.applyAdvOrder(uid, peteclient.AdvOrder{ + GUID: "g2", Action: peteclient.AdvOrderSiegeJoin, + }) + if retry || status != "rejected_already_fought" { + t.Fatalf("second bout = %q retry=%v, want rejected_already_fought", status, retry) + } + if detail == "" { + t.Fatal("a refusal with no prose leaves the strip saying nothing useful") + } +} + +// TestUnknownActionIsRejectedNotRetried: Pete validates the action before it ever +// queues one, so an unknown verb is a contract breach. Spinning on it would poll +// the same dead order every 15 seconds forever. +func TestUnknownActionIsRejectedNotRetried(t *testing.T) { + newMischiefTestDB(t) + p := &AdventurePlugin{} + status, _, retry := p.applyAdvOrder("@x:example.org", peteclient.AdvOrder{ + GUID: "g", Action: "sell_house", + }) + if retry || !strings.HasPrefix(status, "rejected_") { + t.Fatalf("unknown action = %q retry=%v, want a terminal rejection", status, retry) + } +} + +// TestAdvOrderPlainText: the Siege verdict is the Matrix footer reused, and Pete +// renders a verdict as text — so the markdown has to come off or the player reads +// literal asterisks. +func TestAdvOrderPlainText(t *testing.T) { + got := advOrderPlainText("💥 You deal **412** damage. **Grelloth** has **5,800 / 18,000 HP** left.\nYou stagger out at 1 HP.") + if strings.Contains(got, "*") || strings.Contains(got, "\n") { + t.Fatalf("plain text still carries markup or a newline: %q", got) + } + if !strings.Contains(got, "412") || !strings.Contains(got, "Grelloth") { + t.Fatalf("plain text lost the facts: %q", got) + } +} + +// ── W5b: the three verbs that take arguments and spend coins ─────────────────── + +// webOrderTestChar builds a character solvent enough to outfit an expedition. +func webOrderTestChar(t *testing.T, uid id.UserID, level int, coins float64) *AdventurePlugin { + t.Helper() + if err := createAdvCharacter(uid, "weborder"); err != nil { + t.Fatal(err) + } + c := &DnDCharacter{ + UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: level, + STR: 14, DEX: 12, CON: 14, INT: 10, WIS: 10, CHA: 10, + HPMax: 30, HPCurrent: 30, ArmorClass: 14, + } + if err := SaveDnDCharacter(c); err != nil { + t.Fatal(err) + } + euro := &EuroPlugin{} + euro.ensureBalance(uid) + if coins > 0 { + euro.Credit(uid, coins, "test bankroll") + } + return &AdventurePlugin{euro: euro} +} + +// A forged zone must buy nothing. Pete only ever offers what gogobee quoted it, +// but a quote is a stale snapshot and not a permission — so the order path +// re-resolves against availableZonesFor and refuses anything that isn't there. +func TestWebExpeditionStartReResolvesTheZone(t *testing.T) { + setupEmptyTestDB(t) + uid := id.UserID("@web-start-forged:example.org") + t.Cleanup(func() { cleanupExpeditions(uid); cleanupZoneRuns(uid) }) + p := webOrderTestChar(t, uid, 2, 100000) + + before := p.euro.GetBalance(uid) + status, _, retry := p.applyAdvOrder(uid, peteclient.AdvOrder{ + GUID: "forged-zone", Action: peteclient.AdvOrderExpedition, + Params: &peteclient.AdvOrderParams{Zone: "dragons_lair", Loadout: "lean"}, + }) + if retry { + t.Fatal("a locked zone asked for a retry; it must be terminal") + } + if status != "rejected_zone_locked" { + t.Fatalf("status = %q, want rejected_zone_locked", status) + } + if after := p.euro.GetBalance(uid); after != before { + t.Fatalf("a refused departure moved money: %.0f -> %.0f", before, after) + } + if exp, _ := getActiveExpedition(uid); exp != nil { + t.Fatal("a refused departure started an expedition anyway") + } +} + +// An unknown loadout is refused, never defaulted. Defaulting would spend coins on +// a pack size the player never picked. +func TestWebExpeditionStartRefusesAnUnknownLoadout(t *testing.T) { + setupEmptyTestDB(t) + uid := id.UserID("@web-start-loadout:example.org") + t.Cleanup(func() { cleanupExpeditions(uid); cleanupZoneRuns(uid) }) + p := webOrderTestChar(t, uid, 2, 100000) + + before := p.euro.GetBalance(uid) + status, _, _ := p.applyAdvOrder(uid, peteclient.AdvOrder{ + GUID: "bad-loadout", Action: peteclient.AdvOrderExpedition, + Params: &peteclient.AdvOrderParams{Zone: string(ZoneGoblinWarrens), Loadout: "enormous"}, + }) + if status != "rejected_unavailable" { + t.Fatalf("status = %q, want rejected_unavailable", status) + } + if after := p.euro.GetBalance(uid); after != before { + t.Fatalf("a refused loadout moved money: %.0f -> %.0f", before, after) + } +} + +// The money test that matters: a re-offered order (verdict-ack lost before the +// ledger stamped it) must not charge twice, and must not answer "you're already +// on an expedition" for the expedition it just started. +func TestWebExpeditionStartChargesOnceOnAReoffer(t *testing.T) { + setupEmptyTestDB(t) + uid := id.UserID("@web-start-idem:example.org") + t.Cleanup(func() { cleanupExpeditions(uid); cleanupZoneRuns(uid) }) + p := webOrderTestChar(t, uid, 2, 100000) + + order := peteclient.AdvOrder{ + GUID: "start-once", Action: peteclient.AdvOrderExpedition, + Params: &peteclient.AdvOrderParams{Zone: string(ZoneGoblinWarrens), Loadout: "lean"}, + } + before := p.euro.GetBalance(uid) + status, _, retry := p.applyAdvOrder(uid, order) + if retry || status != "applied" { + t.Fatalf("first apply = %q retry=%v, want applied", status, retry) + } + afterFirst := p.euro.GetBalance(uid) + if afterFirst >= before { + t.Fatalf("outfitting cost nothing: %.0f -> %.0f", before, afterFirst) + } + + // The re-offer. applyAdvOrder is reached directly here on purpose: the + // adv_applied_orders ledger would normally short-circuit it, and this asserts + // the layer *underneath* that guard is safe too. + status, _, retry = p.applyAdvOrder(uid, order) + if retry { + t.Fatal("the re-offer asked for a retry") + } + if status != "applied" { + t.Fatalf("re-offer = %q, want applied — the settled debit is what tells a "+ + "replay apart from a player who really is already out", status) + } + if after := p.euro.GetBalance(uid); after != afterFirst { + t.Fatalf("the re-offer charged again: %.0f -> %.0f", afterFirst, after) + } +} + +// Babysit: same replay contract, plus the two durations are the only two sold. +func TestWebBabysitChargesOnceAndSellsTwoDurations(t *testing.T) { + setupEmptyTestDB(t) + uid := id.UserID("@web-sitter:example.org") + p := webOrderTestChar(t, uid, 2, 100000) + + status, _, _ := p.applyAdvOrder(uid, peteclient.AdvOrder{ + GUID: "sitter-odd", Action: peteclient.AdvOrderBabysit, + Params: &peteclient.AdvOrderParams{Days: 3}, + }) + if status != "rejected_unavailable" { + t.Fatalf("3-day sitter = %q, want rejected_unavailable", status) + } + + order := peteclient.AdvOrder{ + GUID: "sitter-once", Action: peteclient.AdvOrderBabysit, + Params: &peteclient.AdvOrderParams{Days: 7}, + } + before := p.euro.GetBalance(uid) + if status, _, _ := p.applyAdvOrder(uid, order); status != "applied" { + t.Fatalf("hire = %q, want applied", status) + } + afterFirst := p.euro.GetBalance(uid) + if afterFirst >= before { + t.Fatalf("the sitter worked for free: %.0f -> %.0f", before, afterFirst) + } + if status, _, _ := p.applyAdvOrder(uid, order); status != "applied" { + t.Fatalf("re-offer = %q, want applied", status) + } + if after := p.euro.GetBalance(uid); after != afterFirst { + t.Fatalf("the re-offer charged again: %.0f -> %.0f", afterFirst, after) + } +} + +// A refusal must never come back as retry: a retried refusal never reaches a +// verdict, so the order parks and the strip says "asked for…" forever. +func TestWebMoneyVerbRefusalsAreTerminal(t *testing.T) { + setupEmptyTestDB(t) + uid := id.UserID("@web-broke:example.org") + t.Cleanup(func() { cleanupExpeditions(uid); cleanupZoneRuns(uid) }) + p := webOrderTestChar(t, uid, 2, 0) + + for _, tc := range []struct { + name string + order peteclient.AdvOrder + want string + }{ + {"broke departure", peteclient.AdvOrder{ + GUID: "broke-1", Action: peteclient.AdvOrderExpedition, + Params: &peteclient.AdvOrderParams{Zone: string(ZoneGoblinWarrens), Loadout: "heavy"}, + }, "rejected_insufficient_funds"}, + {"nothing to resume", peteclient.AdvOrder{ + GUID: "resume-1", Action: peteclient.AdvOrderResume, + Params: &peteclient.AdvOrderParams{Loadout: "lean"}, + }, "rejected_nothing_to_resume"}, + {"broke sitter", peteclient.AdvOrder{ + GUID: "broke-2", Action: peteclient.AdvOrderBabysit, + Params: &peteclient.AdvOrderParams{Days: 30}, + }, "rejected_insufficient_funds"}, + } { + status, detail, retry := p.applyAdvOrder(uid, tc.order) + if retry { + t.Fatalf("%s asked for a retry; it must be terminal", tc.name) + } + if status != tc.want { + t.Fatalf("%s = %q, want %q (detail %q)", tc.name, status, tc.want, detail) + } + if strings.ContainsAny(detail, "*`") { + t.Fatalf("%s verdict still carries Matrix markdown: %q", tc.name, detail) + } + } +} + +// An order with no params at all is a contract breach, not a user mistake, and +// must be refused rather than defaulted into spending money. +func TestWebMoneyVerbsRefuseMissingParams(t *testing.T) { + setupEmptyTestDB(t) + uid := id.UserID("@web-noparams:example.org") + t.Cleanup(func() { cleanupExpeditions(uid); cleanupZoneRuns(uid) }) + p := webOrderTestChar(t, uid, 2, 100000) + + before := p.euro.GetBalance(uid) + for _, action := range []string{ + peteclient.AdvOrderExpedition, peteclient.AdvOrderResume, peteclient.AdvOrderBabysit, + } { + status, _, retry := p.applyAdvOrder(uid, peteclient.AdvOrder{GUID: "np-" + action, Action: action}) + if retry || status != "rejected_unavailable" { + t.Fatalf("%s with no params = %q retry=%v, want rejected_unavailable", action, status, retry) + } + } + if after := p.euro.GetBalance(uid); after != before { + t.Fatalf("a paramless order moved money: %.0f -> %.0f", before, after) + } +} diff --git a/internal/plugin/pete_orders_undo_test.go b/internal/plugin/pete_orders_undo_test.go new file mode 100644 index 0000000..a8f3b51 --- /dev/null +++ b/internal/plugin/pete_orders_undo_test.go @@ -0,0 +1,209 @@ +package plugin + +import ( + "strings" + "testing" + "time" + + "gogobee/internal/peteclient" + "maunium.net/go/mautrix/id" +) + +// W9: the three web verbs that undo something — abandon an expedition, walk out +// of somebody else's party, send the sitter home. +// +// The thing worth pinning here is not the verdict text, it is the LOCK. Each of +// these three now has a headless twin shared between a Matrix command and the web +// order path, and the two halves take advUserLock on opposite sides: the two +// expedition twins cannot take it (their Matrix caller holds it across the whole +// `!expedition` switch) and their web wrappers must, while the babysit twin takes +// it itself and its web wrapper must not. +// +// Getting either of those backwards does not fail loudly. advUserLock is a plain +// sync.Mutex, so a second acquire parks the goroutine forever with the deferred +// Unlock never running — which wedges every later !adventure / !expedition / +// !zone command from that player, not just the one that deadlocked. That is a +// bug that shipped once already (see TestExpeditionAliasesDoNotWedgeTheUserLock), +// so both directions are tested here. +// +// NOTE: these two lock tests HANG rather than fail on regression. The timeout is +// the assertion. + +// TestUndoCommandsDoNotWedgeTheUserLock is the Matrix half: `!expedition abandon` +// and `!expedition leave` reach their twins with the lock already held, so a twin +// that took it itself would park here. +func TestUndoCommandsDoNotWedgeTheUserLock(t *testing.T) { + setupEmptyTestDB(t) + uid := id.UserID("@w9-cmd-lock:example") + t.Cleanup(func() { cleanupExpeditions(uid) }) + + for _, sub := range []string{"abandon", "leave"} { + done := make(chan struct{}) + go func() { + defer close(done) + p := &AdventurePlugin{euro: &EuroPlugin{}} + _ = p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, sub) + }() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatalf("!expedition %s never returned: its twin re-took advUserLock", sub) + } + } +} + +// TestUndoOrdersDoNotWedgeTheUserLock is the web half, and it checks the harder +// half of the same property: not just that the order returns, but that the lock +// is FREE afterwards. A wrapper that took the lock around a twin that also takes +// it would park inside applyAdvOrder; a wrapper that forgot to release would let +// the order finish and wedge the next command instead, which is the failure that +// would have been missed by only timing the call. +func TestUndoOrdersDoNotWedgeTheUserLock(t *testing.T) { + setupEmptyTestDB(t) + uid := id.UserID("@w9-order-lock:example") + t.Cleanup(func() { cleanupExpeditions(uid) }) + + for _, action := range []string{ + peteclient.AdvOrderAbandon, + peteclient.AdvOrderLeave, + peteclient.AdvOrderBabysitCancel, + } { + done := make(chan struct{}) + go func() { + defer close(done) + p := &AdventurePlugin{euro: &EuroPlugin{}} + p.applyAdvOrder(uid, peteclient.AdvOrder{GUID: "g-" + action, Action: action}) + // The lock must be back. Taking it here is what catches a wrapper that + // returned without unlocking. + mu := p.advUserLock(uid) + mu.Lock() + mu.Unlock() + }() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatalf("order %q never returned or never released advUserLock", action) + } + } +} + +// TestUndoOrderRefusalsAreTerminal: none of the three may come back as a retry. +// A retried refusal never reaches a verdict, so the order sits pending forever +// and the panel never stops saying "asked for…". Each also has to carry prose — +// the strip prefers gogobee's own sentence over its canned fallback, and an empty +// detail on a refusal is the one case where the page has nothing to show. +func TestUndoOrderRefusalsAreTerminal(t *testing.T) { + setupEmptyTestDB(t) + uid := id.UserID("@w9-refusals:example") + t.Cleanup(func() { cleanupExpeditions(uid) }) + // A real character with nothing going on. Without one, babysit_cancel refuses + // with "no adventurer" and the sitter branch this is meant to cover is never + // reached — the first cut of this test passed the wrong assertion for that + // reason. + if err := createAdvCharacter(uid, "w9refusals"); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + p := &AdventurePlugin{euro: &EuroPlugin{}} + + cases := []struct { + action string + want string + }{ + // Nothing to abandon and nothing to leave are the same fact from two + // doors, and both are the plain "you aren't on one" answer. + {peteclient.AdvOrderAbandon, "rejected_not_running"}, + {peteclient.AdvOrderLeave, "rejected_not_running"}, + // No sitter is its own verdict rather than rejected_unavailable: the page + // says something specific about it, and "unavailable" reads as a fault. + {peteclient.AdvOrderBabysitCancel, "rejected_nothing_to_cancel"}, + } + for _, tc := range cases { + status, detail, retry := p.applyAdvOrder(uid, peteclient.AdvOrder{ + GUID: "g-" + tc.action, Action: tc.action, + }) + if retry { + t.Fatalf("%s asked for a retry on a refusal; it must be terminal", tc.action) + } + if status != tc.want { + t.Fatalf("%s status = %q, want %q", tc.action, status, tc.want) + } + if strings.TrimSpace(detail) == "" { + t.Fatalf("%s refused with no prose; the panel would have nothing to say", tc.action) + } + } +} + +// TestUndoVerdictsCarryNoMarkdown: every detail line these file is reused from a +// Matrix sentence, and Pete renders a verdict as text. An asterisk or a backtick +// left in it shows up literally under the button. +// +// The backticks matter more than they look: the leave refusal names `!extract` +// and `!expedition abandon`, which is the correct thing to say (the web now has a +// button for one of them and not the other), but it must not say it in markup. +func TestUndoVerdictsCarryNoMarkdown(t *testing.T) { + setupEmptyTestDB(t) + uid := id.UserID("@w9-markdown:example") + t.Cleanup(func() { cleanupExpeditions(uid) }) + p := &AdventurePlugin{euro: &EuroPlugin{}} + + for _, action := range []string{ + peteclient.AdvOrderAbandon, + peteclient.AdvOrderLeave, + peteclient.AdvOrderBabysitCancel, + } { + _, detail, _ := p.applyAdvOrder(uid, peteclient.AdvOrder{GUID: "g-md-" + action, Action: action}) + if strings.ContainsAny(detail, "*`\n") { + t.Fatalf("%s verdict carries markdown or a newline: %q", action, detail) + } + } +} + +// TestWebAbandonIsTheGamesOwnAbandon: the web verb must run the real path, not a +// lookalike. The proof is the state the row lands in — no expedition left at all, +// as opposed to the 'extracting' limbo an extraction leaves behind — and that the +// verdict says what became of the supplies, which is the one thing a player who +// clicked the wrong button needs to be told. +func TestWebAbandonIsTheGamesOwnAbandon(t *testing.T) { + // setupEmptyTestDB, NOT setupZoneRunTestDB: the latter copies data/gogobee.db + // and t.Skip()s when it is missing, and that file is deleted after every local + // run — so a test written on it is green-by-skipping on any clean checkout. + // See the Decisions note in the plan's progress file; W5a's order tests were + // silently skipping for exactly this reason. + setupEmptyTestDB(t) + uid := id.UserID("@w9-abandon-live:example.org") + t.Cleanup(func() { cleanupExpeditions(uid) }) + if err := createAdvCharacter(uid, "w9abandon"); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + p := &AdventurePlugin{euro: &EuroPlugin{}} + + if _, err := startExpedition(uid, ZoneGoblinWarrens, "", ExpeditionSupplies{ + Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1, PacksStandard: 1, + }); err != nil { + t.Fatalf("startExpedition: %v", err) + } + + status, detail, retry := p.applyAdvOrder(uid, peteclient.AdvOrder{ + GUID: "g-abandon", Action: peteclient.AdvOrderAbandon, + }) + if retry || status != "applied" { + t.Fatalf("abandon = %q retry=%v detail=%q, want applied", status, retry, detail) + } + if !strings.Contains(strings.ToLower(detail), "supplies") { + t.Fatalf("verdict %q never says the supplies are gone, which is the difference from pulling out", detail) + } + if exp, _, err := activeExpeditionFor(uid); err != nil { + t.Fatalf("read expedition: %v", err) + } else if exp != nil { + t.Fatalf("expedition survived a web abandon with status %q", exp.Status) + } + + // And the second click, which is what a stale page produces: a terminal + // refusal, never a retry and never a second abandon. + status, _, retry = p.applyAdvOrder(uid, peteclient.AdvOrder{ + GUID: "g-abandon-2", Action: peteclient.AdvOrderAbandon, + }) + if retry || status != "rejected_not_running" { + t.Fatalf("re-abandon = %q retry=%v, want a terminal rejected_not_running", status, retry) + } +} diff --git a/internal/plugin/pete_realm.go b/internal/plugin/pete_realm.go new file mode 100644 index 0000000..b748147 --- /dev/null +++ b/internal/plugin/pete_realm.go @@ -0,0 +1,633 @@ +package plugin + +import ( + "context" + "database/sql" + "log/slog" + "sort" + "time" + + "gogobee/internal/db" + "gogobee/internal/peteclient" + + "maunium.net/go/mautrix/id" +) + +// The realm snapshot: the world map, the hall of firsts, and the board. +// +// Everything Pete has shown so far is either the present moment (the roster, the +// Siege bar) or one thing that happened (a dispatch, a run log). None of it says +// what the realm *is* — that there are thirty-odd named places with a difficulty +// order, that some of them have never been beaten by anybody, and that the +// people playing have a history against them. That is what this carries. +// +// Snapshot semantics, like the roster and the Siege: pushed whole, replaces +// Pete's copy, dropped rather than retried on failure. The difference is the +// clock. The roster is a photograph of where people are standing and is worth +// re-taking every two minutes; a realm-first is a thing that happened once, ever, +// and re-deriving the whole ledger plus three aggregate scans at that rate would +// be pure waste. So this rides the same ticker at a much longer stride. +const ( + // realmPushInterval — how often the realm is recomputed and pushed. The + // fastest-moving field in the whole snapshot is a zone's occupant list, and a + // ten-minute-old answer to "who is in the Sunken Vault" is still a true and + // useful one. Everything else moves on the scale of days. + realmPushInterval = 10 * time.Minute + + // realmMaxOccupants bounds the per-zone occupant list. A realm has tens of + // players; this only stops a pathological case spooling a huge payload. + realmMaxOccupants = 50 +) + +// realmLastPush is when the realm snapshot last went out. Zero means never, so +// the first tick after start-up always pushes — an operator restarting the bot +// should not have to wait ten minutes to see whether the wire works. +var realmLastPush time.Time + +// realmPushOK mirrors rosterPushOK: log the transitions and nothing else. +var realmPushOK bool + +// pushRealm recomputes and sends the realm snapshot, at most once per +// realmPushInterval however often the ticker calls it. +func (p *AdventurePlugin) pushRealm() { + now := time.Now().UTC() + if !realmLastPush.IsZero() && now.Sub(realmLastPush) < realmPushInterval { + return + } + + snap, err := buildRealmSnapshot(now) + if err != nil { + slog.Error("realm: build snapshot failed", "err", err) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), rosterPushTimeout) + defer cancel() + + if err := peteclient.PushRealm(ctx, snap); err != nil { + if realmPushOK { + slog.Warn("realm: push failed, realm pages will go stale on Pete", "err", err) + } else { + slog.Debug("realm: push failed, dropping snapshot", "err", err) + } + realmPushOK = false + // Deliberately NOT stamping realmLastPush: a failed push should be retried + // on the next roster tick, not ten minutes from now. The stamp is a + // "we already told Pete this" marker, and we didn't. + return + } + + realmLastPush = now + if !realmPushOK { + slog.Info("realm: snapshot accepted by Pete — realm pages are publishing", + "zones", len(snap.Zones), "firsts", len(snap.Firsts), "standings", len(snap.Standings)) + realmPushOK = true + } +} + +// realmClearStats is the per-zone clear history, read in one pass. +type realmClearStats struct { + clears int + clearers int + firstUser id.UserID + firstClearAt int64 +} + +// buildRealmSnapshot assembles the whole realm from the game's own tables. +// +// The three aggregate reads are done up front and once each, keyed into maps, +// rather than per-zone or per-player: this runs against the live DB on a ticker +// and a query-per-zone loop over a registry that only grows is the kind of thing +// that is fine until it isn't. +func buildRealmSnapshot(now time.Time) (peteclient.RealmSnapshot, error) { + snap := peteclient.RealmSnapshot{SnapshotAt: now.Unix()} + + clearsByZone, err := loadRealmClearStats() + if err != nil { + return snap, err + } + occupantsByZone := loadRealmOccupants() + + // ── Zones ─────────────────────────────────────────────────────────────── + // zoneOrder is the design-doc ordering and is what the page draws in, so the + // realm reads the way it was designed rather than the way a map iterates. + for _, zid := range zoneOrder { + def, ok := dndZoneRegistry[zid] + if !ok { + continue + } + z := peteclient.RealmZone{ + ID: string(def.ID), + Display: def.Display, + Tier: int(def.Tier), + LevelMin: def.LevelMin, + LevelMax: def.LevelMax, + Faction: def.Faction, + Atmosphere: def.Atmosphere, + Postgame: def.Tier == ZoneTierMythic, + Occupants: occupantsByZone[string(def.ID)], + } + if st, ok := clearsByZone[string(def.ID)]; ok { + z.Clears = st.clears + z.Clearers = st.clearers + z.FirstClearAt = st.firstClearAt + // An opted-out first-clearer keeps the claim and loses the identity — + // the Siege contributor rule, and for the same reason. Deleting the + // claim outright would leave the zone drawn as never-cleared, which is + // a false statement about the realm rather than a withheld one. + if !isNewsOptedOut(st.firstUser) { + z.FirstClearBy = charName(st.firstUser) + if z.FirstClearBy != "" { + z.FirstClearToken = eventToken(st.firstUser, "roster") + } + } + } + snap.Zones = append(snap.Zones, z) + } + + snap.Firsts = loadRealmFirsts(clearsByZone) + + // The three pages must not be able to contradict each other about the same + // zone. loadRealmFirsts derives the zone half of the hall from clearsByZone — + // the same map the tiles and the board use — but it also carries any ledger + // claim with no surviving run behind it, and that case would otherwise draw a + // place as never-beaten on the map while the hall named the year it fell. + // + // So an unbacked claim floors the clear count at one. Deliberately a floor and + // not an assignment: where the run history is intact it is the better answer + // and this only fills a hole. Nothing is attributed — the zone reads "cleared, + // by somebody", which is exactly what is known about it. + for i := range snap.Zones { + if snap.Zones[i].Clears > 0 { + continue + } + for _, f := range snap.Firsts { + if f.Kind == "zone" && f.Target == snap.Zones[i].ID { + snap.Zones[i].Clears = 1 + snap.Zones[i].Clearers = 1 + break + } + } + } + + snap.Standings, err = loadRealmStandings(clearsByZone) + if err != nil { + return snap, err + } + return snap, nil +} + +// loadRealmClearStats reads every zone's clear history in one pass: how many +// successful runs, how many distinct people managed it, and who did it first. +// +// MIN(completed_at) with a bare user_id column is SQLite's bare-column min/max +// rule — the user_id comes from the same row the minimum came from, so the +// (zone, first clearer, when) triple is internally consistent. backfillZoneFirsts +// relies on exactly this and has done since the news seam shipped. +// +// completed_at is selected raw as a string and parsed in Go rather than being +// wrapped in anything: modernc.org/sqlite rebuilds a time.Time from the column's +// declared type, and passing a DATETIME through MIN() alongside an aggregate is +// close enough to the COALESCE() trap that it is not worth finding out. The +// string always parses — it is written by SQLite's own CURRENT_TIMESTAMP. +// +// NOTE the absence of `AND abandoned = 0`, which looks like it belongs here and +// does not. `abandoned` does not mean "the player gave up" — it means the run +// ROW was retired, and abandonZoneRunByID exists specifically to retire a run +// whose boss is already dead when the expedition travels onward (see its comment +// in dnd_zone_run.go). In prod, 30 of the realm's 32 boss kills carry +// abandoned = 1. Filtering them out drew a map on which almost nothing had ever +// been beaten, while the hall of firsts — reading a different table — said six +// zones had been. boss_defeated = 1 is the clear, full stop. +func loadRealmClearStats() (map[string]realmClearStats, error) { + rows, err := db.Get().Query(` + SELECT zone_id, + COUNT(*) AS clears, + COUNT(DISTINCT user_id) AS clearers, + user_id, + MIN(completed_at) + FROM dnd_zone_run + WHERE boss_defeated = 1 AND completed_at IS NOT NULL + GROUP BY zone_id`) + if err != nil { + return nil, err + } + defer rows.Close() + + out := map[string]realmClearStats{} + for rows.Next() { + var zoneID, userID, completedAt string + var st realmClearStats + if err := rows.Scan(&zoneID, &st.clears, &st.clearers, &userID, &completedAt); err != nil { + return nil, err + } + st.firstUser = id.UserID(userID) + if ts, ok := parseSQLiteTime(completedAt); ok { + st.firstClearAt = ts.Unix() + } + out[zoneID] = st + } + return out, rows.Err() +} + +// loadRealmOccupants answers "who is in there right now", per zone. +// +// Presence is dropped for an opted-out player rather than anonymised. Unlike a +// first clear it is not part of a tally that stops adding up without them, and +// it is the same live-location fact the run liveblog refuses to publish — an +// anonymous "somebody is in the Drowned Star" next to a roster showing exactly +// one person out on expedition is not an anonymisation. +// +// Errors are swallowed to nil: an unreadable expedition table should cost the +// realm map its occupant dots, not the whole page. +func loadRealmOccupants() map[string][]peteclient.RealmOccupant { + rows, err := db.Get().Query( + `SELECT user_id, zone_id, current_day FROM dnd_expedition WHERE status = 'active'`) + if err != nil { + slog.Error("realm: occupants query", "err", err) + return nil + } + defer rows.Close() + + type live struct { + uid id.UserID + zoneID string + day int + } + var found []live + for rows.Next() { + var uid, zoneID string + var day int + if err := rows.Scan(&uid, &zoneID, &day); err != nil { + slog.Error("realm: occupants scan", "err", err) + return nil + } + found = append(found, live{id.UserID(uid), zoneID, day}) + } + if err := rows.Err(); err != nil { + slog.Error("realm: occupants rows", "err", err) + return nil + } + + // Names and opt-out are resolved only after the cursor is drained. The pool + // is one connection wide and charName reads the DB; resolving inside the loop + // is the deadlock W2a shipped and then had to fix. + out := map[string][]peteclient.RealmOccupant{} + for _, l := range found { + if isNewsOptedOut(l.uid) { + continue + } + name := charName(l.uid) + if name == "" { + continue // never fall back to a Matrix handle on a public page + } + if len(out[l.zoneID]) >= realmMaxOccupants { + continue + } + out[l.zoneID] = append(out[l.zoneID], peteclient.RealmOccupant{ + Token: eventToken(l.uid, "roster"), + Name: name, + Level: charLevel(l.uid), + Day: l.day, + }) + } + for zid := range out { + sort.Slice(out[zid], func(i, j int) bool { return out[zid][i].Name < out[zid][j].Name }) + } + return out +} + +// loadRealmFirsts renders news_realm_firsts as a history book. +// +// The ledger stores only (kind, target, first_at) — it exists to tier a dispatch, +// not to remember who. The holder is recovered here from the game's own history: +// a zone first from the earliest boss-defeating run, a treasure first from the +// earliest surviving row in adventure_treasures. Both can come back empty — a +// treasure that was found and later discarded leaves no owner anywhere — and an +// unattributed first is rendered as one rather than dropped. It still happened. +// loadRealmFirsts renders the hall of firsts, and it takes the clear stats +// rather than reading the ledger alone, for two reasons that only showed up +// against real prod data: +// +// 1. news_realm_firsts is INCOMPLETE for zones. It has only been written since +// the news seam went live, and the one-shot that seeded it filtered on +// `abandoned = 0` — the same wrong filter loadRealmClearStats documents — so +// it missed every zone whose clears were all retired runs. In prod it holds 6 +// zones where the run history knows 9. +// 2. Its first_at is when the CLAIM was recorded, not when the thing happened. +// Every backfilled row in prod carries the same timestamp: the minute the +// backfill ran. A history book dated by when somebody wrote it down is not +// much of a history book. +// +// So the zone half is derived from the run history, which is complete and +// correctly dated, and the ledger supplies the kinds the run history knows +// nothing about (treasures, and whatever ships next) plus any zone claim with no +// surviving run behind it. That also makes the hall agree with the board by +// construction: both count a zone-first as "you were the first to clear it". +func loadRealmFirsts(clearsByZone map[string]realmClearStats) []peteclient.RealmFirst { + rows, err := db.Get().Query( + `SELECT kind, target, first_at FROM news_realm_firsts ORDER BY first_at ASC`) + if err != nil { + slog.Error("realm: firsts query", "err", err) + return nil + } + defer rows.Close() + + var out []peteclient.RealmFirst + for rows.Next() { + var f peteclient.RealmFirst + if err := rows.Scan(&f.Kind, &f.Target, &f.AtUnix); err != nil { + slog.Error("realm: firsts scan", "err", err) + return nil + } + out = append(out, f) + } + if err := rows.Err(); err != nil { + slog.Error("realm: firsts rows", "err", err) + return nil + } + + // Same discipline as the occupants: the cursor is closed before anything + // else touches the database. + rows.Close() + + // Drop the ledger's zone rows wherever the run history has the same zone — + // it is the better record of both who and when. A claim with no run behind it + // survives, unattributed, and is what floors that zone's clear count in + // buildRealmSnapshot. + kept := out[:0] + for _, f := range out { + if f.Kind == "zone" { + if _, ok := clearsByZone[f.Target]; ok { + continue + } + } + kept = append(kept, f) + } + out = kept + + // The zone half, from the authority the map and the board also use. + for zoneID, st := range clearsByZone { + zone := zoneOrFallback(ZoneID(zoneID)) + f := peteclient.RealmFirst{ + Kind: "zone", + Target: zoneID, + Display: zone.Display, + Tier: int(zone.Tier), + AtUnix: st.firstClearAt, + } + if !isNewsOptedOut(st.firstUser) { + if name := charName(st.firstUser); name != "" { + f.Holder = name + f.Token = eventToken(st.firstUser, "roster") + } + } + out = append(out, f) + } + + for i := range out { + switch out[i].Kind { + case "zone": + if out[i].Display == "" { + zone := zoneOrFallback(ZoneID(out[i].Target)) + out[i].Display = zone.Display + out[i].Tier = int(zone.Tier) + out[i].Holder, out[i].Token = realmFirstZoneHolder(out[i].Target) + } + case "treasure": + if def := lookupAdvTreasureDef(out[i].Target); def != nil { + out[i].Display = def.Name + out[i].Tier = def.Tier + } else { + out[i].Display = out[i].Target + } + out[i].Holder, out[i].Token = realmFirstTreasureHolder(out[i].Target) + default: + // A kind nobody has taught this function about still belongs in the + // hall — it is a genuine realm-first and the ledger says so. It just + // arrives with the raw target as its name, which is the same + // degrade-don't-drop rule the unknown event_type inversion settled on. + out[i].Display = out[i].Target + } + } + + // Oldest first: the order it happened in. Pete regroups it newest-year-first + // for the page, but the wire carries history in history's order. + sort.Slice(out, func(i, j int) bool { + if out[i].AtUnix != out[j].AtUnix { + return out[i].AtUnix < out[j].AtUnix + } + return out[i].Target < out[j].Target + }) + return out +} + +// realmFirstZoneHolder names the earliest clearer of a zone. Returns ("", "") +// when the run history no longer has one, and ("Name", "") when it does but the +// player has opted out — the claim survives the anonymisation, the link does not. +func realmFirstZoneHolder(zoneID string) (name, token string) { + var userID string + err := db.Get().QueryRow(` + SELECT user_id + FROM dnd_zone_run + WHERE zone_id = ? AND boss_defeated = 1 AND completed_at IS NOT NULL + ORDER BY completed_at ASC + LIMIT 1`, zoneID).Scan(&userID) + if err != nil { + if err != sql.ErrNoRows { + slog.Error("realm: zone-first holder", "zone", zoneID, "err", err) + } + return "", "" + } + uid := id.UserID(userID) + if isNewsOptedOut(uid) { + return "", "" + } + name = charName(uid) + if name == "" { + return "", "" + } + return name, eventToken(uid, "roster") +} + +// realmFirstTreasureHolder names the earliest holder of a treasure key. A +// treasure writes one row per bonus, so the MIN is over what may be several rows +// for the same acquisition; that is fine, they share a timestamp. +func realmFirstTreasureHolder(key string) (name, token string) { + var userID string + err := db.Get().QueryRow(` + SELECT user_id + FROM adventure_treasures + WHERE treasure_key = ? + ORDER BY acquired_at ASC + LIMIT 1`, key).Scan(&userID) + if err != nil { + if err != sql.ErrNoRows { + slog.Error("realm: treasure-first holder", "key", key, "err", err) + } + return "", "" + } + uid := id.UserID(userID) + if isNewsOptedOut(uid) { + return "", "" + } + name = charName(uid) + if name == "" { + return "", "" + } + return name, eventToken(uid, "roster") +} + +// loadRealmStandings builds the board: one line per living, named, opted-in +// adventurer, every number a lifetime total. +// +// It walks player_meta the way buildRosterSnapshot does, and for the same +// reason — that is the list of people who exist, and a standings table assembled +// by grouping the run history instead would silently include characters that have +// since been deleted or never finished setup. +func loadRealmStandings(clearsByZone map[string]realmClearStats) ([]peteclient.RealmStanding, error) { + rows, err := db.Get().Query(`SELECT user_id FROM player_meta WHERE alive = 1`) + if err != nil { + return nil, err + } + defer rows.Close() + + var uids []id.UserID + for rows.Next() { + var uid string + if err := rows.Scan(&uid); err != nil { + return nil, err + } + uids = append(uids, id.UserID(uid)) + } + if err := rows.Err(); err != nil { + return nil, err + } + rows.Close() + + // Who holds how many realm-firsts, from the same authority the zone column + // uses — so a zone's "first cleared by X" and X's firsts count can never + // disagree with each other. + firstsBy := map[id.UserID]int{} + for _, st := range clearsByZone { + firstsBy[st.firstUser]++ + } + + perZone, err := loadRealmPlayerClears() + if err != nil { + return nil, err + } + siege := loadRealmSiegeTotals() + + var out []peteclient.RealmStanding + for _, uid := range uids { + if isNewsOptedOut(uid) { + continue // the board omits an opted-out player outright, as it always has + } + c, err := LoadDnDCharacter(uid) + if err != nil || c == nil || c.PendingSetup { + continue + } + name := charName(uid) + if name == "" { + continue + } + s := peteclient.RealmStanding{ + Token: eventToken(uid, "roster"), + Name: name, + Level: c.Level, + ClassRace: classRaceLabel(c), + Firsts: firstsBy[uid], + SiegeDamage: siege[uid].damage, + SiegeFights: siege[uid].fights, + } + for zoneID, n := range perZone[uid] { + s.Clears += n + s.Zones++ + if t := int(zoneOrFallback(ZoneID(zoneID)).Tier); t > s.DeepestTier { + s.DeepestTier = t + } + } + out = append(out, s) + } + + // Ranked here, not on Pete: the ordering is a statement about the game + // ("deepest tier beaten, then how much of the realm you have beaten"), and + // the game is the thing that gets to make it. Name breaks the tie so the + // board is stable between snapshots that are otherwise identical. + sort.Slice(out, func(i, j int) bool { + a, b := out[i], out[j] + switch { + case a.DeepestTier != b.DeepestTier: + return a.DeepestTier > b.DeepestTier + case a.Zones != b.Zones: + return a.Zones > b.Zones + case a.Clears != b.Clears: + return a.Clears > b.Clears + case a.Level != b.Level: + return a.Level > b.Level + default: + return a.Name < b.Name + } + }) + return out, nil +} + +// loadRealmPlayerClears returns clears[user][zone] = count, in one pass. +func loadRealmPlayerClears() (map[id.UserID]map[string]int, error) { + rows, err := db.Get().Query(` + SELECT user_id, zone_id, COUNT(*) + FROM dnd_zone_run + WHERE boss_defeated = 1 AND completed_at IS NOT NULL + GROUP BY user_id, zone_id`) + if err != nil { + return nil, err + } + defer rows.Close() + + out := map[id.UserID]map[string]int{} + for rows.Next() { + var uid, zoneID string + var n int + if err := rows.Scan(&uid, &zoneID, &n); err != nil { + return nil, err + } + u := id.UserID(uid) + if out[u] == nil { + out[u] = map[string]int{} + } + out[u][zoneID] = n + } + return out, rows.Err() +} + +type realmSiegeTotal struct{ damage, fights int } + +// loadRealmSiegeTotals sums every Siege a player has ever turned up to. Across +// all bosses, not just the live one — the war room already shows the current +// muster, and what the board is for is the person who has shown up to all six. +func loadRealmSiegeTotals() map[id.UserID]realmSiegeTotal { + rows, err := db.Get().Query( + `SELECT user_id, SUM(damage), SUM(fights) FROM world_boss_contrib GROUP BY user_id`) + if err != nil { + slog.Error("realm: siege totals query", "err", err) + return nil + } + defer rows.Close() + + out := map[id.UserID]realmSiegeTotal{} + for rows.Next() { + var uid string + var damage, fights int + if err := rows.Scan(&uid, &damage, &fights); err != nil { + slog.Error("realm: siege totals scan", "err", err) + return nil + } + out[id.UserID(uid)] = realmSiegeTotal{damage, fights} + } + if err := rows.Err(); err != nil { + slog.Error("realm: siege totals rows", "err", err) + return nil + } + return out +} diff --git a/internal/plugin/pete_realm_test.go b/internal/plugin/pete_realm_test.go new file mode 100644 index 0000000..7fdb6dd --- /dev/null +++ b/internal/plugin/pete_realm_test.go @@ -0,0 +1,351 @@ +package plugin + +import ( + "testing" + "time" + + "gogobee/internal/db" + + "maunium.net/go/mautrix/id" +) + +// seedRealmFixture builds a small but realistic realm: two players, three +// cleared runs across two zones, one live expedition, and the realm-first ledger +// that the zone clears would have seeded. +func seedRealmFixture(t *testing.T) { + t.Helper() + dir := t.TempDir() + db.Close() + if err := db.Init(dir); err != nil { + t.Fatalf("db.Init: %v", err) + } + t.Cleanup(db.Close) + + db.Exec("seed josie", `INSERT INTO player_meta (user_id, display_name, alive) VALUES (?, ?, 1)`, + "@josie:x", "Josie") + db.Exec("seed quack", `INSERT INTO player_meta (user_id, display_name, alive) VALUES (?, ?, 1)`, + "@quack:x", "Quack") + + // The board walks player_meta and then loads a character, so both halves have + // to exist: a player_meta row with no character is somebody who never finished + // setup, and standings correctly leaves them off. + for _, c := range []*DnDCharacter{ + {UserID: "@josie:x", Race: RaceHuman, Class: ClassFighter, Level: 12, + STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 10, CHA: 10, + HPMax: 120, HPCurrent: 120, ArmorClass: 18}, + {UserID: "@quack:x", Race: RaceElf, Class: ClassMage, Level: 8, + STR: 8, DEX: 16, CON: 12, INT: 18, WIS: 12, CHA: 10, + HPMax: 48, HPCurrent: 48, ArmorClass: 12}, + } { + if err := SaveDnDCharacter(c); err != nil { + t.Fatalf("SaveDnDCharacter(%s): %v", c.UserID, err) + } + } + + // Josie cleared the Warrens first, then again; Quack cleared them later. Only + // Josie has been through the Crypt — and it is a deeper tier, which is what + // makes the board's depth-before-breadth ordering testable. + for _, r := range []struct { + runID, user, zone, at string + }{ + {"r1", "@josie:x", string(ZoneGoblinWarrens), "2026-01-10 12:00:00"}, + {"r2", "@quack:x", string(ZoneGoblinWarrens), "2026-03-02 12:00:00"}, + {"r3", "@josie:x", string(ZoneGoblinWarrens), "2026-04-05 12:00:00"}, + {"r4", "@josie:x", string(ZoneCryptValdris), "2026-05-01 12:00:00"}, + } { + db.Exec("seed run", `INSERT INTO dnd_zone_run + (run_id, user_id, zone_id, total_rooms, boss_defeated, abandoned, completed_at) + VALUES (?, ?, ?, 6, 1, 0, ?)`, r.runID, r.user, r.zone, r.at) + } + // A retired-but-won run: boss_defeated = 1 with abandoned = 1. This IS a + // clear — `abandoned` means the run row was retired (the expedition travelled + // on after the kill), not that anybody gave up, and in prod it is how 30 of + // the realm's 32 boss kills are stored. The fixture carries one so the + // aggregate can never quietly go back to filtering them out. + db.Exec("seed retired-win", `INSERT INTO dnd_zone_run + (run_id, user_id, zone_id, total_rooms, boss_defeated, abandoned, completed_at) + VALUES ('r5', '@quack:x', 'crypt_valdris', 6, 1, 1, '2026-05-02 12:00:00')`) + // A genuinely unfinished run: no boss, no completion. Not a clear. + db.Exec("seed inflight", `INSERT INTO dnd_zone_run + (run_id, user_id, zone_id, total_rooms, boss_defeated, abandoned, completed_at) + VALUES ('r6', '@quack:x', 'crypt_valdris', 6, 0, 0, NULL)`) + + claimRealmFirst("zone", string(ZoneGoblinWarrens)) + claimRealmFirst("zone", string(ZoneCryptValdris)) +} + +// TestRealmClearStatsPickTheEarliestClearer is the load-bearing query on the +// whole map: "who first got through here" is the single most interesting fact a +// zone has, and it has to be the person who was actually first. +// +// It leans on SQLite's bare-column min/max rule — the user_id comes from the same +// row MIN(completed_at) came from — which is the same thing backfillZoneFirsts +// has relied on since the news seam shipped. If that ever stopped holding, this +// would attribute somebody else's conquest to whoever the grouping happened to +// land on, silently. +func TestRealmClearStatsPickTheEarliestClearer(t *testing.T) { + seedRealmFixture(t) + + stats, err := loadRealmClearStats() + if err != nil { + t.Fatalf("loadRealmClearStats: %v", err) + } + + warrens, ok := stats["goblin_warrens"] + if !ok { + t.Fatal("no stats for goblin_warrens") + } + if warrens.clears != 3 { + t.Errorf("warrens clears = %d, want 3", warrens.clears) + } + if warrens.clearers != 2 { + t.Errorf("warrens clearers = %d, want 2", warrens.clearers) + } + if warrens.firstUser != id.UserID("@josie:x") { + t.Errorf("warrens first clearer = %q, want @josie:x — the earliest run didn't win", warrens.firstUser) + } + + // Two clears: Josie's, and Quack's retired-but-won run. The in-flight run is + // correctly excluded. A regression to `AND abandoned = 0` shows up here as 1. + crypt := stats["crypt_valdris"] + if crypt.clears != 2 { + t.Errorf("crypt clears = %d, want 2 — a won-then-retired run is a clear, "+ + "and an in-flight one is not", crypt.clears) + } + if crypt.firstUser != id.UserID("@josie:x") { + t.Errorf("crypt first clearer = %q, want @josie:x", crypt.firstUser) + } +} + +// TestOptedOutFirstClearerIsAnonymisedNotErased. The Siege contributor rule, +// applied to the map: deleting an opted-out clearer's claim would leave the zone +// drawn as never-cleared, and "nobody has ever come out of there" is the most +// dramatic thing the page can say. Saying it falsely because somebody chose +// privacy would be worse than saying nothing. +func TestOptedOutFirstClearerIsAnonymisedNotErased(t *testing.T) { + seedRealmFixture(t) + setNewsOptout(id.UserID("@josie:x"), true) + + snap, err := buildRealmSnapshot(time.Now().UTC()) + if err != nil { + t.Fatalf("buildRealmSnapshot: %v", err) + } + + var warrens *struct { + clears int + by, token string + } + for _, z := range snap.Zones { + if z.ID == "goblin_warrens" { + warrens = &struct { + clears int + by, token string + }{z.Clears, z.FirstClearBy, z.FirstClearToken} + } + } + if warrens == nil { + t.Fatal("goblin_warrens is not in the snapshot at all") + } + if warrens.clears != 3 { + t.Errorf("clears = %d, want 3 — an opt-out deleted the town's history", warrens.clears) + } + if warrens.by != "" || warrens.token != "" { + t.Errorf("opted-out clearer still named: by=%q token=%q", warrens.by, warrens.token) + } +} + +// TestOptedOutPlayerLeavesTheBoardEntirely. Standings follow the board's rule, +// not the Siege's: an opted-out player is omitted outright. Their level, class +// and clear count would re-identify them, and unlike a siege contribution there +// is no shared total that stops adding up without them. +func TestOptedOutPlayerLeavesTheBoardEntirely(t *testing.T) { + seedRealmFixture(t) + setNewsOptout(id.UserID("@quack:x"), true) + + snap, err := buildRealmSnapshot(time.Now().UTC()) + if err != nil { + t.Fatalf("buildRealmSnapshot: %v", err) + } + for _, s := range snap.Standings { + if s.Name == "Quack" { + t.Fatal("an opted-out player is still on the standings board") + } + } +} + +// TestOccupantsDropOptedOutPlayers. Presence is the strictest case on the page +// and deliberately stricter than a first clear: "who is in the Crypt of Valdris +// right now" is the live-location fact the run liveblog refuses to publish at +// all, so an opted-out player is dropped rather than anonymised. +func TestOccupantsDropOptedOutPlayers(t *testing.T) { + seedRealmFixture(t) + db.Exec("seed expedition", `INSERT INTO dnd_expedition + (expedition_id, user_id, zone_id, status, current_day) + VALUES ('e1', '@josie:x', 'crypt_valdris', 'active', 3)`) + + if occ := loadRealmOccupants(); len(occ["crypt_valdris"]) != 1 { + t.Fatalf("opted-in occupant missing: %+v", occ) + } else if occ["crypt_valdris"][0].Name != "Josie" || occ["crypt_valdris"][0].Day != 3 { + t.Errorf("occupant = %+v, want Josie on day 3", occ["crypt_valdris"][0]) + } + + setNewsOptout(id.UserID("@josie:x"), true) + if occ := loadRealmOccupants(); len(occ["crypt_valdris"]) != 0 { + t.Errorf("opted-out player still shows as standing in a zone: %+v", occ["crypt_valdris"]) + } +} + +// TestStandingsCountFirstsFromTheSameAuthorityTheMapDoes. A zone's "first +// through: Josie" and Josie's own firsts count come off one map in one pass, so +// the two can never disagree — which they would if the board recounted the +// ledger itself and the two queries drifted. +func TestStandingsCountFirstsFromTheSameAuthorityTheMapDoes(t *testing.T) { + seedRealmFixture(t) + + snap, err := buildRealmSnapshot(time.Now().UTC()) + if err != nil { + t.Fatalf("buildRealmSnapshot: %v", err) + } + + firstsByName := map[string]int{} + for _, s := range snap.Standings { + firstsByName[s.Name] = s.Firsts + } + // Josie was first through both zones; Quack was first through neither. + if firstsByName["Josie"] != 2 { + t.Errorf("Josie holds %d firsts, want 2", firstsByName["Josie"]) + } + if firstsByName["Quack"] != 0 { + t.Errorf("Quack holds %d firsts, want 0", firstsByName["Quack"]) + } + + named := 0 + for _, z := range snap.Zones { + if z.FirstClearBy == "Josie" { + named++ + } + } + if named != firstsByName["Josie"] { + t.Errorf("the map names Josie on %d zones but the board credits her with %d — "+ + "the two disagree about the same fact", named, firstsByName["Josie"]) + } +} + +// TestStandingsRankDeepestFirst. The ordering is the game's statement about what +// it values, and Pete renders it without renumbering — so it has to be right +// here. Depth beats breadth: somebody who has put down a Tier 5 boss is ahead of +// somebody who has cleared the whole of Tier 1 forty times. +func TestStandingsRankDeepestFirst(t *testing.T) { + seedRealmFixture(t) + + rows, err := loadRealmStandings(map[string]realmClearStats{}) + if err != nil { + t.Fatalf("loadRealmStandings: %v", err) + } + if len(rows) < 2 { + t.Fatalf("got %d standings rows, want 2", len(rows)) + } + for i := 1; i < len(rows); i++ { + a, b := rows[i-1], rows[i] + if a.DeepestTier < b.DeepestTier { + t.Errorf("row %d (T%d) sorts above row %d (T%d) — the board is not deepest-first", + i-1, a.DeepestTier, i, b.DeepestTier) + } + if a.DeepestTier == b.DeepestTier && a.Zones < b.Zones { + t.Errorf("equal depth but row %d covers %d zones above row %d's %d", + i-1, a.Zones, i, b.Zones) + } + } +} + +// TestFirstsLedgerIsRenderedNotJustCounted. news_realm_firsts has existed since +// the news seam shipped and has only ever been used to decide a dispatch tier — +// the ledger itself was never read back. This is the whole point of the hall: it +// is a history book, and every row needs a name and a date on it. +func TestFirstsLedgerIsRenderedNotJustCounted(t *testing.T) { + seedRealmFixture(t) + + stats, err := loadRealmClearStats() + if err != nil { + t.Fatalf("loadRealmClearStats: %v", err) + } + firsts := loadRealmFirsts(stats) + if len(firsts) != 2 { + t.Fatalf("got %d firsts, want 2", len(firsts)) + } + // Oldest first, which is the order it happened in. + if firsts[0].Target != "goblin_warrens" { + t.Errorf("ledger order starts with %q, want goblin_warrens", firsts[0].Target) + } + for _, f := range firsts { + if f.Display == "" { + t.Errorf("first %q has no display name — it would render as a blank row", f.Target) + } + if f.Holder != "Josie" { + t.Errorf("first %q holder = %q, want Josie (she cleared both zones first)", f.Target, f.Holder) + } + if f.Token == "" { + t.Errorf("first %q has a holder but no token, so the hall can't link to them", f.Target) + } + } +} + +// TestUnrecoverableFirstStillGetsAnEntry. The ledger records (kind, target, +// first_at) and nothing else; the holder is recovered at push time from the run +// history. A treasure found and later discarded leaves no owner anywhere, and +// that entry has to survive as an unattributed first rather than vanish — it +// still happened, and the hall is a record of what happened. +func TestUnrecoverableFirstStillGetsAnEntry(t *testing.T) { + seedRealmFixture(t) + claimRealmFirst("treasure", "a_hat_nobody_kept") + + var found bool + stats, err := loadRealmClearStats() + if err != nil { + t.Fatalf("loadRealmClearStats: %v", err) + } + for _, f := range loadRealmFirsts(stats) { + if f.Target == "a_hat_nobody_kept" { + found = true + if f.Holder != "" { + t.Errorf("holder = %q, want empty — nothing in the game knows who had it", f.Holder) + } + if f.Display == "" { + t.Error("an unrecoverable first got no display name at all") + } + } + } + if !found { + t.Error("a first with no recoverable holder was dropped from the ledger entirely") + } +} + +// TestRealmPushIsRateLimitedBelowTheRosterTick. The realm rides the 2-minute +// roster ticker but is aggregate scans over the whole run history, and none of +// it moves at roster speed. The self-limit is the thing that makes riding that +// ticker acceptable, so it is worth pinning — and so is the other half: a FAILED +// push must not stamp the clock, or an outage would be followed by ten minutes +// of silence instead of a retry on the next tick. +func TestRealmPushIsRateLimitedBelowTheRosterTick(t *testing.T) { + if realmPushInterval <= rosterTickInterval { + t.Fatalf("realmPushInterval (%v) is not longer than the roster tick (%v) — "+ + "the realm would be recomputed every tick", realmPushInterval, rosterTickInterval) + } + + // The gate itself: zero means never-pushed and must always go. + realmLastPush = time.Time{} + t.Cleanup(func() { realmLastPush = time.Time{} }) + now := time.Now().UTC() + if !realmLastPush.IsZero() { + t.Fatal("fixture broken") + } + // A stamp inside the window suppresses; one outside it does not. + realmLastPush = now.Add(-realmPushInterval / 2) + if now.Sub(realmLastPush) >= realmPushInterval { + t.Error("a push half an interval old is not being suppressed") + } + realmLastPush = now.Add(-realmPushInterval - time.Minute) + if now.Sub(realmLastPush) < realmPushInterval { + t.Error("a push older than the interval is still being suppressed") + } +} diff --git a/internal/plugin/pete_roster.go b/internal/plugin/pete_roster.go index 0e3ad7d..bc7e2fb 100644 --- a/internal/plugin/pete_roster.go +++ b/internal/plugin/pete_roster.go @@ -52,6 +52,18 @@ func (p *AdventurePlugin) peteRosterTicker() { } p.pushRoster() p.pushDetails() + p.pushSiege() + // Self-rate-limited to realmPushInterval: the realm is aggregate scans + // over the whole run history and none of it moves at roster speed. + p.pushRealm() + p.pushRunBeats() + // After the beats, not before: the summary is the last beat of a run's + // story and has no business overtaking the log it is about. It is also the + // only step here that can talk to the model, which is why it lives on a + // ticker at all rather than at the moment a run ends — and why it starts + // beside the ticker rather than on it, since a cold model takes longer to + // load than the interval between two pushes. + p.sweepRunSummariesAsync() } } @@ -215,6 +227,11 @@ func (p *AdventurePlugin) buildDetailSnapshot(now time.Time) (peteclient.DetailS if p.euro != nil { pd.Balance = p.euro.GetBalance(uid) } + // W5b: what this owner may ask for from the web, priced. See pete_offers.go + // on why a quote is not a permission. + pd.Zones = zoneOffersFor(uid) + pd.Resume = resumeOfferFor(uid) + pd.Babysit = babysitOfferFor(adv) snap.Players = append(snap.Players, pd) } return snap, nil @@ -445,23 +462,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 @@ -548,7 +626,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 @@ -561,6 +645,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) diff --git a/internal/plugin/pete_roster_party_test.go b/internal/plugin/pete_roster_party_test.go new file mode 100644 index 0000000..6aabb5b --- /dev/null +++ b/internal/plugin/pete_roster_party_test.go @@ -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 +} diff --git a/internal/plugin/pete_run_summary.go b/internal/plugin/pete_run_summary.go new file mode 100644 index 0000000..0bfab14 --- /dev/null +++ b/internal/plugin/pete_run_summary.go @@ -0,0 +1,395 @@ +package plugin + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "os" + "sort" + "strings" + "sync/atomic" + "time" + + "gogobee/internal/db" + "gogobee/internal/peteclient" +) + +// The run summary — three sentences over forty beats. +// +// Every other line in the liveblog is assembled by Pete out of a beat's own +// nouns and numbers, and that is the right split: a log has to be exactly what +// happened, in order, and prose in the middle of it would be the more convincing +// of the two accounts and the less true. But a *report* is read afterwards, by +// somebody who wasn't watching, and the question it answers is not "what +// happened" — the log already answers that — it is "what was that run". That is +// a judgement, and no template makes judgements. +// +// So this is the one piece of prose on the channel, and it earns the model far +// better than a dispatch headline does. authorDispatch turns four fields into a +// sentence a template could nearly have written; this reads a whole expedition +// and picks out what mattered. +// +// Three rules, and the first one is why this file exists at all: +// +// - **Off the hot path.** It runs on the roster ticker, not at the moment the +// run ends. A run ending is already a player-facing beat with a dispatch +// being authored against it; adding a second bounded-but-real LLM call to +// that chokepoint would stall the command that killed the boss. +// - **One per tick.** A backlog after an outage drains over minutes rather +// than spooling a hundred generations at once. +// - **Best effort, exactly once.** A run that can't be summarised is filed +// with an empty summary beat rather than retried forever — the row is what +// stops the sweep picking it up again next tick, and a report with no +// summary is still the log and the numbers, which is most of it. + +// runSummaryMaxBeats bounds what goes into the prompt. Far more than a normal +// run produces; the cap is for the multi-day expedition that beat out hundreds, +// where the last chunk is the part with the ending in it. +const runSummaryMaxBeats = 120 + +// maxRunSummary mirrors Pete's cap so we never ship prose Pete will reject on +// length alone. Byte count, matching Pete's len() check. +const maxRunSummary = 1200 + +// runSummaryTimeout has to cover a COLD model, not just a generation. +// +// dispatchLLMTimeout is tight because authoring runs on a game chokepoint and a +// template dispatch now beats a voiced one late. Nothing here is waiting on this: +// it is a background sweep, the run ended minutes ago, and the page it feeds is +// already serving without it. The cost of being impatient is the opposite of +// there — a timeout files an empty summary beat, and that run never gets another +// chance at one. +// +// And impatience is the live risk, because this call is almost always the one +// that pays the load. Ollama evicts an idle model after about five minutes, and +// runs end far further apart than that, so the steady state is: model on disk, +// nothing resident, weights to page in before the first token. A budget sized +// for generation alone would expire during the load on every single run and file +// an empty beat that says the box is down when the box is fine. So this is sized +// for load-then-generate, and a timeout here really does mean the box is down. +const runSummaryTimeout = 5 * time.Minute + +var runSummaryHTTP = &http.Client{Timeout: runSummaryTimeout} + +// runSummaryBusy is the whole concurrency story: at most one sweep in flight, +// ever. The ticker starts one and moves on, so a cold model loading for minutes +// costs the board nothing, and the ticks that fire meanwhile find the flag set +// and skip rather than queue. +var runSummaryBusy atomic.Bool + +// sweepRunSummariesAsync starts a sweep off the caller's goroutine if one isn't +// already running. +// +// It has to be off the ticker: runSummaryTimeout is minutes and the tick is two, +// so a synchronous call would hold the roster, details, siege and beat pushes +// behind a model load and put the live board permanently a tick or more behind +// the game. Ordering against those pushes is not lost by going async — the beat +// this files is written to the local buffer with the next seq, and the pusher +// ships it by seq on whichever tick comes after, still behind the run's own log. +func (p *AdventurePlugin) sweepRunSummariesAsync() { + if !runSummaryBusy.CompareAndSwap(false, true) { + return // one still working; the next tick will find it done or still busy + } + go func() { + defer runSummaryBusy.Store(false) + p.sweepRunSummaries() + }() +} + +// sweepRunSummaries authors the summary for at most one finished run per call. +// Called from the roster ticker, after the beats themselves have been pushed — +// the summary is the last beat of a run's story and there is no rush to have it +// overtake the log it is about. +func (p *AdventurePlugin) sweepRunSummaries() { + if !peteclient.Enabled() || !newsEmissionOn() { + return + } + if os.Getenv("OLLAMA_HOST") == "" || os.Getenv("OLLAMA_MODEL") == "" { + return // no model, no summary, no wasted queries asking which run needs one + } + runID := nextRunNeedingSummary() + if runID == "" { + return + } + // File the beat whatever happens below. An empty one carries no prose and Pete + // stores nothing from it — its entire job is to be the row that stops this run + // coming back round every two minutes for the rest of the week. + summary, name := authorRunSummary(runID) + if summary == "" { + slog.Debug("run summary: nothing authored, filing an empty beat to close it out", "run", runID) + } + recordRunBeat(runID, peteclient.RunBeat{ + Kind: "summary", + Name: name, + Prose: summary, + }) +} + +// nextRunNeedingSummary picks the most recently finished run that has an `end` +// beat and no `summary` beat yet. +// +// Newest first, deliberately. If the sweep is behind — an outage, a busy +// evening — the run somebody is most likely to be looking at right now is the +// one that just ended, not the one from four hours ago. The old ones still get +// their turn on later ticks; they just don't get to hold up the fresh one. +func nextRunNeedingSummary() string { + var runID string + err := db.Get().QueryRow(` + SELECT e.run_id + FROM pete_run_beat e + WHERE e.kind = 'end' + AND NOT EXISTS ( + SELECT 1 FROM pete_run_beat s + WHERE s.run_id = e.run_id AND s.kind = 'summary') + ORDER BY e.occurred_at DESC + LIMIT 1`).Scan(&runID) + if err != nil { + return "" // ErrNoRows is the common case: nothing to summarise + } + if !runBeatAllowed(runID) { + // An opted-out player's beats never leave the box, so there is nothing for + // a summary to be attached to. File the closing beat anyway (it will be + // retired locally with the rest) so this run stops being picked. + recordRunBeat(runID, peteclient.RunBeat{Kind: "summary"}) + return "" + } + return runID +} + +// authorRunSummary reads a run's beats back and returns Pete's summary of it, +// plus the adventurer's name for the guard allow-list. Returns empty strings on +// any failure — the model being off, a timeout, an unparseable completion, an +// over-long generation — because every one of those is a report without a +// summary rather than a problem. +func authorRunSummary(runID string) (summary, name string) { + beats, err := loadRunBeatsForSummary(runID) + if err != nil || len(beats) == 0 { + return "", "" + } + name = runSummarySubject(beats) + if name == "" { + // No name means no allow-list on Pete's side, which means the guard rejects + // anything naming anyone. Don't spend a generation to have it thrown away. + return "", "" + } + + raw, err := callOllamaDispatch(runSummaryHTTP, os.Getenv("OLLAMA_HOST"), os.Getenv("OLLAMA_MODEL"), + buildRunSummaryPrompt(name, beats)) + if err != nil { + slog.Warn("run summary: LLM authoring failed", "run", runID, "err", err) + return "", name + } + summary = parseRunSummary(raw) + if summary == "" || len(summary) > maxRunSummary { + slog.Warn("run summary: unusable output", "run", runID, "len", len(summary)) + return "", name + } + return summary, name +} + +// loadRunBeatsForSummary reads a run's own beats back out of the outbound +// buffer. It reads the TAIL and re-sorts, so a run long enough to hit the cap +// contributes the part with its ending in it rather than its first morning. +func loadRunBeatsForSummary(runID string) ([]peteclient.RunBeat, error) { + rows, err := db.Get().Query(` + SELECT seq, payload FROM pete_run_beat + WHERE run_id = ? ORDER BY seq DESC LIMIT ?`, runID, runSummaryMaxBeats) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []peteclient.RunBeat + for rows.Next() { + var seq int64 + var payload string + if err := rows.Scan(&seq, &payload); err != nil { + return nil, err + } + var b peteclient.RunBeat + if err := json.Unmarshal([]byte(payload), &b); err != nil { + continue // a row the pusher will retire on its own; not this sweep's problem + } + b.RunID, b.Seq = runID, seq + out = append(out, b) + } + if err := rows.Err(); err != nil { + return nil, err + } + sort.Slice(out, func(i, j int) bool { return out[i].Seq < out[j].Seq }) + return out, nil +} + +// runSummarySubject finds the one name a summary is allowed to use. Only the +// `start` beat carries identity, by design — so a run whose start beat was +// dropped has no subject here, and gets no summary rather than an anonymous one. +func runSummarySubject(beats []peteclient.RunBeat) string { + for _, b := range beats { + if b.Name != "" { + return b.Name + } + } + return "" +} + +// buildRunSummaryPrompt renders the run as a plain numbered log and asks for +// three sentences over it. +// +// The beats go in as facts, not as Pete's rendered lines: Pete's phrasing is +// Pete's, and feeding a model its own output back would have it summarising a +// summary. The rules are the dispatch prompt's, tightened in the one place that +// matters here — a run log is full of monster names and a model asked to write +// about a party is very willing to invent a second member of it. +func buildRunSummaryPrompt(name string, beats []peteclient.RunBeat) string { + var log strings.Builder + zone, outcome := "", "" + n := 0 + for _, b := range beats { + if b.Zone != "" && zone == "" { + zone = b.Zone + } + line := describeBeatForPrompt(b) + if line == "" { + continue + } + if b.Kind == "end" { + outcome = b.Outcome + } + n++ + fmt.Fprintf(&log, "%d. %s\n", n, line) + } + if zone == "" { + zone = "a dungeon" + } + ending := "the run ended" + switch outcome { + case "cleared": + ending = "they cleared it" + case "died": + ending = "they died down there" + case "retreated": + ending = "they walked out alive but beaten" + } + + return fmt.Sprintf(`You are Pete, a warm, friendly local news reporter for a fantasy adventuring town. Think a beloved local newscaster who genuinely knows everyone and is glad to see them. Conversational, never snarky, never a caps-lock hype-man. Warmth carries the register, not exclamation marks. + +Below is the log of one expedition, room by room, exactly as it was recorded. Write a SHORT summary of how the run went: what it cost them, the moment it turned, and how it ended. + +STRICT RULES — do not violate these: +- The ONLY adventurer name you may use is: %s. Never invent another adventurer, companion, party member or friend. If the log does not say someone was there, they were not there. +- Monster, zone and item names in the log are game names — use them as given. +- Use ONLY what the log says. Do not invent numbers, fights, items or outcomes. +- Do NOT add numbers together and do NOT state any total. The exact totals are printed next to your summary and a total you worked out yourself will contradict them. Quote a number only if that exact number appears on one line of the log. +- Three sentences at most. No markdown, no emoji, no headline, no bullet points. +- Past tense, third person. Do not address the reader as "you". + +Respond with ONLY a JSON object, no other text: +{"summary": "at most three sentences about how the run went"} + +The expedition: %s went into %s, and %s. + +The log: +%s`, name, name, zone, ending, log.String()) +} + +// describeBeatForPrompt renders one beat as a flat fact line for the prompt. +// Returns "" for a beat with nothing in it worth a sentence — a room with no +// identity, an empty haul — so the model isn't handed forty lines of "walked +// into the next room" to find three sentences in. +func describeBeatForPrompt(b peteclient.RunBeat) string { + switch b.Kind { + case "start": + if b.TotalRooms > 0 { + return fmt.Sprintf("set out into %s, %d rooms deep", orSomething(b.Zone), b.TotalRooms) + } + return "set out into " + orSomething(b.Zone) + case "combat": + what := orSomething(b.Target) + switch b.RoomKind { + case "boss": + what = "the boss, " + what + case "elite": + what = "an elite, " + what + } + switch b.Outcome { + case "won": + s := fmt.Sprintf("killed %s, taking %d damage", what, b.Amount) + if b.HPMax > 0 { + s += fmt.Sprintf(" (left on %d of %d health)", b.HP, b.HPMax) + } + if b.Crits > 0 { + s += fmt.Sprintf(", %d critical hit(s)", b.Crits) + } + return s + case "retreat": + return "could not finish " + what + " in time and withdrew" + default: + return "was beaten by " + what + } + case "trap": + if b.Amount <= 0 { + return "spotted a trap and stepped over it" + } + s := fmt.Sprintf("sprung a trap for %d damage", b.Amount) + if b.HPMax > 0 { + s += fmt.Sprintf(" (left on %d of %d health)", b.HP, b.HPMax) + } + return s + case "treasure": + return "found " + orSomething(b.Target) + case "lock": + if b.Outcome == "picked" { + return "picked a locked door" + } + return "found every way on sealed and doubled back" + case "region": + return "crossed out of " + orSomething(b.Region) + " into " + orSomething(b.Target) + case "haul": + if b.Amount <= 0 { + return "" + } + return fmt.Sprintf("gathered %d supplies along the way", b.Amount) + case "end": + switch b.Outcome { + case "cleared": + return "finished the run and got out" + case "died": + return "did not come home" + case "retreated": + return "withdrew, wounded but alive" + } + return "the run ended" + } + return "" +} + +func orSomething(s string) string { + if s == "" { + return "something" + } + return s +} + +// parseRunSummary pulls {"summary": ...} out of the completion, tolerating the +// same noise parseDispatch does: reasoning blocks, fences, prose around the JSON. +func parseRunSummary(raw string) string { + s := raw + if i := strings.Index(s, ""); i != -1 { + if j := strings.Index(s, ""); j != -1 { + s = s[:i] + s[j+len(""):] + } + } + start := strings.Index(s, "{") + end := strings.LastIndex(s, "}") + if start < 0 || end <= start { + return "" + } + var out struct { + Summary string `json:"summary"` + } + if err := json.Unmarshal([]byte(s[start:end+1]), &out); err != nil { + return "" + } + return strings.TrimSpace(out.Summary) +} diff --git a/internal/plugin/pete_run_summary_test.go b/internal/plugin/pete_run_summary_test.go new file mode 100644 index 0000000..74ba92c --- /dev/null +++ b/internal/plugin/pete_run_summary_test.go @@ -0,0 +1,199 @@ +package plugin + +import ( + "strings" + "testing" + + "gogobee/internal/db" + "gogobee/internal/peteclient" + + "maunium.net/go/mautrix/id" +) + +// finishRun writes a small realistic run's beats and closes it. +func finishRun(runID, name string) { + recordRunBeat(runID, peteclient.RunBeat{Kind: "start", Token: "tok", Name: name, + Level: 14, Zone: "Crypt of Valdris", TotalRooms: 9, Room: 1}) + recordRunBeat(runID, peteclient.RunBeat{Kind: "combat", Room: 2, Target: "Bone Chanter", + Outcome: "won", Amount: 7, HP: 61, HPMax: 68}) + recordRunBeat(runID, peteclient.RunBeat{Kind: "trap", Room: 3, Outcome: "sprung", + Amount: 22, HP: 39, HPMax: 68}) + recordRunBeat(runID, peteclient.RunBeat{Kind: "end", Room: 3, Outcome: "died"}) +} + +// TestSummarySweepPicksAFinishedRunOnce. The sweep runs every two minutes +// forever, so the property that matters is not that it finds a run — it is that +// it lets one go. A run that stayed pickable would author a fresh summary every +// tick for the rest of the week. +func TestSummarySweepPicksAFinishedRunOnce(t *testing.T) { + newBoredomTestDB(t) + enablePeteSeam(t) + seedBeatRun(t, "run-done", id.UserID("@josie:example.com")) + finishRun("run-done", "Josie") + + if got := nextRunNeedingSummary(); got != "run-done" { + t.Fatalf("sweep didn't find the finished run: %q", got) + } + // Filing the closing beat is what retires it, whether or not any prose was + // authored into it. + recordRunBeat("run-done", peteclient.RunBeat{Kind: "summary"}) + if got := nextRunNeedingSummary(); got != "" { + t.Errorf("run came back round after its summary beat was filed: %q", got) + } +} + +// TestSummarySweepIgnoresARunStillWalking. A summary is a reading of a finished +// run. Writing one over a run in progress would be an ending invented before +// there was one. +func TestSummarySweepIgnoresARunStillWalking(t *testing.T) { + newBoredomTestDB(t) + enablePeteSeam(t) + seedBeatRun(t, "run-live", id.UserID("@josie:example.com")) + recordRunBeat("run-live", peteclient.RunBeat{Kind: "start", Name: "Josie", Zone: "Crypt"}) + recordRunBeat("run-live", peteclient.RunBeat{Kind: "combat", Target: "Rat", Outcome: "won"}) + + if got := nextRunNeedingSummary(); got != "" { + t.Errorf("picked a run that hasn't ended: %q", got) + } +} + +// TestSummarySweepRetiresAnOptedOutRun. Their beats never leave the box, so +// there is nothing on Pete for a summary to attach to — but the run must still +// stop being picked, or the sweep spends a generation on it every tick and +// throws the result away. +func TestSummarySweepRetiresAnOptedOutRun(t *testing.T) { + newBoredomTestDB(t) + enablePeteSeam(t) + uid := id.UserID("@quiet:example.com") + seedBeatRun(t, "run-quiet", uid) + finishRun("run-quiet", "Quack") + setNewsOptout(uid, true) + + if got := nextRunNeedingSummary(); got != "" { + t.Errorf("offered an opted-out player's run for summarising: %q", got) + } + kinds := beatKinds(t, "run-quiet") + if kinds[len(kinds)-1] != "summary" { + t.Errorf("opted-out run wasn't closed out; kinds = %v", kinds) + } + // And it stays closed out. + if got := nextRunNeedingSummary(); got != "" { + t.Errorf("opted-out run came back round: %q", got) + } +} + +// TestSummaryPromptCarriesTheRunAndOnlyOneName. +// +// The prompt is the whole safety story on this side (Pete's guard is the other +// half, and it only ever sees the answer). A run log is full of monster names, +// and a model asked to write warmly about "the party" will happily invent a +// second member of it — which on a public page is words put in a real person's +// mouth. So the one name is stated twice and the facts are handed over as facts. +func TestSummaryPromptCarriesTheRunAndOnlyOneName(t *testing.T) { + newBoredomTestDB(t) + enablePeteSeam(t) + seedBeatRun(t, "run-p", id.UserID("@josie:example.com")) + finishRun("run-p", "Josie") + + beats, err := loadRunBeatsForSummary("run-p") + if err != nil { + t.Fatal(err) + } + if len(beats) != 4 { + t.Fatalf("want 4 beats, got %d", len(beats)) + } + if beats[0].Seq >= beats[len(beats)-1].Seq { + t.Error("beats came back out of order; the log would read backwards") + } + if got := runSummarySubject(beats); got != "Josie" { + t.Fatalf("subject = %q, want Josie", got) + } + + p := buildRunSummaryPrompt("Josie", beats) + for _, want := range []string{ + "The ONLY adventurer name you may use is: Josie", + "Josie went into Crypt of Valdris, and they died down there", + "killed Bone Chanter, taking 7 damage", + "sprung a trap for 22 damage", + "did not come home", + "Three sentences at most", + } { + if !strings.Contains(p, want) { + t.Errorf("prompt is missing %q", want) + } + } +} + +// TestUnattributedRunGetsNoSummary. Only the `start` beat carries identity. A +// run that lost it has no name to hand the guard, so Pete would reject any +// summary naming anybody — spending a generation to have it thrown away, and +// risking an anonymous paragraph about a player nobody can consent for. +func TestUnattributedRunGetsNoSummary(t *testing.T) { + newBoredomTestDB(t) + enablePeteSeam(t) + seedBeatRun(t, "run-anon", id.UserID("@josie:example.com")) + recordRunBeat("run-anon", peteclient.RunBeat{Kind: "combat", Target: "Rat", Outcome: "won"}) + recordRunBeat("run-anon", peteclient.RunBeat{Kind: "end", Outcome: "cleared"}) + + summary, name := authorRunSummary("run-anon") + if summary != "" || name != "" { + t.Errorf("authored over a run with no owner: name=%q summary=%q", name, summary) + } +} + +// TestParseRunSummaryTolerance mirrors parseDispatch's: the model wraps its +// answer in reasoning blocks, fences and apologies, and none of that is a reason +// to lose a summary that is sitting right there. +func TestParseRunSummaryTolerance(t *testing.T) { + cases := []struct{ name, raw, want string }{ + {"plain", `{"summary": "It went badly."}`, "It went badly."}, + {"think block", "hmm\n{\"summary\": \"It went badly.\"}", "It went badly."}, + {"fenced with chatter", "Sure!\n```json\n{\"summary\": \"It went badly.\"}\n```\n", "It went badly."}, + {"no json", "It went badly.", ""}, + {"empty summary", `{"summary": " "}`, ""}, + } + for _, c := range cases { + if got := parseRunSummary(c.raw); got != c.want { + t.Errorf("%s: parseRunSummary = %q, want %q", c.name, got, c.want) + } + } +} + +// TestDispatchRunLinkNeedsARecentRunWithBeats. +// +// latestRunIDForNews answers "which run is this dispatch about", and it is asked +// from call sites that have already let go of the run. Both of its guards are +// load-bearing: a run with no beats behind it would mint a dispatch link to a +// 404, and a stale run would attach a campaign death at the Empty Throne to +// whatever dungeon that player last walked. +func TestDispatchRunLinkNeedsARecentRunWithBeats(t *testing.T) { + newBoredomTestDB(t) + enablePeteSeam(t) + uid := id.UserID("@josie:example.com") + + // A run with no beats: pre-liveblog, or the seam was off while it walked. + seedBeatRun(t, "run-silent", uid) + if got := latestRunIDForNews(uid); got != "" { + t.Errorf("linked a dispatch to a run Pete has never heard of: %q", got) + } + + // The real one, closed seconds ago. + seedBeatRun(t, "run-real", uid) + finishRun("run-real", "Josie") + if _, err := db.Get().Exec( + `UPDATE dnd_zone_run SET completed_at = CURRENT_TIMESTAMP WHERE run_id = 'run-real'`); err != nil { + t.Fatal(err) + } + if got := latestRunIDForNews(uid); got != "run-real" { + t.Errorf("run link = %q, want run-real", got) + } + + // A day later, they die somewhere that isn't a dungeon at all. + if _, err := db.Get().Exec( + `UPDATE dnd_zone_run SET completed_at = datetime('now', '-1 day') WHERE run_id = 'run-real'`); err != nil { + t.Fatal(err) + } + if got := latestRunIDForNews(uid); got != "" { + t.Errorf("attached an unrelated death to yesterday's expedition: %q", got) + } +} diff --git a/internal/plugin/pete_runbeat.go b/internal/plugin/pete_runbeat.go new file mode 100644 index 0000000..04f5afb --- /dev/null +++ b/internal/plugin/pete_runbeat.go @@ -0,0 +1,281 @@ +package plugin + +import ( + "context" + "encoding/json" + "log/slog" + "time" + + "gogobee/internal/db" + "gogobee/internal/peteclient" + + "maunium.net/go/mautrix/id" +) + +// Run beats — the room-by-room texture of an expedition, on its way to Pete. +// +// Until now Pete learned that an expedition happened only when it ended: a +// zone_clear, a retreat, a death. The run itself — the fight that nearly went +// wrong, the trap, the haul — was narrated to one Matrix DM and then discarded. +// This records the shape of each moment as it happens so Pete can retell it. +// +// Three rules hold the design together: +// +// - **Facts, not prose.** A beat carries nouns and numbers; the engine's +// narration stays in Matrix. Pete owns the words, the same split every Fact +// already respects. +// - **Its own channel.** Beats never touch 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. +// - **Never block, never fail the game.** recordRunBeat swallows its errors to +// a log line. A liveblog is a nice-to-have; the walk it is watching is not. +// +// Delivery rides the roster ticker (one extra request per 2 minutes, not one per +// room) but unlike the roster it is retried, because a dropped beat is a hole in +// a story rather than a stale number the next snapshot corrects. + +// runBeatBatch bounds one push. A busy realm mid-evening might produce a few +// hundred beats between ticks; this keeps any single request small and lets the +// backlog drain over a few ticks instead of one enormous POST. +const runBeatBatch = 200 + +// recordRunBeat appends a beat to the outbound buffer. Never returns an error: +// every caller is on the walk's hot path and none of them can do anything useful +// with a failure to log a story. +// +// Seq is assigned by the INSERT itself (MAX+1 within the statement), so two +// concurrent writers on the same run can't collide on a number — SQLite +// serialises the statement, and the primary key would reject the loser anyway. +func recordRunBeat(runID string, b peteclient.RunBeat) { + if runID == "" || !peteclient.Enabled() || !newsEmissionOn() { + return + } + b.RunID = runID + if b.OccurredAt == 0 { + b.OccurredAt = nowUnix() + } + // Seq and RunID live in columns; the rest of the beat is the payload, so + // adding a field later needs no migration. + kind, occurred := b.Kind, b.OccurredAt + b.Seq = 0 + payload, err := json.Marshal(b) + if err != nil { + slog.Debug("runbeat: marshal failed", "run", runID, "kind", kind, "err", err) + return + } + if _, err := db.Get().Exec(` + INSERT INTO pete_run_beat (run_id, seq, kind, occurred_at, payload) + SELECT ?, COALESCE(MAX(seq), 0) + 1, ?, ?, ? + FROM pete_run_beat WHERE run_id = ?`, + runID, kind, occurred, string(payload), runID); err != nil { + slog.Debug("runbeat: record failed", "run", runID, "kind", kind, "err", err) + } +} + +// runHasEndBeat reports whether this run's story has already been closed. Cheap +// enough to ask at every end site because a run only ends once; see beatRunEnd +// for why the first answer is the one that must stick. +func runHasEndBeat(runID string) bool { + if runID == "" || !peteclient.Enabled() { + return false + } + var n int + err := db.Get().QueryRow( + `SELECT COUNT(*) FROM pete_run_beat WHERE run_id = ? AND kind = 'end'`, runID).Scan(&n) + return err == nil && n > 0 +} + +// latestRunIDForNews is the run a just-filed dispatch is about, or "" when there +// isn't one to point at. +// +// The three dispatches that end an expedition — a clear, a retreat, a death — +// are all emitted *after* the run they concluded has been closed, and two of +// them from call sites several frames away from the run row. So rather than +// thread a run id through five signatures and hope the lifetimes line up, this +// asks the question that is actually true at that moment: what is the last run +// this player started. A player has one run at a time and a dispatch about their +// expedition ending is about that one. Multi-region is the case worth stating: +// each region gets its own run, and the last one started is the one they were +// standing in when it ended, which is the log the dispatch should open. +// +// Two clauses do the real work and neither is optional: +// +// - The `pete_run_beat` check. Runs exist with no beats behind them — from +// before the liveblog shipped, or with the seam off — and handing Pete a run +// id it has nothing for would mint a dispatch link to a 404. +// - The recency window. Not every death happens in a dungeon: the campaign +// path kills people at the Empty Throne, and without this a death that had +// nothing to do with any expedition would link to whatever run that player +// last walked, possibly days ago. An expedition-ending dispatch is filed +// seconds after its run closes, so "still open, or closed just now" is the +// honest test for "this dispatch is about that run". +func latestRunIDForNews(userID id.UserID) string { + if userID == "" || !peteclient.Enabled() { + return "" + } + var runID string + err := db.Get().QueryRow(` + SELECT r.run_id + FROM dnd_zone_run r + WHERE r.user_id = ? + AND (r.completed_at IS NULL OR r.completed_at >= datetime('now', '-10 minutes')) + AND EXISTS (SELECT 1 FROM pete_run_beat b WHERE b.run_id = r.run_id) + ORDER BY r.started_at DESC, r.rowid DESC + LIMIT 1`, string(userID)).Scan(&runID) + if err != nil { + return "" + } + return runID +} + +// runBeatPushOK mirrors rosterPushOK: log the transitions, stay quiet otherwise. +var runBeatPushOK bool + +// pushRunBeats drains the unsent buffer to Pete. Called from the roster ticker. +func (p *AdventurePlugin) pushRunBeats() { + beats, drop, err := loadRunBeatBatch(runBeatBatch) + if err != nil { + slog.Error("runbeat: load batch failed", "err", err) + return + } + // Beats belonging to an opted-out player are retired locally without ever + // going out. Marking them sent (rather than deleting) keeps one code path for + // "this row is done with" and lets the retention sweep reap them on its own + // clock. Opting back in mid-run loses the earlier beats, which is the right + // way round to be wrong. + if len(drop) > 0 { + if err := markRunBeatsSent(drop); err != nil { + slog.Warn("runbeat: retire opted-out beats", "err", err) + } + } + if len(beats) == 0 { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), rosterPushTimeout) + defer cancel() + if err := peteclient.PushRunBeats(ctx, beats); err != nil { + if runBeatPushOK { + slog.Warn("runbeat: push failed, liveblog will lag on Pete", "err", err, "beats", len(beats)) + } else { + slog.Debug("runbeat: push failed, will retry next tick", "err", err) + } + runBeatPushOK = false + return // rows stay unsent: this is the one push that retries + } + if err := markRunBeatsSent(beats); err != nil { + // Delivered but not marked. Pete is idempotent on (run_id, seq), so the + // re-send next tick is a no-op there — better than dropping the row. + slog.Warn("runbeat: mark sent failed, beats will re-send", "err", err) + } + if !runBeatPushOK { + slog.Info("runbeat: liveblog accepted by Pete", "beats", len(beats)) + runBeatPushOK = true + } +} + +// loadRunBeatBatch reads up to limit unsent beats in (run, seq) order and splits +// them into the ones to send and the ones to retire unsent. +// +// It drains the cursor completely before resolving a single owner, and that is +// not a style preference. The pool is one connection wide, so a query issued +// while these rows are still open waits for a connection that this loop is +// holding and will not release until the loop ends — a deadlock that the roster +// ticker would hit on its very first tick with any beat in the buffer. +// +// The opt-out check is then per *run*, resolved once and cached for the batch: a +// run belongs to exactly one player, and re-asking per beat would turn a 200-row +// batch into 200 lookups of an answer that cannot change inside one tick. +func loadRunBeatBatch(limit int) (send []peteclient.RunBeat, drop []peteclient.RunBeat, err error) { + type row struct { + runID string + seq int64 + payload string + } + + rows, qerr := db.Get().Query(` + SELECT run_id, seq, payload + FROM pete_run_beat + WHERE sent_at IS NULL + ORDER BY run_id ASC, seq ASC + LIMIT ?`, limit) + if qerr != nil { + return nil, nil, qerr + } + var raw []row + for rows.Next() { + var r row + if err := rows.Scan(&r.runID, &r.seq, &r.payload); err != nil { + rows.Close() + return nil, nil, err + } + raw = append(raw, r) + } + rerr := rows.Err() + rows.Close() + if rerr != nil { + return nil, nil, rerr + } + + allowed := map[string]bool{} + for _, r := range raw { + var b peteclient.RunBeat + if err := json.Unmarshal([]byte(r.payload), &b); err != nil { + // An undecodable row is dead weight forever; retire it rather than + // letting it head the queue and block every beat behind it. + slog.Warn("runbeat: undecodable payload, retiring", "run", r.runID, "seq", r.seq, "err", err) + drop = append(drop, peteclient.RunBeat{RunID: r.runID, Seq: r.seq}) + continue + } + b.RunID, b.Seq = r.runID, r.seq + + ok, known := allowed[r.runID] + if !known { + ok = runBeatAllowed(r.runID) + allowed[r.runID] = ok + } + if ok { + send = append(send, b) + } else { + drop = append(drop, b) + } + } + return send, drop, nil +} + +// runBeatAllowed reports whether this run's beats may leave the box. A run whose +// owner can't be resolved is refused: the liveblog is a public surface, and +// "don't know who this is" is not a safe basis for publishing where they are. +func runBeatAllowed(runID string) bool { + run, err := getZoneRun(runID) + if err != nil || run == nil || run.UserID == "" { + return false + } + return !isNewsOptedOut(id.UserID(run.UserID)) +} + +// markRunBeatsSent stamps a batch delivered, in one transaction so a crash +// mid-mark can't leave half a run looking unsent and re-send it. +func markRunBeatsSent(beats []peteclient.RunBeat) error { + if len(beats) == 0 { + return nil + } + tx, err := db.Get().Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + stmt, err := tx.Prepare(`UPDATE pete_run_beat SET sent_at = ? WHERE run_id = ? AND seq = ?`) + if err != nil { + return err + } + defer stmt.Close() + now := time.Now().UTC().Unix() + for _, b := range beats { + if _, err := stmt.Exec(now, b.RunID, b.Seq); err != nil { + return err + } + } + return tx.Commit() +} diff --git a/internal/plugin/pete_runbeat_emit.go b/internal/plugin/pete_runbeat_emit.go new file mode 100644 index 0000000..91e2b41 --- /dev/null +++ b/internal/plugin/pete_runbeat_emit.go @@ -0,0 +1,241 @@ +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" + }) +} diff --git a/internal/plugin/pete_runbeat_test.go b/internal/plugin/pete_runbeat_test.go new file mode 100644 index 0000000..9bb6fa8 --- /dev/null +++ b/internal/plugin/pete_runbeat_test.go @@ -0,0 +1,343 @@ +package plugin + +import ( + "testing" + "time" + + "gogobee/internal/db" + "gogobee/internal/peteclient" + + "maunium.net/go/mautrix/id" +) + +// seedBeatRun writes a dnd_zone_run row directly. The beat pusher resolves a +// run's owner through this table to decide whether the log may leave the box, so +// a test that skips it is testing a code path production never takes. +func seedBeatRun(t *testing.T, runID string, uid id.UserID) { + t.Helper() + if _, err := db.Get().Exec(` + INSERT INTO dnd_zone_run + (run_id, user_id, zone_id, total_rooms, rooms_cleared, gm_mood, + current_node, visited_nodes, node_choices, rooms_traversed) + VALUES (?, ?, 'goblin_warrens', 8, '[]', 50, 'goblin_warrens.r1', '["goblin_warrens.r1"]', '{}', 1)`, + runID, string(uid)); err != nil { + t.Fatalf("seed zone run: %v", err) + } +} + +func beatKinds(t *testing.T, runID string) []string { + t.Helper() + rows, err := db.Get().Query( + `SELECT kind FROM pete_run_beat WHERE run_id = ? ORDER BY seq ASC`, runID) + if err != nil { + t.Fatalf("read beats: %v", err) + } + defer rows.Close() + var out []string + for rows.Next() { + var k string + if err := rows.Scan(&k); err != nil { + t.Fatal(err) + } + out = append(out, k) + } + return out +} + +// TestRunBeatSeqIsMonotonicPerRun. (run_id, seq) is the identity Pete is +// idempotent on and it is also the render order, so a repeated or missing number +// is either a lost beat or a duplicated one. Two runs walking at once must not +// share a counter. +func TestRunBeatSeqIsMonotonicPerRun(t *testing.T) { + newBoredomTestDB(t) + enablePeteSeam(t) + + for i := 0; i < 3; i++ { + recordRunBeat("run-a", peteclient.RunBeat{Kind: "room", Room: i + 1}) + recordRunBeat("run-b", peteclient.RunBeat{Kind: "room", Room: i + 1}) + } + + for _, run := range []string{"run-a", "run-b"} { + rows, err := db.Get().Query( + `SELECT seq FROM pete_run_beat WHERE run_id = ? ORDER BY seq ASC`, run) + if err != nil { + t.Fatal(err) + } + var seqs []int64 + for rows.Next() { + var s int64 + if err := rows.Scan(&s); err != nil { + t.Fatal(err) + } + seqs = append(seqs, s) + } + rows.Close() + if len(seqs) != 3 { + t.Fatalf("%s: %d beats, want 3", run, len(seqs)) + } + for i, s := range seqs { + if s != int64(i+1) { + t.Errorf("%s: seq[%d] = %d, want %d", run, i, s, i+1) + } + } + } +} + +// TestRunBeatsAreDroppedForOptedOutPlayers is the privacy guard, and it is +// stricter than the board's. +// +// The board omits an opted-out player from a snapshot. The liveblog would be a +// room-by-room account of where somebody is and what is happening to them, which +// is the most exposing surface in the whole plan — so the rule here is that the +// beats never leave the box at all. They are retired locally instead, so the +// buffer can't fill up with rows that will never ship. +// +// It is also the pin on the connection-pool deadlock this originally shipped +// with: resolving an owner requires a second query, and doing that with the beat +// cursor still open waits forever on a one-connection pool. If loadRunBeatBatch +// ever goes back to resolving inside its own rows loop, this test stops failing +// and starts HANGING — which is what it did the first time, and is why the note +// is here rather than in a comment nobody reads at 3am. +func TestRunBeatsAreDroppedForOptedOutPlayers(t *testing.T) { + newBoredomTestDB(t) + enablePeteSeam(t) + now := time.Now().UTC() + + seedRosterPlayer(t, "@shy:test", "Quack", &now, &now) + seedRosterPlayer(t, "@loud:test", "Josie", &now, &now) + seedBeatRun(t, "run-shy", "@shy:test") + seedBeatRun(t, "run-loud", "@loud:test") + setNewsOptout("@shy:test", true) + + recordRunBeat("run-shy", peteclient.RunBeat{Kind: "room", Room: 2}) + recordRunBeat("run-loud", peteclient.RunBeat{Kind: "room", Room: 2}) + + send, drop, err := loadRunBeatBatch(100) + if err != nil { + t.Fatalf("loadRunBeatBatch: %v", err) + } + if len(send) != 1 || send[0].RunID != "run-loud" { + t.Fatalf("sendable beats = %+v, want only run-loud", send) + } + if len(drop) != 1 || drop[0].RunID != "run-shy" { + t.Fatalf("dropped beats = %+v, want only run-shy", drop) + } + + // Retiring means marked sent, not deleted — one code path for "done with this + // row", and the retention sweep reaps it on its own clock. + if err := markRunBeatsSent(drop); err != nil { + t.Fatalf("retire: %v", err) + } + send, drop, _ = loadRunBeatBatch(100) + if len(drop) != 0 { + t.Errorf("retired beats came back: %+v", drop) + } + if len(send) != 1 { + t.Errorf("retiring the opted-out beats disturbed the rest: %+v", send) + } +} + +// TestRunBeatsRefuseAnUnresolvableRun. The liveblog is public. A run whose owner +// can't be resolved is not a run we know is safe to publish — "don't know who +// this is" is not a basis for saying where they are. +func TestRunBeatsRefuseAnUnresolvableRun(t *testing.T) { + newBoredomTestDB(t) + enablePeteSeam(t) + + recordRunBeat("run-ghost", peteclient.RunBeat{Kind: "room", Room: 1}) + + send, drop, err := loadRunBeatBatch(100) + if err != nil { + t.Fatal(err) + } + if len(send) != 0 { + t.Errorf("beats for an unknown run were queued for publication: %+v", send) + } + if len(drop) != 1 { + t.Errorf("orphan beats = %d, want 1 retired", len(drop)) + } +} + +// TestRunEndIsFirstWriterWins. A run ends once, but it passes through more than +// one place that can say so: a death in the combat resolver goes on to call +// abandonZoneRun, and the expedition layer retires completed runs. The specific +// outcome is filed first and must survive the generic one behind it — a log that +// says "abandoned" about somebody who was killed is worse than no log. +func TestRunEndIsFirstWriterWins(t *testing.T) { + newBoredomTestDB(t) + enablePeteSeam(t) + now := time.Now().UTC() + + seedRosterPlayer(t, "@a:test", "Josie", &now, &now) + seedBeatRun(t, "run-a", "@a:test") + run, err := getZoneRun("run-a") + if err != nil || run == nil { + t.Fatalf("load run: %v", err) + } + + beatRunEnd(run, "died") + beatRunEnd(run, "abandoned") + beatRunEnd(run, "cleared") + + if kinds := beatKinds(t, "run-a"); len(kinds) != 1 || kinds[0] != "end" { + t.Fatalf("beats = %v, want exactly one end beat", kinds) + } + send, _, err := loadRunBeatBatch(10) + if err != nil { + t.Fatal(err) + } + if len(send) != 1 || send[0].Outcome != "died" { + t.Errorf("stored outcome = %+v, want the first (specific) close", send) + } +} + +// TestRunBeatsAreANoOpWhenTheSeamIsOff. The whole channel hangs off the same +// master switch as the dispatch queue: with news emission off, nothing is +// recorded at all, so turning it off doesn't quietly accrue a buffer that floods +// Pete the moment it comes back on. +func TestRunBeatsAreANoOpWhenTheSeamIsOff(t *testing.T) { + newBoredomTestDB(t) + + recordRunBeat("run-a", peteclient.RunBeat{Kind: "room", Room: 1}) + if kinds := beatKinds(t, "run-a"); len(kinds) != 0 { + t.Errorf("recorded %v with the seam disabled", kinds) + } +} + +// TestBeatCombatReadsTheOutcome pins the three fight endings apart. "Outlasted +// by the monster" and "killed by the monster" are the same losing branch in the +// engine and mechanically different events — one starts a respawn timer and the +// other doesn't — so the log must not collapse them. +func TestBeatCombatReadsTheOutcome(t *testing.T) { + newBoredomTestDB(t) + enablePeteSeam(t) + now := time.Now().UTC() + + seedRosterPlayer(t, "@a:test", "Josie", &now, &now) + seedBeatRun(t, "run-a", "@a:test") + run, _ := getZoneRun("run-a") + + beatCombat(run, "Rat", false, false, true, false, 30, 24, 30, 1, 0) + beatCombat(run, "Aldric", false, true, false, true, 24, 8, 30, 0, 2) + beatCombat(run, "The Rotmother", false, true, false, false, 8, 0, 30, 0, 0) + + send, _, err := loadRunBeatBatch(10) + if err != nil { + t.Fatal(err) + } + if len(send) != 3 { + t.Fatalf("got %d combat beats, want 3", len(send)) + } + want := []string{"won", "retreat", "down"} + for i, w := range want { + if send[i].Outcome != w { + t.Errorf("beat %d outcome = %q, want %q", i, send[i].Outcome, w) + } + } + if send[0].Amount != 6 || send[0].HP != 24 || send[0].HPMax != 30 { + t.Errorf("won beat lost its numbers: %+v", send[0]) + } + if send[0].Crits != 1 { + t.Errorf("crits = %d, want 1", send[0].Crits) + } + if send[1].RoomKind != "boss" { + t.Errorf("room kind = %q, want boss", send[1].RoomKind) + } +} + +// TestBeatCombatNeverReportsNegativeDamage. A party that healed through a fight +// finishes on more HP than it started with. "Took −4 damage" is not a fact. +func TestBeatCombatNeverReportsNegativeDamage(t *testing.T) { + newBoredomTestDB(t) + enablePeteSeam(t) + now := time.Now().UTC() + + seedRosterPlayer(t, "@a:test", "Josie", &now, &now) + seedBeatRun(t, "run-a", "@a:test") + run, _ := getZoneRun("run-a") + + beatCombat(run, "Rat", false, false, true, false, 20, 28, 30, 0, 0) + + send, _, _ := loadRunBeatBatch(10) + if len(send) != 1 || send[0].Amount != 0 { + t.Fatalf("amount = %+v, want 0", send) + } +} + +// TestBeatHaulPicksTheBiggestYieldDeterministically. Go's map order is random, +// so a "mostly X" line built off a range would name a different resource every +// time the same room was rendered. +func TestBeatHaulPicksTheBiggestYieldDeterministically(t *testing.T) { + newBoredomTestDB(t) + enablePeteSeam(t) + now := time.Now().UTC() + + seedRosterPlayer(t, "@a:test", "Josie", &now, &now) + for i, runID := range []string{"run-1", "run-2", "run-3", "run-4", "run-5"} { + seedBeatRun(t, runID, "@a:test") + run, _ := getZoneRun(runID) + beatHaul(run, autoHarvestSummary{ + Yields: map[string]int{"ironcap": 5, "moss": 2, "flint": 1}, + Names: map[string]string{"ironcap": "Ironcap", "moss": "Moss", "flint": "Flint"}, + }) + _ = i + } + send, _, err := loadRunBeatBatch(20) + if err != nil { + t.Fatal(err) + } + if len(send) != 5 { + t.Fatalf("got %d haul beats, want 5", len(send)) + } + for _, b := range send { + if b.Target != "Ironcap" { + t.Fatalf("haul named %q, want Ironcap every time", b.Target) + } + if b.Amount != 8 || b.Count != 3 { + t.Errorf("haul totals = %d over %d kinds, want 8 over 3", b.Amount, b.Count) + } + } + + // Nothing gathered is not a beat. + seedBeatRun(t, "run-empty", "@a:test") + empty, _ := getZoneRun("run-empty") + beatHaul(empty, autoHarvestSummary{}) + if kinds := beatKinds(t, "run-empty"); len(kinds) != 0 { + t.Errorf("an empty haul produced %v", kinds) + } +} + +// TestStartingARunOpensItsLog is the end-to-end seam check on the game side: the +// engine primitive every zone entry goes through files the one beat that carries +// identity, so nothing downstream has to be told who is walking. +func TestStartingARunOpensItsLog(t *testing.T) { + newBoredomTestDB(t) + enablePeteSeam(t) + now := time.Now().UTC() + + seedRosterPlayer(t, "@a:test", "Josie", &now, &now) + run, err := startZoneRun("@a:test", "goblin_warrens", 5, nil) + if err != nil { + t.Fatalf("startZoneRun: %v", err) + } + send, _, err := loadRunBeatBatch(10) + if err != nil { + t.Fatal(err) + } + if len(send) != 1 || send[0].Kind != "start" { + t.Fatalf("beats = %+v, want one start", send) + } + b := send[0] + if b.RunID != run.RunID { + t.Errorf("start beat run = %q, want %q", b.RunID, run.RunID) + } + if b.Name != "Josie" || b.Level != 5 { + t.Errorf("start beat identity = %q L%d, want Josie L5", b.Name, b.Level) + } + if b.Token == "" || b.Token != eventToken("@a:test", "roster") { + t.Errorf("start beat token = %q, want the public board token", b.Token) + } + if b.TotalRooms != run.TotalRooms { + t.Errorf("start beat rooms = %d, want %d", b.TotalRooms, run.TotalRooms) + } +} diff --git a/internal/plugin/pete_siege.go b/internal/plugin/pete_siege.go new file mode 100644 index 0000000..2739bcf --- /dev/null +++ b/internal/plugin/pete_siege.go @@ -0,0 +1,376 @@ +package plugin + +import ( + "context" + "log/slog" + "sort" + "strconv" + "time" + + "gogobee/internal/db" + "gogobee/internal/peteclient" + + "maunium.net/go/mautrix/id" +) + +// The Siege war room, pushed to Pete. +// +// The Siege is the only mechanic where the whole town works on one object, and +// until now it existed exclusively in Matrix — which means anybody not in the +// room at the time never knew it happened. A communal event nobody can see is a +// communal event that fails. +// +// It rides the roster ticker and follows the roster's rules exactly, because it +// is the same kind of thing: a snapshot of what is currently true, pushed whole, +// replacing whatever Pete had, dropped rather than retried on failure. A retried +// snapshot would be a lie about how much HP is left. +// +// The one place it deliberately departs from the board is the opt-out. The board +// omits an opted-out player entirely — a row showing class + level + zone is +// trivially re-identifiable, so absence is the only honest option there. Here a +// contributor is anonymised instead of dropped: their damage is part of what the +// town did to the boss, and a defender board that quietly deleted it would +// understate the shared effort and stop the numbers adding up. An opted-out +// player who has NOT fought is still omitted — there is nothing to account for, +// so naming their absence would be exposure for nothing. + +// siegeHistoryLimit bounds the "sieges past" table. A Siege a month means this is +// years of history; the cap only exists so the payload can't grow without bound. +const siegeHistoryLimit = 24 + +// pushSiege builds and sends the war room. Mirrors pushRoster: transitions are +// logged, the steady state is silent. +var siegePushOK bool + +func (p *AdventurePlugin) pushSiege() { + snap, err := buildSiegeSnapshot(time.Now().UTC()) + if err != nil { + slog.Error("siege: build snapshot failed", "err", err) + return + } + ctx, cancel := context.WithTimeout(context.Background(), rosterPushTimeout) + defer cancel() + + if err := peteclient.PushSiege(ctx, snap); err != nil { + if siegePushOK { + slog.Warn("siege: push failed, war room will go stale on Pete", "err", err) + } else { + slog.Debug("siege: push failed, dropping snapshot", "err", err) + } + siegePushOK = false + return + } + if !siegePushOK { + slog.Info("siege: war room accepted by Pete", "active", snap.Active, "defenders", len(snap.Defenders)) + siegePushOK = true + } +} + +// buildSiegeSnapshot assembles the whole war room: the live boss, the muster, +// and the history. +func buildSiegeSnapshot(now time.Time) (peteclient.SiegeSnapshot, error) { + snap := peteclient.SiegeSnapshot{SnapshotAt: now.Unix()} + + hist, err := loadResolvedWorldBosses(siegeHistoryLimit) + if err != nil { + return snap, err + } + snap.History = hist + + boss, err := loadActiveWorldBoss() + if err != nil { + return snap, err + } + if boss == nil { + return snap, nil // no Siege camped: a real answer, not an empty snapshot + } + + snap.Active = true + snap.BossID = boss.ID + snap.BossName = boss.Name + snap.Tier = boss.Tier + snap.HPCurrent = boss.HPCurrent + snap.HPMax = boss.HPMax + snap.StartsAt = boss.StartsAt.Unix() + snap.EndsAt = boss.EndsAt.Unix() + + defenders, boutsToday, err := buildSiegeMuster(boss.ID, now) + if err != nil { + return snap, err + } + snap.Defenders = defenders + snap.BoutsToday = boutsToday + return snap, nil +} + +// buildSiegeMuster returns every alive adventurer's standing against this boss, +// ranked, plus how many bouts have been taken today. +// +// It starts from the contribution rows rather than from the roster so a +// contributor who has since died (or whose player_meta row went away) still +// appears — the damage they did is on the boss whether they are standing or not. +// The alive roster is then folded in on top to produce the zero-fight rows that +// make the "bout still going spare" column exist. +func buildSiegeMuster(bossID int64, now time.Time) ([]peteclient.SiegeDefender, int, error) { + contribs, err := loadWorldBossContribs(bossID) + if err != nil { + return nil, 0, err + } + today := now.Format("2006-01-02") + + byUser := make(map[id.UserID]worldBossContrib, len(contribs)) + for _, c := range contribs { + byUser[c.UserID] = c + } + + // Everyone alive, so the un-fought have a row to stand in. + rows, err := db.Get().Query(`SELECT user_id FROM player_meta WHERE alive = 1`) + if err != nil { + return nil, 0, err + } + order := make([]id.UserID, 0, len(contribs)) + seen := make(map[id.UserID]bool, len(contribs)) + for rows.Next() { + var uid string + if err := rows.Scan(&uid); err != nil { + rows.Close() + return nil, 0, err + } + u := id.UserID(uid) + if !seen[u] { + seen[u] = true + order = append(order, u) + } + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, 0, err + } + // Contributors who are no longer on the alive roster still owe the board a row. + for _, c := range contribs { + if !seen[c.UserID] { + seen[c.UserID] = true + order = append(order, c.UserID) + } + } + + boutsToday := 0 + out := make([]peteclient.SiegeDefender, 0, len(order)) + for _, uid := range order { + c, fought := byUser[uid] + if fought && c.LastFightDate == today { + boutsToday++ + } + optedOut := isNewsOptedOut(uid) + if optedOut && !fought { + continue // nothing to account for; naming the absence is exposure for nothing + } + + d := peteclient.SiegeDefender{Name: anonName} + if fought { + d.Fights = c.Fights + d.Damage = c.Damage + d.FoughtToday = c.LastFightDate == today + } + if !optedOut { + name := charName(uid) + if name == "" { + // No character name means no honest way to render the row: never fall + // back to a Matrix handle on a public page. A contributor in this state + // keeps their damage, anonymously; a non-contributor is simply dropped. + if !fought { + continue + } + } else { + d.Name = name + d.Token = eventToken(uid, "roster") + if ch, err := LoadDnDCharacter(uid); err == nil && ch != nil && !ch.PendingSetup { + d.Level = ch.Level + } + } + } + out = append(out, d) + } + + // Rank: damage, then bouts, then name. Deterministic to the last key so an + // unchanged muster produces a byte-identical snapshot and Pete's board doesn't + // reshuffle itself every two minutes. + sort.SliceStable(out, func(i, j int) bool { + if out[i].Damage != out[j].Damage { + return out[i].Damage > out[j].Damage + } + if out[i].Fights != out[j].Fights { + return out[i].Fights > out[j].Fights + } + return out[i].Name < out[j].Name + }) + return out, boutsToday, nil +} + +// loadResolvedWorldBosses reads the closed-out Sieges, newest first, each with +// its defender count and the contributor who turned up most. +func loadResolvedWorldBosses(limit int) ([]peteclient.SiegePast, error) { + // resolved_at is selected raw and folded in Go, never COALESCE()'d in SQL: + // modernc.org/sqlite rebuilds a time.Time from the column's DECLARED type and + // COALESCE erases that affinity, so the Scan would fail. Same trap + // buildRosterSnapshot documents. + rows, err := db.Get().Query(` + SELECT id, name, tier, hp_max, hp_current, status, resolved_at, ends_at + FROM world_boss + WHERE status IN ('defeated', 'survived') + ORDER BY id DESC + LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []peteclient.SiegePast + for rows.Next() { + var ( + h peteclient.SiegePast + resolvedAt, endsAt *time.Time + ) + if err := rows.Scan(&h.BossID, &h.BossName, &h.Tier, &h.HPMax, &h.HPRemaining, + &h.Outcome, &resolvedAt, &endsAt); err != nil { + return nil, err + } + // A boss resolved by the ticker has resolved_at; a legacy row might not. + // The window's close is the honest fallback — it is when the Siege ended + // either way, and it is never null. + switch { + case resolvedAt != nil: + h.EndedAt = resolvedAt.Unix() + case endsAt != nil: + h.EndedAt = endsAt.Unix() + } + out = append(out, h) + } + if err := rows.Err(); err != nil { + return nil, err + } + + for i := range out { + n, mvp, fights, err := worldBossMuster(out[i].BossID) + if err != nil { + slog.Warn("siege: history muster load failed", "boss", out[i].BossID, "err", err) + continue + } + out[i].Defenders = n + out[i].MVP = mvp + out[i].MVPFights = fights + } + return out, nil +} + +// worldBossMuster reports how many people fought a boss and who fought it most. +// The MVP is by fights, not damage — the same accessibility call the payout +// split makes (computeWorldBossPayouts): turning up is the contribution the +// mechanic actually asks for. An opted-out MVP is anonymised, never dropped; +// the count behind the name is a fact about the town. +func worldBossMuster(bossID int64) (defenders int, mvp string, mvpFights int, err error) { + contribs, err := loadWorldBossContribs(bossID) + if err != nil { + return 0, "", 0, err + } + // loadWorldBossContribs already orders by fights desc, damage desc, so the + // first row with any fights at all is the MVP. + for _, c := range contribs { + if c.Fights <= 0 { + continue + } + defenders++ + if mvp == "" { + mvp = anonName + if !isNewsOptedOut(c.UserID) { + if name := charName(c.UserID); name != "" { + mvp = name + } + } + mvpFights = c.Fights + } + } + return defenders, mvp, mvpFights, nil +} + +// ── Dispatches ─────────────────────────────────────────────────────────────── + +// emitSiegeStart files the "a boss is at the gates" dispatch. PRIORITY: this is +// the one beat where the right response is for everyone to look up right now, +// and unlike a zone clear it is not something TwinBee has already announced to +// the same people — the games-room shout and this go to different rooms. +// +// Realm-level, so there is no subject player and no opt-out to apply. +func emitSiegeStart(boss *worldBossState) { + if !peteclient.Enabled() || !newsEmissionOn() { + return + } + emitFact(peteclient.Fact{ + GUID: siegeGUID("siege_start", boss.ID), + EventType: "siege_start", + Tier: "priority", + Boss: boss.Name, + Level: boss.Tier, + Stakes: siegeWindowPhrase(boss), + OccurredAt: boss.StartsAt.Unix(), + }, "", "") +} + +// emitSiegeWin files the "the town held" dispatch. Count is the number of +// defenders, which is what Pete's template reads to say how many stood. +func emitSiegeWin(boss *worldBossState, defenders int) { + if !peteclient.Enabled() || !newsEmissionOn() { + return + } + emitFact(peteclient.Fact{ + GUID: siegeGUID("siege_win", boss.ID), + EventType: "siege_win", + Tier: "priority", + Boss: boss.Name, + Level: boss.Tier, + Count: defenders, + Outcome: "defeated", + OccurredAt: nowUnix(), + }, "", "") +} + +// emitSiegeLoss files the "it broke through" dispatch. +func emitSiegeLoss(boss *worldBossState) { + if !peteclient.Enabled() || !newsEmissionOn() { + return + } + emitFact(peteclient.Fact{ + GUID: siegeGUID("siege_loss", boss.ID), + EventType: "siege_loss", + Tier: "priority", + Boss: boss.Name, + Level: boss.Tier, + Outcome: "survived", + OccurredAt: nowUnix(), + }, "", "") +} + +// siegeGUID keys a Siege dispatch on the boss row id, which is unique and stable +// for the life of the event. That makes each of the three beats fire at most +// once per Siege however many times its resolution path is re-entered — the +// status guard in setWorldBossStatus already dedupes the payout, and this dedupes +// the news the same way. +func siegeGUID(eventType string, bossID int64) string { + return eventType + ":" + strconv.FormatInt(bossID, 10) +} + +// siegeWindowPhrase is the deadline as Pete's siege_start template wants it — +// "You've got %s" — so it must read as a duration, not a timestamp. +func siegeWindowPhrase(boss *worldBossState) string { + h := int(boss.EndsAt.Sub(boss.StartsAt).Hours()) + switch { + case h <= 0: + return "no time at all" + case h == 24: + return "a day" + case h%24 == 0: + return strconv.Itoa(h/24) + " days" + default: + return strconv.Itoa(h) + " hours" + } +} diff --git a/internal/plugin/pete_siege_test.go b/internal/plugin/pete_siege_test.go new file mode 100644 index 0000000..cc3eea8 --- /dev/null +++ b/internal/plugin/pete_siege_test.go @@ -0,0 +1,254 @@ +package plugin + +import ( + "testing" + "time" + + "gogobee/internal/db" + + "maunium.net/go/mautrix/id" +) + +// seedSiege writes a world_boss row directly and returns it. Real rows on +// purpose: the history query scans two declared DATETIME columns (resolved_at, +// ends_at) and the modernc affinity trap only fires against actual stored +// values, never against a hand-built struct. +func seedSiege(t *testing.T, name string, tier, hpMax, hpCurrent int, status string, starts, ends time.Time, resolved *time.Time) int64 { + t.Helper() + res, err := db.Get().Exec( + `INSERT INTO world_boss (name, tier, hp_max, hp_current, status, starts_at, ends_at, resolved_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + name, tier, hpMax, hpCurrent, status, starts, ends, resolved) + if err != nil { + t.Fatalf("seed world_boss: %v", err) + } + id, err := res.LastInsertId() + if err != nil { + t.Fatal(err) + } + return id +} + +func seedContrib(t *testing.T, bossID int64, uid id.UserID, fights, damage int, lastDate string) { + t.Helper() + if _, err := db.Get().Exec( + `INSERT INTO world_boss_contrib (boss_id, user_id, fights, damage, last_fight_date) + VALUES (?, ?, ?, ?, ?)`, bossID, string(uid), fights, damage, lastDate); err != nil { + t.Fatalf("seed contrib: %v", err) + } +} + +// TestSiegeSnapshotMustersEveryoneAlive is the reason the payload carries +// zero-fight rows at all. The mechanic is one bout per person per day, so the +// interesting number is not "who fought" but "who still could" — and Pete can +// only draw that column if the people in it are on the wire. A snapshot of +// contributors alone would render a board that quietly congratulates itself. +func TestSiegeSnapshotMustersEveryoneAlive(t *testing.T) { + newBoredomTestDB(t) + now := time.Now().UTC() + old := now.Add(-40 * time.Hour) + today := now.Format("2006-01-02") + + seedRosterPlayer(t, "@a:test", "Josie", &old, &old) + seedRosterPlayer(t, "@b:test", "Quack", &old, &old) + seedRosterPlayer(t, "@c:test", "Camcast", &old, &old) + + bossID := seedSiege(t, "Gorloth the Sunderer", 4, 1000, 400, "active", + now.Add(-2*time.Hour), now.Add(70*time.Hour), nil) + seedContrib(t, bossID, "@a:test", 3, 500, today) + seedContrib(t, bossID, "@b:test", 1, 100, now.AddDate(0, 0, -1).Format("2006-01-02")) + + snap, err := buildSiegeSnapshot(now) + if err != nil { + t.Fatalf("buildSiegeSnapshot: %v", err) + } + if !snap.Active || snap.BossName != "Gorloth the Sunderer" { + t.Fatalf("snapshot missed the live boss: %+v", snap) + } + if snap.HPCurrent != 400 || snap.HPMax != 1000 { + t.Errorf("pool = %d/%d, want 400/1000", snap.HPCurrent, snap.HPMax) + } + if len(snap.Defenders) != 3 { + t.Fatalf("muster has %d rows, want 3 — the un-fought must have a row to stand in", len(snap.Defenders)) + } + if snap.BoutsToday != 1 { + t.Errorf("bouts_today = %d, want 1 — only Josie has been out today", snap.BoutsToday) + } + + // Ranked by damage: Josie, Quack, then the adventurer who hasn't started. + if snap.Defenders[0].Name != "Josie" || !snap.Defenders[0].FoughtToday { + t.Errorf("top of the muster = %+v, want Josie having fought today", snap.Defenders[0]) + } + if snap.Defenders[1].Name != "Quack" || snap.Defenders[1].FoughtToday { + t.Errorf("second = %+v, want Quack with a bout still spare (hers was yesterday)", snap.Defenders[1]) + } + if snap.Defenders[2].Name != "Camcast" || snap.Defenders[2].Fights != 0 { + t.Errorf("third = %+v, want Camcast at zero fights", snap.Defenders[2]) + } + for _, d := range snap.Defenders { + if d.Token == "" { + t.Errorf("%s has no board token — the defender board can't link to their page", d.Name) + } + } +} + +// TestSiegeOptOutAnonymisesContributorAndDropsBystander is the one place the +// Siege deliberately breaks the board's opt-out rule, so it is worth pinning +// both halves. +// +// The board omits an opted-out player outright: a row showing class + level + +// zone re-identifies them, so absence is the only honest option. Here a +// CONTRIBUTOR is anonymised instead — the damage they did is on the boss and is +// part of what the town accomplished, and deleting it would understate the +// shared effort and stop the totals adding up. A non-contributor is still +// dropped, because there is nothing to account for and naming their absence +// would be exposure for nothing. +func TestSiegeOptOutAnonymisesContributorAndDropsBystander(t *testing.T) { + newBoredomTestDB(t) + now := time.Now().UTC() + old := now.Add(-40 * time.Hour) + today := now.Format("2006-01-02") + + seedRosterPlayer(t, "@shy:test", "Ghost", &old, &old) // opted out, fought + seedRosterPlayer(t, "@lurk:test", "Silent", &old, &old) // opted out, never fought + seedRosterPlayer(t, "@open:test", "Josie", &old, &old) // opted in, fought + setNewsOptout("@shy:test", true) + setNewsOptout("@lurk:test", true) + + bossID := seedSiege(t, "The Iron Colossus", 4, 1000, 200, "active", + now.Add(-time.Hour), now.Add(71*time.Hour), nil) + seedContrib(t, bossID, "@shy:test", 4, 600, today) + seedContrib(t, bossID, "@open:test", 1, 200, today) + + snap, err := buildSiegeSnapshot(now) + if err != nil { + t.Fatalf("buildSiegeSnapshot: %v", err) + } + if len(snap.Defenders) != 2 { + t.Fatalf("muster has %d rows, want 2 — the opted-out bystander should be gone and the opted-out contributor kept", len(snap.Defenders)) + } + + top := snap.Defenders[0] + if top.Damage != 600 { + t.Fatalf("top of the muster did %d damage, want 600 — the anonymous contributor lost their rank", top.Damage) + } + if top.Name != anonName { + t.Errorf("opted-out contributor rendered as %q, want %q", top.Name, anonName) + } + if top.Token != "" { + t.Error("opted-out contributor carries a board token — that is a link straight back to a page that names them") + } + if top.Level != 0 { + t.Errorf("opted-out contributor leaked level %d — level + damage is most of a re-identification", top.Level) + } + + for _, d := range snap.Defenders { + if d.Name == "Silent" || d.Name == "Ghost" { + t.Errorf("opted-out player %q reached the wire under their character name", d.Name) + } + } +} + +// TestSiegeHistoryReadsResolvedClock is the scan-affinity guard for the history +// query, the exact trap buildRosterSnapshot documents: resolved_at and ends_at +// are declared DATETIME, and a COALESCE() in the SQL would erase that affinity +// so the Scan fails — which would silently publish an empty history rather than +// anything obviously broken. +func TestSiegeHistoryReadsResolvedClock(t *testing.T) { + newBoredomTestDB(t) + now := time.Now().UTC() + old := now.Add(-40 * time.Hour) + + seedRosterPlayer(t, "@a:test", "Josie", &old, &old) + seedRosterPlayer(t, "@b:test", "Quack", &old, &old) + + resolved := now.Add(-24 * time.Hour) + won := seedSiege(t, "The Ashen Wyrm", 5, 1200, 0, "defeated", + now.Add(-96*time.Hour), now.Add(-24*time.Hour), &resolved) + seedContrib(t, won, "@a:test", 6, 700, "2026-07-01") + seedContrib(t, won, "@b:test", 2, 500, "2026-07-01") + + // A legacy row with no resolved_at must still date itself — off the window's + // close, which is when the Siege ended either way and is never null. + lost := seedSiege(t, "Kravok, Maw of the Deep", 4, 800, 300, "survived", + now.Add(-200*time.Hour), now.Add(-128*time.Hour), nil) + seedContrib(t, lost, "@a:test", 1, 500, "2026-06-01") + + snap, err := buildSiegeSnapshot(now) + if err != nil { + t.Fatalf("buildSiegeSnapshot: %v", err) + } + if snap.Active { + t.Error("no boss is camped but the snapshot claims one is") + } + if len(snap.History) != 2 { + t.Fatalf("history has %d rows, want 2", len(snap.History)) + } + + // Newest first (id desc): the survived Kravok was inserted last. + h := snap.History[0] + if h.BossName != "Kravok, Maw of the Deep" || h.Outcome != "survived" { + t.Fatalf("history[0] = %+v, want the survived Kravok first", h) + } + if h.HPRemaining != 300 || h.HPMax != 800 { + t.Errorf("survived bar = %d/%d, want 300/800", h.HPRemaining, h.HPMax) + } + if h.EndedAt != now.Add(-128*time.Hour).Unix() { + t.Errorf("legacy row dated %d, want the window close %d", h.EndedAt, now.Add(-128*time.Hour).Unix()) + } + + w := snap.History[1] + if w.Outcome != "defeated" || w.HPRemaining != 0 { + t.Errorf("defeated Siege = %+v, want a pool at zero", w) + } + if w.EndedAt != resolved.Unix() { + t.Errorf("resolved row dated %d, want resolved_at %d", w.EndedAt, resolved.Unix()) + } + if w.Defenders != 2 { + t.Errorf("defenders = %d, want 2", w.Defenders) + } + // MVP is by fights, not damage — the same accessibility call the payout split + // makes. Josie fought six times for 700; had it been by damage she'd still win, + // so the ordering is pinned by loadWorldBossContribs' fights-desc ordering. + if w.MVP != "Josie" || w.MVPFights != 6 { + t.Errorf("MVP = %q with %d fights, want Josie with 6", w.MVP, w.MVPFights) + } +} + +// TestSiegeDispatchesFireOncePerSiege. The three Siege beats key their GUID on +// the boss row id, so a resolution path re-entered (a redeploy mid-window, the +// ticker's safety net firing after an inline kill) files the same dispatch guid +// and Pete dedupes it. Without that, a restart could announce the same Siege +// twice to the whole room. +func TestSiegeDispatchesFireOncePerSiege(t *testing.T) { + if a, b := siegeGUID("siege_start", 7), siegeGUID("siege_start", 7); a != b { + t.Errorf("guid not stable: %q vs %q", a, b) + } + if a, b := siegeGUID("siege_start", 7), siegeGUID("siege_start", 8); a == b { + t.Errorf("two different Sieges share guid %q", a) + } + if a, b := siegeGUID("siege_win", 7), siegeGUID("siege_loss", 7); a == b { + t.Errorf("win and loss share guid %q — one Siege cannot file both", a) + } +} + +// TestSiegeWindowPhrase: the siege_start template says "You've got %s", so the +// stakes field has to read as a duration in a sentence, not as a timestamp. +func TestSiegeWindowPhrase(t *testing.T) { + base := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + cases := []struct { + window time.Duration + want string + }{ + {worldBossWindow, "3 days"}, + {24 * time.Hour, "a day"}, + {36 * time.Hour, "36 hours"}, + {0, "no time at all"}, + } + for _, c := range cases { + b := &worldBossState{StartsAt: base, EndsAt: base.Add(c.window)} + if got := siegeWindowPhrase(b); got != c.want { + t.Errorf("siegeWindowPhrase(%v) = %q, want %q", c.window, got, c.want) + } + } +} diff --git a/internal/plugin/zone_graph_nav.go b/internal/plugin/zone_graph_nav.go index 9542fd7..6dd17d4 100644 --- a/internal/plugin/zone_graph_nav.go +++ b/internal/plugin/zone_graph_nav.go @@ -436,6 +436,20 @@ func completeRunAtNode(runID string, boss bool) error { if boss { bossI = 1 } + // Only a boss kill is a clear. This function also closes a non-boss + // dead-end — the party simply ran out of map — and beatRunEnd is + // first-writer-wins, so calling that "cleared" would put a lie in the + // liveblog and the run summary that nothing downstream could correct. + // "ended" is deliberately outside the {cleared,died,retreated,abandoned} + // set: the prompt renderer already degrades an unknown outcome to a + // neutral "the run ended", which is exactly what happened. + if run, _ := getZoneRun(runID); run != nil { + outcome := "ended" + if boss { + outcome = "cleared" + } + beatRunEnd(run, outcome) + } _, err := db.Get().Exec(` UPDATE dnd_zone_run SET boss_defeated = ?, @@ -474,7 +488,12 @@ func advanceZoneRunNode(runID, nextNode string) (int, error) { nextNode, string(visitedJSON), runID); err != nil { return 0, err } - return pathIndexOf(visited, nextNode), nil + idx := pathIndexOf(visited, nextNode) + // Every forward move in the game funnels through here — auto-advance, a + // player's `!zone go`, and the autopilot's stale-fork pick alike — which is + // what makes this the one honest place to say "the party is now in room N". + beatRoom(r, nextNode, idx, "entered") + return idx, nil } // resolveForkChoice takes a 1-based choice index against a pending diff --git a/internal/plugin/zone_revisit.go b/internal/plugin/zone_revisit.go index 607ad83..a75c06b 100644 --- a/internal/plugin/zone_revisit.go +++ b/internal/plugin/zone_revisit.go @@ -69,7 +69,11 @@ func revisitZoneRun(runID, targetNode string, visited []string) (int, error) { WHERE run_id = ?`, targetNode, runID); err != nil { return 0, err } - return pathIndexOf(visited, targetNode), nil + idx := pathIndexOf(visited, targetNode) + if run, _ := getZoneRun(runID); run != nil { + beatRoom(run, targetNode, idx, "doubled back") + } + return idx, nil } // handleRevisitCmd implements `!revisit ` (also reachable as