Merge w9-verbs-and-polish: headless verb twins, realm-firsts repair, review fixes

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
This commit is contained in:
prosolis
2026-07-24 22:08:00 -07:00
37 changed files with 6173 additions and 259 deletions
+37
View File
@@ -700,6 +700,11 @@ func RunMaintenance() {
// weight the drain query already skips — drop it so a durable outage // weight the drain query already skips — drop it so a durable outage
// can't accrete rows forever. // can't accrete rows forever.
{"pete_emit_queue_parked", `DELETE FROM pete_emit_queue WHERE sent_at IS NULL AND created_at < ?`, []interface{}{cutoff30d}}, {"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 — purge entries older than today
{"rate_limits", `DELETE FROM rate_limits WHERE date < ?`, []interface{}{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 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 -- Players who opted out of being named in Pete's adventure news. Enforced at
-- emit time (anonymize, never delete). Mirrors shade_optout. -- emit time (anonymize, never delete). Mirrors shade_optout.
CREATE TABLE IF NOT EXISTS news_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 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 -- Babysitting Service
CREATE TABLE IF NOT EXISTS adventure_babysit_log ( CREATE TABLE IF NOT EXISTS adventure_babysit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
+406
View File
@@ -48,6 +48,13 @@ type Fact struct {
Milestone string `json:"milestone,omitempty"` Milestone string `json:"milestone,omitempty"`
OccurredAt int64 `json:"occurred_at"` OccurredAt int64 `json:"occurred_at"`
NoPush bool `json:"no_push,omitempty"` // backfill: suppress Pete web-push 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 // 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, // 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 // 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"` ThreatLevel int `json:"threat_level,omitempty"`
Room string `json:"room,omitempty"` Room string `json:"room,omitempty"`
Map *RosterMap `json:"map,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 // 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) 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, // PlayerDetail is the private, owner-only expansion for one player: inventory,
// vault, house, and pets. Like MischiefBalance it is keyed by localpart (the // 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 // 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 // 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. // an absent one — dropping it would let the confirm dialog show a stale amount.
Balance float64 `json:"balance"` 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 // 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. // 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 PetView struct {
Type string `json:"type"` Type string `json:"type"`
Name string `json:"name"` Name string `json:"name"`
Level int `json:"level"` Level int `json:"level"`
XP int `json:"xp,omitempty"` XP int `json:"xp,omitempty"`
XPNeeded int `json:"xp_needed,omitempty"` // 0 = at the level cap
ArmorTier int `json:"armor_tier,omitempty"` 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) 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. // getJSON does a bearer-authed GET and decodes the body.
func (c *Client) getJSON(ctx context.Context, path string, out any) error { func (c *Client) getJSON(ctx context.Context, path string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.IngestURL+path, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.IngestURL+path, nil)
+7
View File
@@ -253,6 +253,12 @@ func (p *AdventurePlugin) Init() error {
// deaths, single-holder achievements) the first boot the seam is live, so // 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). // launch doesn't open onto an empty section. One-shot, kept (see gap #7).
p.bootstrapPeteNewsBackfill() 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 // 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 // over-archived: it treats any active dnd_zone_run row not linked to
// an active expedition as a legacy `!adventure dungeon` orphan, which // an active expedition as a legacy `!adventure dungeon` orphan, which
@@ -292,6 +298,7 @@ func (p *AdventurePlugin) Init() error {
go p.mischiefTicker() go p.mischiefTicker()
go p.peteMischiefTicker() go p.peteMischiefTicker()
go p.peteEquipTicker() go p.peteEquipTicker()
go p.peteAdvOrderTicker()
// Auto-cashout any arena runs left in 'awaiting' from a prior restart // Auto-cashout any arena runs left in 'awaiting' from a prior restart
p.arenaCleanupStaleRuns() p.arenaCleanupStaleRuns()
+132 -24
View File
@@ -1,6 +1,7 @@
package plugin package plugin
import ( import (
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"strings" "strings"
@@ -100,33 +101,91 @@ func (p *AdventurePlugin) handleBabysitCmd(ctx MessageContext, args string) erro
} }
} }
func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) error { // Sentinels for the ways hiring a sitter can be refused, so the web action queue
userMu := p.advUserLock(ctx.Sender) // (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() userMu.Lock()
defer userMu.Unlock() defer userMu.Unlock()
char, err := loadAdvCharacter(ctx.Sender) char, err := loadAdvCharacter(uid)
if err != nil { 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 { 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 { 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)) daily := babysitDailyCost(dndLevelForUser(char.UserID))
totalCost := daily * days totalCost := daily * days
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) balance := p.euro.GetBalance(char.UserID)
if balance < float64(totalCost) { 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))) 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") { debited := false
return p.SendDM(ctx.Sender, "Payment failed. The babysitter looked at your wallet and walked away.") 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) clearBabysitLogs(char.UserID)
@@ -138,23 +197,40 @@ func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) er
if err := saveAdvCharacter(char); err != nil { if err := saveAdvCharacter(char); err != nil {
slog.Error("babysit: failed to save character", "user", char.UserID, "err", err) slog.Error("babysit: failed to save character", "user", char.UserID, "err", err)
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") 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.") }
return babysitOutcome{}, refuseAdv(errBabysitFailed, "Something went wrong activating the service. Your gold has been refunded.")
} }
if err := upsertPlayerMetaBabysitState(char.UserID, babysitStateFromAdvChar(char)); err != nil { if err := upsertPlayerMetaBabysitState(char.UserID, babysitStateFromAdvChar(char)); err != nil {
slog.Error("player_meta: babysit start dual-write failed", "user", char.UserID, "err", err) 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." petLine := "No pet to tend yet — the babysitter will keep that in mind."
if char.HasPet() { if char.HasPet() {
petLine = fmt.Sprintf("Pet: %s (L%d) — daily care included", char.PetName, char.PetLevel) 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"+ text := fmt.Sprintf("🍼 **Adventurer Babysitting Service — Activated**\n\n"+
"Duration: %s (%d days)\n"+ "Duration: %s (%d days)\n"+
@@ -162,7 +238,7 @@ func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) er
"%s\n"+ "%s\n"+
"Camp safety: standard camps now rest like fortified ones\n"+ "Camp safety: standard camps now rest like fortified ones\n"+
"Rival duels: declined on your behalf\n\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) return p.SendDM(ctx.Sender, text)
} }
@@ -209,25 +285,49 @@ func (p *AdventurePlugin) handleBabysitStatus(ctx MessageContext) error {
return p.SendDM(ctx.Sender, text) return p.SendDM(ctx.Sender, text)
} }
func (p *AdventurePlugin) handleBabysitCancel(ctx MessageContext) error { // babysitCancelOutcome is what dismissing the sitter did. Summary is the Matrix
userMu := p.advUserLock(ctx.Sender) // 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() userMu.Lock()
defer userMu.Unlock() defer userMu.Unlock()
char, err := loadAdvCharacter(ctx.Sender) char, err := loadAdvCharacter(uid)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "No adventurer found.") return babysitCancelOutcome{}, refuseAdv(errBabysitNoCharacter, "No adventurer found.")
} }
if !char.BabysitActive { 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) logs, err := loadBabysitLogs(char.UserID)
if err != nil { if err != nil {
slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err) 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.BabysitActive = false
char.BabysitExpiresAt = nil 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) 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 ──────────────────────────────────────────────────────────── // ── Expiry Check ────────────────────────────────────────────────────────────
+22 -4
View File
@@ -15,6 +15,11 @@ import (
const petXPPerAction = 1.5 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 '\-]+$`) var petNameValid = regexp.MustCompile(`^[a-zA-Z0-9 '\-]+$`)
// petXPToNextLevel returns XP needed for a given pet level. // 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. // 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 // Returns true if leveled up. Shares the level-up loop with the babysit trickle
// via advancePetLevelsFromXP. // 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 // 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. // by both pet slots (the babysit trickle). Returns true if the pet leveled.
func advancePetLevelsFromXP(xp, level *int, level10Date *string, addCentiXP int) bool { func advancePetLevelsFromXP(xp, level *int, level10Date *string, addCentiXP int) bool {
if *level >= 10 { if *level >= petMaxLevel {
return false return false
} }
*xp += addCentiXP *xp += addCentiXP
leveled := false leveled := false
for *level < 10 { for *level < petMaxLevel {
needed := petXPToNextLevel(*level) * 100 needed := petXPNeededCenti(*level)
if *xp < needed { if *xp < needed {
break break
} }
@@ -104,7 +122,7 @@ func advancePetLevelsFromXP(xp, level *int, level10Date *string, addCentiXP int)
*level++ *level++
leveled = true leveled = true
} }
if *level >= 10 && *level10Date == "" { if *level >= petMaxLevel && *level10Date == "" {
*level10Date = time.Now().UTC().Format("2006-01-02") *level10Date = time.Now().UTC().Format("2006-01-02")
} }
return leveled return leveled
+66 -19
View File
@@ -2,6 +2,7 @@ package plugin
import ( import (
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"hash/fnv" "hash/fnv"
"log/slog" "log/slog"
@@ -394,6 +395,7 @@ func (p *AdventurePlugin) spawnWorldBoss(eventKey string) (*worldBossState, erro
return nil, err return nil, err
} }
p.announceWorldBossSpawn(boss, activeN) p.announceWorldBossSpawn(boss, activeN)
emitSiegeStart(boss)
slog.Info("worldboss: spawned", "id", bossID, "name", name, "tier", tier, "hp", hpMax, "activeN", activeN) slog.Info("worldboss: spawned", "id", bossID, "name", name, "tier", tier, "hp", hpMax, "activeN", activeN)
return boss, nil return boss, nil
} }
@@ -496,6 +498,7 @@ func (p *AdventurePlugin) resolveWorldBossDefeated(boss *worldBossState) {
} }
} }
p.announceWorldBossDefeated(boss, payouts) p.announceWorldBossDefeated(boss, payouts)
emitSiegeWin(boss, len(payouts))
slog.Info("worldboss: defeated", "id", boss.ID, "contributors", len(payouts)) slog.Info("worldboss: defeated", "id", boss.ID, "contributors", len(payouts))
} }
@@ -516,6 +519,7 @@ func (p *AdventurePlugin) resolveWorldBossSurvived(boss *worldBossState) {
paid = tribute paid = tribute
} }
p.announceWorldBossSurvived(boss, paid) p.announceWorldBossSurvived(boss, paid)
emitSiegeLoss(boss)
slog.Info("worldboss: survived", "id", boss.ID, "tribute", paid) 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))) 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 // 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 // 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 // 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. // once-per-day gate can't be raced by a double-tap — and that same lock is what
func (p *AdventurePlugin) fightWorldBoss(ctx MessageContext) error { // makes it safe for the web queue and a Matrix command to reach for the bout at
userMu := p.advUserLock(ctx.Sender) // 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() userMu.Lock()
defer userMu.Unlock() defer userMu.Unlock()
boss, err := loadActiveWorldBoss() boss, err := loadActiveWorldBoss()
if err != nil { 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 { 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 { 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 { 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") today := time.Now().UTC().Format("2006-01-02")
if worldBossBoutUsedToday(boss.ID, ctx.Sender, today) { if worldBossBoutUsedToday(boss.ID, uid, today) {
return p.SendDM(ctx.Sender, fmt.Sprintf( return worldBossBoutResult{}, boss, errSiegeAlreadyFought
"You've already taken your bout against **%s** today. Come back tomorrow — one fight per day.", boss.Name))
} }
bout, err := p.resolveWorldBossBout(ctx.Sender, boss, today) bout, err := p.resolveWorldBossBout(uid, boss, today)
if err != nil { if err != nil {
slog.Error("worldboss: bout failed", "user", ctx.Sender, "err", err) slog.Error("worldboss: bout failed", "user", uid, "err", err)
return p.SendDM(ctx.Sender, "The Siege combat hit an error. Try again in a moment.") return worldBossBoutResult{}, boss, fmt.Errorf("running the bout: %w", err)
} }
// Resolve a defeat BEFORE streaming the (multi-second) narration. The pool is // 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) p.resolveWorldBossDefeated(boss)
} }
playerName, _ := loadDisplayName(ctx.Sender) playerName, _ := loadDisplayName(uid)
if playerName == "" { if playerName == "" {
playerName = "You" playerName = "You"
} }
@@ -658,18 +678,45 @@ func (p *AdventurePlugin) fightWorldBoss(ctx MessageContext) error {
fmt.Sprintf("⚔️ **The Siege — %s** (Tier %d)", boss.Name, boss.Tier), fmt.Sprintf("⚔️ **The Siege — %s** (Tier %d)", boss.Name, boss.Tier),
}, RenderCombatLog(bout.Combat, playerName, boss.Name)...) }, 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 var footer string
if bout.Killed { 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 { } else {
footer = fmt.Sprintf("💥 You deal **%d** damage. **%s** has **%s / %s HP** left.", footer = fmt.Sprintf("💥 You deal **%d** damage. **%s** has **%s / %s HP** left.",
bout.Damage, boss.Name, groupInt(bout.Remaining), groupInt(boss.HPMax)) bout.Damage, boss.Name, groupInt(bout.Remaining), groupInt(boss.HPMax))
} }
if bout.Battered { 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 return nil
} }
+99 -14
View File
@@ -58,34 +58,119 @@ func (p *AdventurePlugin) bootstrapPeteNewsBackfill() {
"zone_firsts", firsts, "deaths", deaths, "achievements", achv) "zone_firsts", firsts, "deaths", deaths, "achievements", achv)
} }
// backfillZoneFirsts seeds news_realm_firsts from history and emits one PRIORITY // zoneFirstClear is one zone's earliest boss kill: who did it and when.
// realm-first dispatch per zone, attributed to its earliest boss-defeating type zoneFirstClear struct {
// clearer. SQLite returns the user_id from the same row as MIN(completed_at) zoneID, userID, completedAt string
// (bare-column min/max rule), so the (zone, first clearer, time) triple is }
// consistent. Returns the count emitted.
func (p *AdventurePlugin) backfillZoneFirsts() int { // 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( rows, err := db.Get().Query(
`SELECT zone_id, user_id, MIN(completed_at) `SELECT zone_id, user_id, MIN(completed_at)
FROM dnd_zone_run 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`) GROUP BY zone_id`)
if err != nil { if err != nil {
slog.Error("backfill: zone-firsts query", "err", err) slog.Error("backfill: zone-firsts query", "err", err)
return 0 return nil, false
} }
type first struct { defer rows.Close()
zoneID, userID, completedAt string
}
var firsts []first
for rows.Next() { for rows.Next() {
var f first var f zoneFirstClear
if err := rows.Scan(&f.zoneID, &f.userID, &f.completedAt); err != nil { if err := rows.Scan(&f.zoneID, &f.userID, &f.completedAt); err != nil {
slog.Error("backfill: zone-firsts scan", "err", err) slog.Error("backfill: zone-firsts scan", "err", err)
continue continue
} }
firsts = append(firsts, f) 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 n := 0
for _, f := range firsts { for _, f := range firsts {
@@ -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")
}
}
+2
View File
@@ -607,6 +607,7 @@ func emitDeathNews(userID id.UserID, location string) {
Zone: location, Zone: location,
Level: lvl, Level: lvl,
Outcome: "lost", Outcome: "lost",
RunID: latestRunIDForNews(userID),
OccurredAt: ts, OccurredAt: ts,
}, userID, "") }, userID, "")
} }
@@ -655,6 +656,7 @@ func emitRetreatNews(userID id.UserID, reason string, zoneID ZoneID, day int) {
Level: charLevel(userID), Level: charLevel(userID),
Count: day, // the day they got to before it fell apart Count: day, // the day they got to before it fell apart
Outcome: "retreated", Outcome: "retreated",
RunID: latestRunIDForNews(userID),
OccurredAt: ts, OccurredAt: ts,
}, userID, "") }, userID, "")
} }
+300 -114
View File
@@ -1,6 +1,7 @@
package plugin package plugin
import ( import (
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"math" "math"
@@ -32,6 +33,23 @@ import (
// in E1e. !advance / !search / !rest / !extract are out-of-scope for E1. // in E1e. !advance / !search / !rest / !extract are out-of-scope for E1.
func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string) error { 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 := p.advUserLock(ctx.Sender)
userMu.Lock() userMu.Lock()
defer userMu.Unlock() 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).") "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) { switch strings.ToLower(sub) {
case "": case "":
// If active, show status; otherwise help. A party member is on an // 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) return p.expeditionCmdHire(ctx, rest)
case "dismiss": case "dismiss":
return p.expeditionCmdDismiss(ctx) return p.expeditionCmdDismiss(ctx)
case "extract":
return p.handleExtractCmd(ctx, "")
case "resume":
return p.handleResumeCmd(ctx, rest)
case "map", "m": case "map", "m":
return p.handleExpeditionMapCmd(ctx, "") return p.handleExpeditionMapCmd(ctx, "")
case "run", "explore", "advance": case "run", "explore", "advance":
@@ -332,83 +344,14 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't parse supply packs: "+err.Error()) 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()) out, err := p.performExpeditionStart(ctx.Sender, c, zoneForCaps, purchase, "")
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")
if err != nil { 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()) return p.SendDM(ctx.Sender, err.Error())
} }
markActedToday(ctx.Sender) zone, supplies, startLine := out.Zone, out.Supplies, out.StartLine
var b strings.Builder var b strings.Builder
b.WriteString(fmt.Sprintf("🗺 **Expedition begins — %s** _(T%d)_\n\n", zone.Display, int(zone.Tier))) 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()) 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: // beginExpedition performs the non-interactive half of starting an expedition:
// supply freebies, the coin debit, persistence, the starting region's run, and // 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 // 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 // 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. // 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) { 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 { if p.euro == nil {
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Coin system unavailable — try again later.") return nil, ExpeditionSupplies{}, "", fmt.Errorf("Coin system unavailable — try again later.")
} }
cost := float64(purchase.Cost()) 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 // Holiday perk: a complimentary standard pack is added to the supplies
// snapshot without inflating the coin cost. Bypasses the per-tier cap // 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) supplies := makeSupplies(zone.Tier, suppliesPurchase)
// Debit coins; bail on debit failure (race / cap). // 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).") return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't debit outfitting cost (try again).")
} }
exp, err := startExpedition(uid, zone.ID, "", supplies) exp, err := startExpedition(uid, zone.ID, "", supplies)
if err != nil { if err != nil {
// Refund on persistence failure. // 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) 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 // Refund and tear the expedition row back down — without a
// linked run, harvest and rooms can't function. // linked run, harvest and rooms can't function.
_ = abandonExpedition(uid) _ = 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) return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't outfit the first region: %s", err)
} }
@@ -697,65 +844,102 @@ func formatLogTimestamp(t time.Time) string {
// ── abandon ───────────────────────────────────────────────────────────────── // ── abandon ─────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error { // Sentinels for the two ways abandoning can be refused, so the web action queue
exp, isLeader, err := activeExpeditionFor(ctx.Sender) // 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 { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error()) return abandonOutcome{}, err
} }
if exp == nil { if exp == nil {
// An extracted expedition is still the owner's to close — it holds the // An extracted expedition is still the owner's to close — it holds the
// roster until the resume window lapses. Without this, a leader who // roster until the resume window lapses. Without this, a leader who
// wanted out had to pay to `!resume` first just to abandon. // wanted out had to pay to `!resume` first just to abandon.
if exp, err = getResumableExpedition(ctx.Sender); err != nil { if exp, err = getResumableExpedition(uid); err != nil {
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error()) return abandonOutcome{}, err
} }
isLeader = exp != nil isLeader = exp != nil
} }
if 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 { if !isLeader {
// Abandoning throws away everyone's day. A member leaves alone. // 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.") "Only your party leader can abandon the expedition. `!expedition leave` to walk out alone.")
} }
zone, _ := getZone(exp.ZoneID) 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 audience := expeditionAudience(exp) // read before abandonExpedition disbands the roster
if err := abandonExpedition(ctx.Sender); err != nil { if err := abandonExpedition(uid); err != nil {
return p.SendDM(ctx.Sender, "Couldn't abandon: "+err.Error()) return abandonOutcome{}, err
} }
markActedToday(ctx.Sender) markActedToday(uid)
_ = retireAllRegionRuns(exp) _ = retireAllRegionRuns(exp)
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "expedition abandoned", "") _ = 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 // 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. // are already spent and their loot is already banked. Say the true thing.
body := fmt.Sprintf( body := fmt.Sprintf(
"Expedition in **%s** abandoned on Day %d. Supplies are forfeit. The dungeon remembers.", "Expedition in **%s** abandoned on Day %d. Supplies are forfeit. The dungeon remembers.",
zone.Display, exp.CurrentDay) out.Zone.Display, out.Day)
if extracted { if out.Extracted {
body = fmt.Sprintf( 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.", "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 return p.SendDM(ctx.Sender, body)
// 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
} }
// helper: ensure we don't shadow id.UserID import in test harness. // 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 { if err := removeAdvInventoryItem(spendTool); err != nil {
slog.Warn("expedition: autopilot tools spend", "user", run.UserID, "err", err) 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]) fireGraphRegionTransition(run.UserID, g.Nodes[run.CurrentNode], g.Nodes[chosen.To])
if exp != nil { 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) slog.Warn("expedition: backtrack clear fork", "run", run.RunID, "err", err)
return false return false
} }
beatLock(run, "", "sealed")
if _, err := revisitZoneRun(run.RunID, target, run.VisitedNodes); err != nil { if _, err := revisitZoneRun(run.RunID, target, run.VisitedNodes); err != nil {
slog.Warn("expedition: backtrack from dead fork", "run", run.RunID, "err", err) slog.Warn("expedition: backtrack from dead fork", "run", run.RunID, "err", err)
return false return false
+170 -50
View File
@@ -352,32 +352,54 @@ func resumeExpedition(expID string, supplies ExpeditionSupplies) error {
// ── !extract command ──────────────────────────────────────────────────────── // ── !extract command ────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleExtractCmd(ctx MessageContext, _ string) error { // Sentinels for the two ways an extraction can be refused. They exist so the
userMu := p.advUserLock(ctx.Sender) // 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() userMu.Lock()
defer userMu.Unlock() defer userMu.Unlock()
exp, isLeader, err := activeExpeditionFor(ctx.Sender) exp, isLeader, err := activeExpeditionFor(uid)
if err != nil { 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 { if exp == nil {
return p.SendDM(ctx.Sender, "No active expedition to extract from.") return extractOutcome{}, errExtractNoRun
} }
if !isLeader { if !isLeader {
// Extraction ends the expedition for the whole roster, so it is the // Extraction ends the expedition for the whole roster, so it is the
// leader's call — the same reasoning that makes `!flee` leader-only. // 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) zone, _ := getZone(exp.ZoneID)
updated, err := voluntaryExtractExpedition(ctx.Sender) updated, err := voluntaryExtractExpedition(uid)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't extract: "+err.Error()) return extractOutcome{}, err
} }
line := flavor.Pick(flavor.ExtractionVoluntary) line := flavor.Pick(flavor.ExtractionVoluntary)
_ = appendExpeditionLog(updated.ID, updated.CurrentDay, "narrative", _ = appendExpeditionLog(updated.ID, updated.CurrentDay, "narrative",
"voluntary extraction", line) "voluntary extraction", line)
markActedToday(ctx.Sender) markActedToday(uid)
var b strings.Builder var b strings.Builder
b.WriteString(fmt.Sprintf("🚪 **Extraction — %s, Day %d**\n\n", 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 // Emergence seam: surfacing from a run is when an animal may have moved
// into the empty house. Every member surfaced, so every member rolls. // into the empty house. Every member surfaced, so every member rolls.
for _, uid := range expeditionAudience(updated) { for _, member := range expeditionAudience(updated) {
p.maybeRollPetArrivalOnEmerge(uid) 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 return nil
} }
// ── !resume command ───────────────────────────────────────────────────────── // ── !resume command ─────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleResumeCmd(ctx MessageContext, args string) error { // Sentinels for the ways going back in can be refused. Same contract as
userMu := p.advUserLock(ctx.Sender) // 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() userMu.Lock()
defer userMu.Unlock() defer userMu.Unlock()
c, err := LoadDnDCharacter(ctx.Sender) c, err := LoadDnDCharacter(uid)
if err != nil { 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 { 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) zone, _ := getZone(existing.ZoneID)
if !isLeader { 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`.", "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.", "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 { 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 { if exp == nil {
return p.SendDM(ctx.Sender, "No extracted expedition to resume. Use `!expedition start <zone>` to begin a new one.") return resumeOutcome{}, refuseAdv(errResumeNothing,
"No extracted expedition to resume. Use `!expedition start <zone>` to begin a new one.")
} }
if extractionLapsed(exp, time.Now().UTC()) { if extractionLapsed(exp, time.Now().UTC()) {
// Expire it so it doesn't keep resurfacing. The hourly sweeper would get // 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. // here on its own; this keeps the refusal and the reap in one breath.
_ = completeExpedition(exp.ID, ExpeditionStatusFailed) _ = 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.") "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. // D5-b: prompt for a preset loadout on empty args.
if strings.TrimSpace(args) == "" { if strings.TrimSpace(loadoutTok) == "" {
return p.SendDM(ctx.Sender, renderLoadoutPrompt(resumeZone, "resume")) 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 { 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 { if err := purchase.Validate(zone.Tier); err != nil {
return p.SendDM(ctx.Sender, "Invalid pack selection: "+err.Error()) return resumeOutcome{}, refuseAdv(errResumeBadPacks, "Invalid pack selection: %s", err.Error())
} }
cost := float64(purchase.Cost()) cost := float64(purchase.Cost())
if p.euro == nil { 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 { paid := idemKey != "" && p.euro.HasExternalTx(idemKey)
return p.SendDM(ctx.Sender, fmt.Sprintf( if !paid {
if balance := p.euro.GetBalance(uid); balance < cost {
return resumeOutcome{}, refuseAdv(errResumeBroke,
"Not enough coins. Outfitting costs **%d** but you have **%.0f**.", "Not enough coins. Outfitting costs **%d** but you have **%.0f**.",
int(cost), balance)) 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) supplies := makeSupplies(zone.Tier, purchase)
if err := resumeExpedition(exp.ID, supplies); err != nil { if err := resumeExpedition(exp.ID, supplies); err != nil {
p.euro.Credit(ctx.Sender, cost, "expedition resume refund") refund("expedition resume refund", "")
return p.SendDM(ctx.Sender, "Couldn't resume: "+err.Error()) return resumeOutcome{}, refuseAdv(errResumeFailed, "Couldn't resume: %s", err.Error())
} }
exp.Status = ExpeditionStatusActive exp.Status = ExpeditionStatusActive
exp.Supplies = supplies exp.Supplies = supplies
@@ -489,25 +594,40 @@ func (p *AdventurePlugin) handleResumeCmd(ctx MessageContext, args string) error
exp.RegionState[regionStateRegionRuns] = map[string]string{} exp.RegionState[regionStateRegionRuns] = map[string]string{}
_ = persistRegionState(exp) _ = persistRegionState(exp)
if _, err := ensureRegionRun(exp, c.Level); err != nil { if _, err := ensureRegionRun(exp, c.Level); err != nil {
p.euro.Credit(ctx.Sender, cost, "expedition resume refund (run-spawn failed)") refund("expedition resume refund (run-spawn failed)", ":region")
return p.SendDM(ctx.Sender, "Couldn't outfit the resumed region: "+err.Error()) return resumeOutcome{}, refuseAdv(errResumeFailed, "Couldn't outfit the resumed region: %s", err.Error())
} }
line := flavor.Pick(flavor.ExpeditionResume) line := flavor.Pick(flavor.ExpeditionResume)
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", _ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative",
"expedition resumed", line) "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 var b strings.Builder
b.WriteString(fmt.Sprintf("🚪 **Expedition resumed — %s, Day %d**\n\n", b.WriteString(fmt.Sprintf("🚪 **Expedition resumed — %s, Day %d**\n\n",
zone.Display, exp.CurrentDay)) out.Zone.Display, out.Day))
if line != "" { if out.Line != "" {
b.WriteString(line) b.WriteString(out.Line)
b.WriteString("\n\n") b.WriteString("\n\n")
} }
b.WriteString(fmt.Sprintf("**Re-outfitted:** %.0f SU (%d standard, %d deluxe) — %d coins\n", b.WriteString(fmt.Sprintf("**Re-outfitted:** %.0f SU (%d standard, %d deluxe) — %d coins\n",
supplies.Max, purchase.StandardPacks, purchase.DeluxePacks, purchase.Cost())) out.Supplies.Max, out.Purchase.StandardPacks, out.Purchase.DeluxePacks, out.Purchase.Cost()))
b.WriteString(fmt.Sprintf("**Threat:** %d / 100 (resumed at extraction value)\n", exp.ThreatLevel)) b.WriteString(fmt.Sprintf("**Threat:** %d / 100 (resumed at extraction value)\n", out.Threat))
if exp.TemporalStack != 0 { if out.Stack != 0 {
b.WriteString(fmt.Sprintf("**Zone stack:** %d (resumed)\n", exp.TemporalStack)) b.WriteString(fmt.Sprintf("**Zone stack:** %d (resumed)\n", out.Stack))
} }
b.WriteString("\nUse `!expedition status` for the daily briefing.") b.WriteString("\nUse `!expedition status` for the daily briefing.")
return p.SendDM(ctx.Sender, b.String()) return p.SendDM(ctx.Sender, b.String())
@@ -175,3 +175,28 @@ func TestResume_WindowExpired(t *testing.T) {
time.Since(*got.CompletedAt), extractResumeWindow) 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)
}
}
}
@@ -170,6 +170,13 @@ func (p *AdventurePlugin) advanceToNextRegion(userID id.UserID, exp *Expedition,
tc := resolveTransitWanderingCheck(exp, charClass, nil) tc := resolveTransitWanderingCheck(exp, charClass, nil)
_ = processTransitWanderingCheck(exp, tc) _ = 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 // R2 — retire the outgoing region's DungeonRun before mutating
// CurrentRegion so retireRegionRun keys the right region. // CurrentRegion so retireRegionRun keys the right region.
if err := retireRegionRun(exp, cur.ID); err != nil { if err := retireRegionRun(exp, cur.ID); err != nil {
+17 -1
View File
@@ -893,6 +893,7 @@ func (p *AdventurePlugin) runHarvestForAdvance(
if herr != nil { if herr != nil {
return autoHarvestResult{}, "" return autoHarvestResult{}, ""
} }
beatHaul(fresh, hr.Summary)
return hr, renderAutoHarvestFooter(hr.Summary) return hr, renderAutoHarvestFooter(hr.Summary)
} }
@@ -1061,7 +1062,8 @@ func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone Zo
case RoomEntry: case RoomEntry:
return return
case RoomTrap: case RoomTrap:
_, narration := p.resolveTrapRoom(userID, run, zone) damage, narration := p.resolveTrapRoom(userID, run, zone)
beatTrap(userID, run, damage)
outcome = narration outcome = narration
return return
case RoomExploration: case RoomExploration:
@@ -1125,6 +1127,12 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
result := pres.Seats[0] result := pres.Seats[0]
postHP, maxHP := dndHPSnapshot(userID) postHP, maxHP := dndHPSnapshot(userID)
nat20s, nat1s := scanMoodEventsFromEvents(run.RunID, pres.Events) 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. // Compact mode: skip TwinBee banter, skip the multi-beat play-by-play.
// Render a single outcome line. Still records kills, threat, and drops. // 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 // tryPatrolEncounter); see retreatThreatBump in
// dnd_expedition_combat.go. // dnd_expedition_combat.go.
_, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath) _, _ = 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) _ = abandonZoneRun(userID)
// Timeout loss = retreat; the fighters took wounds but nobody actually // Timeout loss = retreat; the fighters took wounds but nobody actually
// died. Don't fire markAdventureDead — that would trigger the 6h respawn // died. Don't fire markAdventureDead — that would trigger the 6h respawn
+6
View File
@@ -238,6 +238,7 @@ func (p *AdventurePlugin) resolveSecretRoom(userID id.UserID, run *DungeonRun, z
it := item it := item
if line := p.grantZoneItem(userID, &it, "🧪"); line != "" { if line := p.grantZoneItem(userID, &it, "🧪"); line != "" {
rewards = append(rewards, 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) slog.Error("zone: addLoot audit", "user", userID, "item", entry.ItemID, "err", err)
} }
granted = append(granted, entry.ItemID) granted = append(granted, entry.ItemID)
source := "zone"
if bossCleared {
source = "boss"
}
beatTreasure(run, item.Name, source)
} }
return granted return granted
} }
+8
View File
@@ -295,6 +295,7 @@ func startZoneRun(userID id.UserID, zoneID ZoneID, dndLevel int, rng *rand.Rand)
); err != nil { ); err != nil {
return nil, fmt.Errorf("insert zone run: %w", err) return nil, fmt.Errorf("insert zone run: %w", err)
} }
beatRunStart(userID, run, zone)
return run, nil return run, nil
} }
@@ -581,6 +582,7 @@ func abandonZoneRun(userID id.UserID) error {
if r == nil { if r == nil {
return ErrNoActiveRun return ErrNoActiveRun
} }
beatRunEnd(r, "abandoned")
_, err = db.Get().Exec(` _, err = db.Get().Exec(`
UPDATE dnd_zone_run UPDATE dnd_zone_run
SET abandoned = 1, SET abandoned = 1,
@@ -599,6 +601,12 @@ func abandonZoneRunByID(runID string) error {
if runID == "" { if runID == "" {
return nil 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(` _, err := db.Get().Exec(`
UPDATE dnd_zone_run UPDATE dnd_zone_run
SET abandoned = 1, SET abandoned = 1,
+47 -17
View File
@@ -280,47 +280,77 @@ func (p *AdventurePlugin) expeditionCmdParty(ctx MessageContext) error {
return p.SendDM(ctx.Sender, b.String()) return p.SendDM(ctx.Sender, b.String())
} }
// expeditionCmdLeave walks a member out. The leader cannot leave — their row is // Sentinels for the two ways walking out can be refused, so the web action queue
// the expedition — so they are pointed at `!extract`, which ends it for all. // can pick a verdict without reading prose. errLeaveIsLeader is deliberately not
func (p *AdventurePlugin) expeditionCmdLeave(ctx MessageContext) error { // 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 // Resolve the seat the way the guards that trap them do. seatedExpeditionFor
// spans `extracting`, which activeExpeditionFor does not: a leader who // spans `extracting`, which activeExpeditionFor does not: a leader who
// extracts and never resumes would otherwise leave their members seated — // extracts and never resumes would otherwise leave their members seated —
// refused a new adventure by the guard, and told "no active expedition" by // 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 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. // the gate sees. It already excludes leaders, so they fall through below.
seated, err := seatedExpeditionFor(ctx.Sender) seated, err := seatedExpeditionFor(uid)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error()) return err
} }
if seated != nil { 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 { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error()) return err
} }
if exp == nil { if exp == nil {
return p.SendDM(ctx.Sender, "No active expedition.") return refuseAdv(errLeaveNothing, "No active expedition.")
} }
if isLeader { 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.") "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 // expeditionCmdLeave walks a member out. The leader cannot leave — their row is
// a member's seat resolves: the `extracting` limbo and the plain active party. // the expedition — so they are pointed at `!extract`, which ends it for all.
func (p *AdventurePlugin) leaveSeatedParty(ctx MessageContext, exp *Expedition) error { func (p *AdventurePlugin) expeditionCmdLeave(ctx MessageContext) error {
if err := leaveParty(exp.ID, ctx.Sender); err != nil { 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, "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 // 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 // it, and clawing them back would let a member starve the party on their way
// out of the door. // out of the door.
_ = p.SendDM(id.UserID(exp.UserID), fmt.Sprintf( _ = p.SendDM(id.UserID(exp.UserID), fmt.Sprintf(
"**%s** turned back. Their supplies stay with the party.", p.DisplayName(ctx.Sender))) "**%s** turned back. Their supplies stay with the party.", p.DisplayName(uid)))
return p.SendDM(ctx.Sender, "You turn back for town. Your supplies stay with the party.") return nil
} }
+7 -2
View File
@@ -279,8 +279,12 @@ func claimRealmFirst(kind, target string) bool {
// *started* — every dispatch was an outcome — which is why the two live boredom // *started* — every dispatch was an outcome — which is why the two live boredom
// runs produced no news at all. // runs produced no news at all.
// //
// The event_type must be one Pete already knows: an unknown type is a 400, which // The event_type no longer has to be one Pete already knows. It used to: an
// retries and then parks the bulletin forever. Deploy Pete first. // 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) { func emitBoredomDeparture(userID id.UserID, zone ZoneDefinition, level int) {
if !peteclient.Enabled() || !newsEmissionOn() { if !peteclient.Enabled() || !newsEmissionOn() {
return return
@@ -339,6 +343,7 @@ func emitZoneClearNews(userID id.UserID, exp *Expedition) {
Boss: zone.Boss.Name, Boss: zone.Boss.Name,
Level: lvl, Level: lvl,
Outcome: "cleared", Outcome: "cleared",
RunID: latestRunIDForNews(userID),
OccurredAt: ts, OccurredAt: ts,
}, userID, "") }, userID, "")
} }
+52
View File
@@ -226,3 +226,55 @@ func TestDetailSnapshotSkipsDeadPlayers(t *testing.T) {
t.Fatalf("detail set = %+v, want just the living player", snap.Players) 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)
}
}
+7 -5
View File
@@ -54,7 +54,7 @@ func authorDispatch(f peteclient.Fact) (headline, lede string) {
} }
prompt := buildDispatchPrompt(f) prompt := buildDispatchPrompt(f)
raw, err := callOllamaDispatch(host, model, prompt) raw, err := callOllamaDispatch(dispatchHTTP, host, model, prompt)
if err != nil { if err != nil {
slog.Warn("pete dispatch: LLM authoring failed, Pete will template", "guid", f.GUID, "err", err) slog.Warn("pete dispatch: LLM authoring failed, Pete will template", "guid", f.GUID, "err", err)
return "", "" return "", ""
@@ -129,9 +129,11 @@ The event:
} }
// callOllamaDispatch posts a single non-streaming generation and returns the raw // callOllamaDispatch posts a single non-streaming generation and returns the raw
// completion (think-tags stripped). Its own bounded client, separate from the // completion (think-tags stripped). The client is a parameter because the two
// interactive callOllama, because the game loop cannot wait 120s on the news. // callers have genuinely different patience: a dispatch is authored on a game
func callOllamaDispatch(host, model, prompt string) (string, error) { // 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{}{ payload := map[string]interface{}{
"model": model, "model": model,
"prompt": prompt, "prompt": prompt,
@@ -146,7 +148,7 @@ func callOllamaDispatch(host, model, prompt string) (string, error) {
return "", fmt.Errorf("marshal payload: %w", err) return "", fmt.Errorf("marshal payload: %w", err)
} }
apiURL := strings.TrimRight(host, "/") + "/api/generate" 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 { if err != nil {
return "", fmt.Errorf("ollama request: %w", err) return "", fmt.Errorf("ollama request: %w", err)
} }
+129
View File
@@ -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
}
+486
View File
@@ -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
}
+385
View File
@@ -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)
}
}
+209
View File
@@ -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)
}
}
+633
View File
@@ -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
}
+351
View File
@@ -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")
}
}
+88 -3
View File
@@ -52,6 +52,18 @@ func (p *AdventurePlugin) peteRosterTicker() {
} }
p.pushRoster() p.pushRoster()
p.pushDetails() 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 { if p.euro != nil {
pd.Balance = p.euro.GetBalance(uid) 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) snap.Players = append(snap.Players, pd)
} }
return snap, nil 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 // 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. // 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 { func petViews(adv *AdventureCharacter) []peteclient.PetView {
var out []peteclient.PetView var out []peteclient.PetView
if adv.PetType != "" && !adv.PetChasedAway { if adv.PetType != "" && !adv.PetChasedAway {
out = append(out, peteclient.PetView{ out = append(out, peteclient.PetView{
Type: adv.PetType, Name: adv.PetName, Level: adv.PetLevel, 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 { if adv.Pet2Type != "" && !adv.Pet2ChasedAway {
out = append(out, peteclient.PetView{ out = append(out, peteclient.PetView{
Type: adv.Pet2Type, Name: adv.Pet2Name, Level: adv.Pet2Level, 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 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. // buildRosterSnapshot assembles the complete board.
// //
// Complete is the contract: Pete *replaces* its board with this, so anyone we // 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) 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) zone := zoneOrFallback(exp.ZoneID)
e.Status = "expedition" e.Status = "expedition"
e.Zone = zone.Display e.Zone = zone.Display
@@ -561,6 +645,7 @@ func buildRosterSnapshot(now time.Time, euro *EuroPlugin) (peteclient.RosterSnap
if e.Detail != nil { if e.Detail != nil {
e.Detail.Supplies = int(exp.Supplies.Current) e.Detail.Supplies = int(exp.Supplies.Current)
e.Detail.ThreatLevel = exp.ThreatLevel e.Detail.ThreatLevel = exp.ThreatLevel
e.Detail.Party = partySeatViews(exp)
if exp.RunID != "" { if exp.RunID != "" {
if run, rerr := getZoneRun(exp.RunID); rerr == nil && run != nil && run.TotalRooms > 0 { 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) e.Detail.Room = fmt.Sprintf("%d / %d", run.CurrentRoom+1, run.TotalRooms)
+197
View File
@@ -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
}
+395
View File
@@ -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, "<think>"); i != -1 {
if j := strings.Index(s, "</think>"); j != -1 {
s = s[:i] + s[j+len("</think>"):]
}
}
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)
}
+199
View File
@@ -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", "<think>hmm</think>\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)
}
}
+281
View File
@@ -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()
}
+241
View File
@@ -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"
})
}
+343
View File
@@ -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)
}
}
+376
View File
@@ -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"
}
}
+254
View File
@@ -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)
}
}
}
+20 -1
View File
@@ -436,6 +436,20 @@ func completeRunAtNode(runID string, boss bool) error {
if boss { if boss {
bossI = 1 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(` _, err := db.Get().Exec(`
UPDATE dnd_zone_run UPDATE dnd_zone_run
SET boss_defeated = ?, SET boss_defeated = ?,
@@ -474,7 +488,12 @@ func advanceZoneRunNode(runID, nextNode string) (int, error) {
nextNode, string(visitedJSON), runID); err != nil { nextNode, string(visitedJSON), runID); err != nil {
return 0, err 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 // resolveForkChoice takes a 1-based choice index against a pending
+5 -1
View File
@@ -69,7 +69,11 @@ func revisitZoneRun(runID, targetNode string, visited []string) (int, error) {
WHERE run_id = ?`, targetNode, runID); err != nil { WHERE run_id = ?`, targetNode, runID); err != nil {
return 0, err 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 <N>` (also reachable as // handleRevisitCmd implements `!revisit <N>` (also reachable as