mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 19:01:09 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34519c9145 | ||
|
|
509df7fadf | ||
|
|
c327cfc5f1 | ||
|
|
a27fb298af | ||
|
|
e5d4bd6dc5 | ||
|
|
f73ab56ac8 | ||
|
|
fff2aa79d0 | ||
|
|
d6136d39d9 | ||
|
|
7a5c8341f0 | ||
|
|
a5b1961486 | ||
|
|
0570afc2e4 |
@@ -700,6 +700,11 @@ func RunMaintenance() {
|
||||
// weight the drain query already skips — drop it so a durable outage
|
||||
// can't accrete rows forever.
|
||||
{"pete_emit_queue_parked", `DELETE FROM pete_emit_queue WHERE sent_at IS NULL AND created_at < ?`, []interface{}{cutoff30d}},
|
||||
// Run beats — the local copy is a delivery buffer, not an archive. Pete
|
||||
// keeps the run report; once a beat is a week old it has either shipped
|
||||
// or missed its window entirely (the liveblog it feeds is about a run
|
||||
// happening *now*), so both states reap on the same clock.
|
||||
{"pete_run_beat", `DELETE FROM pete_run_beat WHERE occurred_at < ?`, []interface{}{cutoff7d}},
|
||||
|
||||
// Rate limits — purge entries older than today
|
||||
{"rate_limits", `DELETE FROM rate_limits WHERE date < ?`, []interface{}{today}},
|
||||
@@ -1045,6 +1050,24 @@ CREATE TABLE IF NOT EXISTS pete_emit_queue (
|
||||
sent_at INTEGER
|
||||
);
|
||||
|
||||
-- Run beats: the room-by-room texture of an expedition, on its way to Pete's
|
||||
-- liveblog. Deliberately NOT pete_emit_queue — these are high-volume and
|
||||
-- low-stakes, and a run that generates forty beats must never be able to crowd
|
||||
-- a death dispatch out of the retry budget. Ordering is the whole contract:
|
||||
-- (run_id, seq) is the primary key and Pete is idempotent on the pair, so a
|
||||
-- re-sent batch collapses and a re-ordered one still sorts right on arrival.
|
||||
CREATE TABLE IF NOT EXISTS pete_run_beat (
|
||||
run_id TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
occurred_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
payload TEXT NOT NULL DEFAULT '{}',
|
||||
sent_at INTEGER,
|
||||
PRIMARY KEY (run_id, seq)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pete_run_beat_unsent
|
||||
ON pete_run_beat(sent_at, run_id, seq);
|
||||
|
||||
-- Players who opted out of being named in Pete's adventure news. Enforced at
|
||||
-- emit time (anonymize, never delete). Mirrors shade_optout.
|
||||
CREATE TABLE IF NOT EXISTS news_optout (
|
||||
@@ -1822,6 +1845,20 @@ CREATE TABLE IF NOT EXISTS equip_applied_orders (
|
||||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- The web ACTION queue's idempotency ledger — the same job as the table above,
|
||||
-- for the verbs that play the game rather than dress the character (extract, a
|
||||
-- Siege bout). Its own table because the two queues have their own guid spaces
|
||||
-- and their own poll loops, and a shared ledger would make a bug in one able to
|
||||
-- silence the other. The stakes are higher here than for equip: a replayed
|
||||
-- extraction ends a run the player resumed, and a replayed bout spends a day the
|
||||
-- player has not been given back.
|
||||
CREATE TABLE IF NOT EXISTS adv_applied_orders (
|
||||
guid TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL, -- the terminal verdict we filed, replayed on re-offer
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Babysitting Service
|
||||
CREATE TABLE IF NOT EXISTS adventure_babysit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
@@ -48,6 +48,13 @@ type Fact struct {
|
||||
Milestone string `json:"milestone,omitempty"`
|
||||
OccurredAt int64 `json:"occurred_at"`
|
||||
NoPush bool `json:"no_push,omitempty"` // backfill: suppress Pete web-push
|
||||
// RunID names the expedition this fact is the ENDING of, and only the three
|
||||
// facts that are one carry it: a clear, a retreat, a death. It is what lets
|
||||
// Pete's dispatch link back to the run's own report — the log, the numbers,
|
||||
// the moment it turned — instead of leaving a paragraph about an outcome with
|
||||
// no way back to what produced it. Empty everywhere else, and safe to be
|
||||
// empty: Pete renders the dispatch exactly as it did before the report existed.
|
||||
RunID string `json:"run_id,omitempty"`
|
||||
// Headline/Lede are LLM-authored prose for this fact, both optional. Pete
|
||||
// prefers them over its own template when present and past its prose-guard,
|
||||
// and falls back to the template otherwise — so an empty pair (LLM off, or
|
||||
@@ -277,6 +284,30 @@ type RosterDetail struct {
|
||||
ThreatLevel int `json:"threat_level,omitempty"`
|
||||
Room string `json:"room,omitempty"`
|
||||
Map *RosterMap `json:"map,omitempty"`
|
||||
// Party is who else is on this expedition, leader first. Absent for a solo
|
||||
// run — a party of one is not a party, and the page should say nothing rather
|
||||
// than draw a roster with a single chair in it.
|
||||
Party []PartySeatView `json:"party,omitempty"`
|
||||
}
|
||||
|
||||
// PartySeatView is one body on a shared expedition, as the public page may see
|
||||
// it. Kind is what the seat *is*, which the game keeps carefully separate:
|
||||
// "leader" owns the expedition row everyone else references, "member" is another
|
||||
// player, "companion" is the hired NPC (Pete) who fights but has no mailbox and
|
||||
// no loot.
|
||||
//
|
||||
// Name/Token are the same pair the board and the Siege muster use. The opt-out
|
||||
// rule here is the Siege *contributor* rule, not the realm-occupant rule: an
|
||||
// opted-out player's seat is anonymised (kept, with no name and no token) rather
|
||||
// than deleted. A party of three that renders as two is a false statement about
|
||||
// the run — the supply burn, the enemy scaling and the loot split all felt three
|
||||
// bodies — whereas an unnamed seat says only that somebody else was there, which
|
||||
// the zone line on this same page already implies.
|
||||
type PartySeatView struct {
|
||||
Kind string `json:"kind"` // leader|member|companion
|
||||
Name string `json:"name,omitempty"`
|
||||
Token string `json:"token,omitempty"` // empty: opted out, or a companion (no board row)
|
||||
Level int `json:"level,omitempty"`
|
||||
}
|
||||
|
||||
// RosterMap is the fog-of-war cut of an adventurer's zone graph: every node
|
||||
@@ -373,6 +404,234 @@ func PushRoster(ctx context.Context, snap RosterSnapshot) error {
|
||||
return std.post(ctx, "/api/ingest/roster", payload)
|
||||
}
|
||||
|
||||
// SiegeDefender is one adventurer's standing in the current Siege muster.
|
||||
//
|
||||
// Token is the same public board token the roster uses, so Pete can link a
|
||||
// defender to their page — and it is EMPTY for an opted-out player, with Name
|
||||
// carrying anonName instead. That is the whole opt-out story here: their damage
|
||||
// still counts and still holds its rank (the town's effort is the town's), but
|
||||
// there is no name to click. It differs from the board's rule (omit entirely)
|
||||
// on purpose — a defender board that silently dropped contributors would
|
||||
// understate what the town actually did to the boss.
|
||||
type SiegeDefender struct {
|
||||
Token string `json:"token,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Level int `json:"level,omitempty"`
|
||||
Fights int `json:"fights"`
|
||||
Damage int `json:"damage"`
|
||||
FoughtToday bool `json:"fought_today"`
|
||||
}
|
||||
|
||||
// SiegePast is one closed-out Siege for the history table.
|
||||
type SiegePast struct {
|
||||
BossID int64 `json:"boss_id"`
|
||||
BossName string `json:"boss_name"`
|
||||
Tier int `json:"tier"`
|
||||
Outcome string `json:"outcome"` // "defeated" | "survived"
|
||||
HPRemaining int `json:"hp_remaining"`
|
||||
HPMax int `json:"hp_max"`
|
||||
Defenders int `json:"defenders"`
|
||||
MVP string `json:"mvp,omitempty"`
|
||||
MVPFights int `json:"mvp_fights,omitempty"`
|
||||
EndedAt int64 `json:"ended_at"`
|
||||
}
|
||||
|
||||
// SiegeSnapshot is the complete war room: the live boss (if any), its muster,
|
||||
// and every Siege that came before. Same complete-snapshot contract as the
|
||||
// roster — Pete replaces its copy — so a defender omitted here leaves the board
|
||||
// and a resolved Siege stops showing a live bar.
|
||||
//
|
||||
// Defenders carries EVERY alive adventurer, not just contributors. The zero-fight
|
||||
// rows are the point: one bout per person per day means somebody who hasn't
|
||||
// swung today is a hit the town hasn't taken, and the page can only show that gap
|
||||
// if the people in it are on the wire.
|
||||
type SiegeSnapshot struct {
|
||||
SnapshotAt int64 `json:"snapshot_at"`
|
||||
Active bool `json:"active"`
|
||||
BossID int64 `json:"boss_id,omitempty"`
|
||||
BossName string `json:"boss_name,omitempty"`
|
||||
Tier int `json:"tier,omitempty"`
|
||||
HPCurrent int `json:"hp_current"`
|
||||
HPMax int `json:"hp_max"`
|
||||
StartsAt int64 `json:"starts_at,omitempty"`
|
||||
EndsAt int64 `json:"ends_at,omitempty"`
|
||||
BoutsToday int `json:"bouts_today"`
|
||||
Defenders []SiegeDefender `json:"defenders,omitempty"`
|
||||
History []SiegePast `json:"history,omitempty"`
|
||||
}
|
||||
|
||||
// PushSiege sends the war room to Pete, synchronously, and drops it on failure —
|
||||
// the same drop-the-lie semantics as PushRoster. A retried snapshot would claim
|
||||
// a pool level that has since moved, and the next tick carries the truth anyway.
|
||||
func PushSiege(ctx context.Context, snap SiegeSnapshot) error {
|
||||
if !Enabled() {
|
||||
return nil
|
||||
}
|
||||
payload, err := json.Marshal(snap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return std.post(ctx, "/api/ingest/siege", payload)
|
||||
}
|
||||
|
||||
// RunBeat is one structured moment inside an expedition run: a room entered, a
|
||||
// fight resolved, a trap sprung, a haul taken. Facts, never prose — Pete owns
|
||||
// the words, exactly as it does for a Fact. The engine already narrates every
|
||||
// one of these to Matrix and then throws the narration away; this carries the
|
||||
// shape underneath it so Pete can retell the run to somebody who wasn't there.
|
||||
//
|
||||
// (RunID, Seq) is the identity. Seq is monotonic per run and assigned at record
|
||||
// time, so Pete can order a batch that arrives out of order and drop a duplicate
|
||||
// without comparing contents.
|
||||
//
|
||||
// Nothing here is player-identifying except Token, which rides the `start` beat
|
||||
// only and is the same public board token the roster uses. An opted-out player's
|
||||
// beats are never pushed at all — see pushRunBeats.
|
||||
type RunBeat struct {
|
||||
RunID string `json:"run_id"`
|
||||
Seq int64 `json:"seq"`
|
||||
Kind string `json:"kind"` // start|room|combat|trap|treasure|haul|lock|camp|region|end|summary
|
||||
OccurredAt int64 `json:"occurred_at"`
|
||||
|
||||
Token string `json:"token,omitempty"` // `start` only: whose run this is
|
||||
Name string `json:"name,omitempty"` // `start` only: character name
|
||||
Level int `json:"level,omitempty"` // `start` only
|
||||
Zone string `json:"zone,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
Room int `json:"room,omitempty"` // 1-based, as the player sees it
|
||||
TotalRooms int `json:"total_rooms,omitempty"` // 0 when unknown
|
||||
RoomKind string `json:"room_kind,omitempty"` // entry|exploration|trap|elite|boss|secret
|
||||
Target string `json:"target,omitempty"` // monster, item, region, lock — the noun
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
Amount int `json:"amount,omitempty"` // damage taken, or a total quantity
|
||||
Count int `json:"count,omitempty"` // how many distinct things Amount covers
|
||||
HP int `json:"hp,omitempty"`
|
||||
HPMax int `json:"hp_max,omitempty"`
|
||||
Crits int `json:"crits,omitempty"`
|
||||
Fumbles int `json:"fumbles,omitempty"`
|
||||
// Prose is the single exception to "nouns and numbers only", and it is
|
||||
// confined to the one kind that has any: `summary`, the three sentences the
|
||||
// local model writes over a finished run. Pete guards it exactly as it guards
|
||||
// a dispatch lede and drops the words (not the beat) on a rejection. Every
|
||||
// other kind must leave this empty — Pete scrubs it if they don't.
|
||||
Prose string `json:"prose,omitempty"`
|
||||
}
|
||||
|
||||
// RealmZone is one zone as the realm map draws it: what it is, who first got
|
||||
// through it, how many have since, and who is inside it right now.
|
||||
//
|
||||
// FirstClearBy is a character name and FirstClearToken the public board token,
|
||||
// exactly as the Siege muster pairs them — and the token is EMPTY for a player
|
||||
// who has opted out, keeping the name off the page too (see buildRealmSnapshot:
|
||||
// an opted-out first-clearer is anonymised, not deleted, because deleting the
|
||||
// claim would make the zone read as never-cleared, which is a different and
|
||||
// false statement about the realm).
|
||||
type RealmZone struct {
|
||||
ID string `json:"id"`
|
||||
Display string `json:"display"`
|
||||
Tier int `json:"tier"`
|
||||
LevelMin int `json:"level_min"`
|
||||
LevelMax int `json:"level_max"`
|
||||
Faction string `json:"faction,omitempty"`
|
||||
Atmosphere string `json:"atmosphere,omitempty"`
|
||||
Postgame bool `json:"postgame,omitempty"` // T6 mythic: gated, drawn apart
|
||||
|
||||
FirstClearBy string `json:"first_clear_by,omitempty"`
|
||||
FirstClearToken string `json:"first_clear_token,omitempty"`
|
||||
FirstClearAt int64 `json:"first_clear_at,omitempty"`
|
||||
|
||||
Clears int `json:"clears"` // boss-defeated runs, all time
|
||||
Clearers int `json:"clearers"` // distinct adventurers who have managed it
|
||||
|
||||
Occupants []RealmOccupant `json:"occupants,omitempty"` // in there right now
|
||||
}
|
||||
|
||||
// RealmOccupant is somebody currently on an expedition in a zone. Same
|
||||
// name+token pair as everywhere else, and an opted-out player is omitted
|
||||
// outright rather than anonymised: unlike a first clear, presence is not part of
|
||||
// a shared tally that stops adding up without them, and "who is in there right
|
||||
// now" is exactly the live-location fact the liveblog is careful about.
|
||||
type RealmOccupant struct {
|
||||
Token string `json:"token,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Level int `json:"level,omitempty"`
|
||||
Day int `json:"day,omitempty"`
|
||||
}
|
||||
|
||||
// RealmFirst is one row of the hall of firsts: a thing that happened in the
|
||||
// realm exactly once ever, and who it happened to. The ledger
|
||||
// (news_realm_firsts) records only (kind, target, first_at) — the holder is
|
||||
// recovered by gogobee at push time from the run history, which is why this is
|
||||
// pushed rather than derived on Pete.
|
||||
type RealmFirst struct {
|
||||
Kind string `json:"kind"` // "zone" | "treasure"
|
||||
Target string `json:"target"` // the zone id or treasure key
|
||||
Display string `json:"display"` // the human name for it
|
||||
Tier int `json:"tier,omitempty"` // zone tier, when kind is "zone"
|
||||
Holder string `json:"holder,omitempty"` // character name, empty when unrecoverable
|
||||
Token string `json:"token,omitempty"` // board token; empty when opted out
|
||||
AtUnix int64 `json:"at_unix"` // when the realm first saw it
|
||||
}
|
||||
|
||||
// RealmStanding is one adventurer's line on the board. Every number here is a
|
||||
// lifetime total from the game's own run history — nothing is a rate, an
|
||||
// average, or anything that would move on its own while nobody played.
|
||||
type RealmStanding struct {
|
||||
Token string `json:"token,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Level int `json:"level"`
|
||||
ClassRace string `json:"class_race,omitempty"`
|
||||
DeepestTier int `json:"deepest_tier"` // deepest zone tier actually cleared
|
||||
Clears int `json:"clears"`
|
||||
Zones int `json:"zones"` // distinct zones cleared
|
||||
Firsts int `json:"firsts"` // realm-firsts held
|
||||
SiegeDamage int `json:"siege_damage"`
|
||||
SiegeFights int `json:"siege_fights"`
|
||||
}
|
||||
|
||||
// RealmSnapshot is the whole realm as one photograph: every zone, the hall of
|
||||
// firsts, and the board. Snapshot semantics, like the roster and the Siege —
|
||||
// Pete replaces its copy and a failed push is dropped, not retried.
|
||||
//
|
||||
// It is pushed on the roster ticker but NOT every tick: none of it moves fast
|
||||
// enough to be worth the aggregate queries every two minutes, and the page's
|
||||
// staleness window is generous for exactly that reason. See realmPushInterval.
|
||||
type RealmSnapshot struct {
|
||||
SnapshotAt int64 `json:"snapshot_at"`
|
||||
Zones []RealmZone `json:"zones,omitempty"`
|
||||
Firsts []RealmFirst `json:"firsts,omitempty"`
|
||||
Standings []RealmStanding `json:"standings,omitempty"`
|
||||
}
|
||||
|
||||
// PushRealm sends the realm pages' backing data to Pete. Drop-on-failure, same
|
||||
// as the other two snapshots.
|
||||
func PushRealm(ctx context.Context, snap RealmSnapshot) error {
|
||||
if !Enabled() {
|
||||
return nil
|
||||
}
|
||||
payload, err := json.Marshal(snap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return std.post(ctx, "/api/ingest/realm", payload)
|
||||
}
|
||||
|
||||
// PushRunBeats delivers a batch of beats. Unlike the snapshots this is
|
||||
// append-only and IS retried — a dropped beat is a hole in a story, not a stale
|
||||
// number that the next tick corrects. The caller only marks rows sent on success.
|
||||
func PushRunBeats(ctx context.Context, beats []RunBeat) error {
|
||||
if !Enabled() || len(beats) == 0 {
|
||||
return nil
|
||||
}
|
||||
payload, err := json.Marshal(struct {
|
||||
Beats []RunBeat `json:"beats"`
|
||||
}{beats})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return std.post(ctx, "/api/ingest/run", payload)
|
||||
}
|
||||
|
||||
// PlayerDetail is the private, owner-only expansion for one player: inventory,
|
||||
// vault, house, and pets. Like MischiefBalance it is keyed by localpart (the
|
||||
// sign-in name), in its own keyspace on Pete — Pete only ever serves it back to
|
||||
@@ -396,6 +655,63 @@ type PlayerDetail struct {
|
||||
// No omitempty: a €0 balance is a real, informative fact (a broke player), not
|
||||
// an absent one — dropping it would let the confirm dialog show a stale amount.
|
||||
Balance float64 `json:"balance"`
|
||||
// Zones is where this adventurer may go right now, priced. It is the offer
|
||||
// list behind the web's "send on expedition" picker: level gating and the T6
|
||||
// postgame gate are resolved here, so a zone the player cannot enter is simply
|
||||
// absent rather than shown and then refused. Empty while they are already out.
|
||||
Zones []ZoneOffer `json:"zones,omitempty"`
|
||||
// Resume is the extracted expedition waiting to be walked back into, priced
|
||||
// the same way. Absent when there is nothing to resume.
|
||||
Resume *ResumeOffer `json:"resume,omitempty"`
|
||||
// Babysit is the pet-care subscription's standing and price. Always present
|
||||
// for a live adventurer: "you already have one" is as useful to the page as a
|
||||
// price is.
|
||||
Babysit *BabysitOffer `json:"babysit,omitempty"`
|
||||
}
|
||||
|
||||
// ZoneOffer is one place the owner may set out for, with what the trip costs.
|
||||
// Pete renders these and does no arithmetic — the prices are the game's, quoted
|
||||
// at push time, and gogobee re-quotes them for real when the order lands.
|
||||
type ZoneOffer struct {
|
||||
ID string `json:"id"`
|
||||
Display string `json:"display"`
|
||||
Tier int `json:"tier"`
|
||||
Hook string `json:"hook,omitempty"`
|
||||
Postgame bool `json:"postgame,omitempty"`
|
||||
Loadouts []LoadoutOffer `json:"loadouts,omitempty"`
|
||||
}
|
||||
|
||||
// LoadoutOffer is one supply preset for a zone: what it is called, what it
|
||||
// costs, and roughly how long it lasts. Key is the token the order carries back.
|
||||
type LoadoutOffer struct {
|
||||
Key string `json:"key"` // lean|balanced|heavy
|
||||
Name string `json:"name"`
|
||||
Blurb string `json:"blurb,omitempty"`
|
||||
Cost int `json:"cost"`
|
||||
Days int `json:"days"` // provisions at the zone's daily burn
|
||||
}
|
||||
|
||||
// ResumeOffer is the extracted expedition the owner can still walk back into,
|
||||
// with the same priced loadouts as a fresh departure. ExpiresAt is the end of
|
||||
// the seven-day window, so the page can say how long is left rather than just
|
||||
// that there is a way back.
|
||||
type ResumeOffer struct {
|
||||
ZoneID string `json:"zone_id"`
|
||||
Display string `json:"display"`
|
||||
Tier int `json:"tier"`
|
||||
Day int `json:"day"`
|
||||
ExpiresAt int64 `json:"expires_at,omitempty"`
|
||||
Loadouts []LoadoutOffer `json:"loadouts,omitempty"`
|
||||
}
|
||||
|
||||
// BabysitOffer is the sitter's standing and price. WeekCost/MonthCost are the
|
||||
// two durations the game sells; they scale with level, which is why they are
|
||||
// pushed rather than hardcoded on Pete.
|
||||
type BabysitOffer struct {
|
||||
Active bool `json:"active"`
|
||||
ExpiresAt int64 `json:"expires_at,omitempty"`
|
||||
WeekCost int `json:"week_cost"`
|
||||
MonthCost int `json:"month_cost"`
|
||||
}
|
||||
|
||||
// EquipSlotView is one of the 5 standard equipment slots, carrying what the web
|
||||
@@ -496,11 +812,19 @@ type HouseView struct {
|
||||
}
|
||||
|
||||
// PetView is one pet slot.
|
||||
//
|
||||
// XP and XPNeeded are both in **centi-XP** — the engine's own unit, a hundredth
|
||||
// of a point, because a pet earns 1.5 XP per action and the ledger is an int.
|
||||
// Pete divides by 100 to show it and does no other arithmetic on either number:
|
||||
// the curve behind XPNeeded is petXPToNextLevel's, per level band, and it is not
|
||||
// Pete's business to know it. XPNeeded is 0 at the level cap, which is the only
|
||||
// signal that there is nothing left to fill.
|
||||
type PetView struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Level int `json:"level"`
|
||||
XP int `json:"xp,omitempty"`
|
||||
XPNeeded int `json:"xp_needed,omitempty"` // 0 = at the level cap
|
||||
ArmorTier int `json:"armor_tier,omitempty"`
|
||||
}
|
||||
|
||||
@@ -748,6 +1072,88 @@ func VerdictEquip(ctx context.Context, guid, status, detail string) error {
|
||||
return std.post(ctx, "/api/equip/verdict", payload)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The action queue
|
||||
//
|
||||
// The equip queue's sibling, and the first one that plays the game rather than
|
||||
// dressing the character. An owner clicks "Pull out" on their own adventurer
|
||||
// page or "Take your bout" on the war room; Pete records the intent and we drain
|
||||
// it here. Same non-idempotent problem, same answer: the poller guards on the
|
||||
// order guid before it runs anything, because an extraction ends a run and a
|
||||
// bout spends the day's only swing, and neither converges on a replay.
|
||||
//
|
||||
// Nothing in an order names a character. Pete resolves that from the session
|
||||
// (one account, one localpart, one adventurer) so there is no id on the wire for
|
||||
// a client to forge — the contrast with EquipOrder, which has to carry an item
|
||||
// id and a slot, is deliberate.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// AdvOrder is one requested action as Pete describes it. owner_localpart is the
|
||||
// Matrix localpart whose adventurer acts; token and character_name are display
|
||||
// copy Pete froze at order time and we ignore both.
|
||||
type AdvOrder struct {
|
||||
GUID string `json:"guid"`
|
||||
OwnerLocalpart string `json:"owner_localpart"`
|
||||
Token string `json:"token"`
|
||||
CharacterName string `json:"character_name"`
|
||||
Action string `json:"action"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
// Params is the verb's arguments, and only the verbs that take any carry it:
|
||||
// which zone, which supply loadout, how many days of sitting. It never names
|
||||
// an adventurer — that still comes from the session on Pete's side — and
|
||||
// every field in it is re-resolved against the game's own tables before it
|
||||
// means anything, so a forged zone or a forged price buys nothing.
|
||||
Params *AdvOrderParams `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
// AdvOrderParams is the union of every verb's arguments, flat rather than
|
||||
// per-verb because there are three of them and each reads one or two fields.
|
||||
// Anything a verb does not read is ignored rather than rejected.
|
||||
type AdvOrderParams struct {
|
||||
Zone string `json:"zone,omitempty"` // zone id, for expedition_start
|
||||
Loadout string `json:"loadout,omitempty"` // lean|balanced|heavy, for expedition_start / resume
|
||||
Days int `json:"days,omitempty"` // 7 or 30, for babysit
|
||||
}
|
||||
|
||||
// Action names, the wire contract's half of storage.AdvAction* on Pete.
|
||||
const (
|
||||
AdvOrderExtract = "extract"
|
||||
AdvOrderSiegeJoin = "siege_join"
|
||||
AdvOrderExpedition = "expedition_start"
|
||||
AdvOrderResume = "expedition_resume"
|
||||
AdvOrderBabysit = "babysit"
|
||||
// The three doors the web verbs' own refusal text used to name without
|
||||
// offering: `!expedition abandon`, `!expedition leave`, `!adventure babysit
|
||||
// cancel`. None of them takes an argument and none of them spends money.
|
||||
AdvOrderAbandon = "expedition_abandon"
|
||||
AdvOrderLeave = "expedition_leave"
|
||||
AdvOrderBabysitCancel = "babysit_cancel"
|
||||
)
|
||||
|
||||
// PendingOrders asks Pete for web actions waiting on us. A Pete predating the
|
||||
// queue answers 404, surfaced here as an error the poll loop logs quietly.
|
||||
func PendingOrders(ctx context.Context) ([]AdvOrder, error) {
|
||||
if !Enabled() {
|
||||
return nil, nil
|
||||
}
|
||||
var out []AdvOrder
|
||||
if err := std.getJSON(ctx, "/api/adventure/orders/pending", &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// VerdictOrder files our verdict on a web action. Idempotent on Pete, so a
|
||||
// retried verdict is safe.
|
||||
func VerdictOrder(ctx context.Context, guid, status, detail string) error {
|
||||
payload, err := json.Marshal(map[string]string{"guid": guid, "status": status, "detail": detail})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return std.post(ctx, "/api/adventure/orders/verdict", payload)
|
||||
}
|
||||
|
||||
// getJSON does a bearer-authed GET and decodes the body.
|
||||
func (c *Client) getJSON(ctx context.Context, path string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.IngestURL+path, nil)
|
||||
|
||||
@@ -253,6 +253,12 @@ func (p *AdventurePlugin) Init() error {
|
||||
// deaths, single-holder achievements) the first boot the seam is live, so
|
||||
// launch doesn't open onto an empty section. One-shot, kept (see gap #7).
|
||||
p.bootstrapPeteNewsBackfill()
|
||||
// Repair the zone half of news_realm_firsts: the original one-shot filtered
|
||||
// on `abandoned = 0` (which does not mean anybody gave up) and dated every
|
||||
// row to the minute it ran. Seeds only, emits nothing. Runs regardless of the
|
||||
// news switches — a ledger that is right only while emission is on mis-tiers
|
||||
// the first dispatch after somebody flips it. One-shot, kept.
|
||||
bootstrapRealmFirstsReseed()
|
||||
// Phase R1 orphan-archive used to run here on every Init, but it
|
||||
// over-archived: it treats any active dnd_zone_run row not linked to
|
||||
// an active expedition as a legacy `!adventure dungeon` orphan, which
|
||||
@@ -292,6 +298,7 @@ func (p *AdventurePlugin) Init() error {
|
||||
go p.mischiefTicker()
|
||||
go p.peteMischiefTicker()
|
||||
go p.peteEquipTicker()
|
||||
go p.peteAdvOrderTicker()
|
||||
|
||||
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
|
||||
p.arenaCleanupStaleRuns()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
@@ -100,33 +101,91 @@ func (p *AdventurePlugin) handleBabysitCmd(ctx MessageContext, args string) erro
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
// Sentinels for the ways hiring a sitter can be refused, so the web action queue
|
||||
// (pete_orders.go) can pick a verdict without parsing prose. Each is returned
|
||||
// inside an advRefusal carrying the finished sentence, so `!adventure babysit`
|
||||
// keeps the copy it always sent.
|
||||
var (
|
||||
errBabysitNoCharacter = errors.New("babysit: no adventurer")
|
||||
errBabysitActive = errors.New("babysit: a sitter is already engaged")
|
||||
errBabysitDead = errors.New("babysit: adventurer is dead")
|
||||
errBabysitBroke = errors.New("babysit: cannot cover the fee")
|
||||
errBabysitFailed = errors.New("babysit: could not engage a sitter")
|
||||
)
|
||||
|
||||
// babysitOutcome is what hiring did, for a caller describing it somewhere other
|
||||
// than a DM.
|
||||
type babysitOutcome struct {
|
||||
Days int
|
||||
Cost int
|
||||
PetName string
|
||||
PetLine string
|
||||
Confirm string
|
||||
}
|
||||
|
||||
// performBabysitPurchase is `!adventure babysit week|month` minus the command
|
||||
// framing. Shared with the web action queue so hiring a sitter from a phone
|
||||
// engages the same one, on the same clock, with the same log reset.
|
||||
//
|
||||
// idemKey, when set, is the web order's guid and moves the fee onto DebitIdem so
|
||||
// a re-offered order cannot charge twice.
|
||||
func (p *AdventurePlugin) performBabysitPurchase(uid id.UserID, days int, idemKey string) (babysitOutcome, error) {
|
||||
userMu := p.advUserLock(uid)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
char, err := loadAdvCharacter(ctx.Sender)
|
||||
char, err := loadAdvCharacter(uid)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "No adventurer found. Type `!adventure` to create one.")
|
||||
return babysitOutcome{}, refuseAdv(errBabysitNoCharacter, "No adventurer found. Type `!adventure` to create one.")
|
||||
}
|
||||
|
||||
if char.BabysitActive {
|
||||
return p.SendDM(ctx.Sender, "🍼 The babysitter is already here. They're not leaving until the job is done.")
|
||||
// A web order that already paid and already engaged the sitter lands here
|
||||
// on the re-offer. The settled fee is what tells that apart from somebody
|
||||
// who really does already have one.
|
||||
if idemKey != "" && p.euro != nil && p.euro.HasExternalTx(idemKey) {
|
||||
// Re-quote the fee rather than leaving it zero: the verdict this
|
||||
// feeds prints the coin figure, and "0 coins" would be a false
|
||||
// receipt for a hire the player did pay for.
|
||||
return babysitOutcome{
|
||||
Days: days,
|
||||
Cost: babysitDailyCost(dndLevelForUser(char.UserID)) * days,
|
||||
PetName: char.PetName,
|
||||
}, nil
|
||||
}
|
||||
return babysitOutcome{}, refuseAdv(errBabysitActive, "🍼 The babysitter is already here. They're not leaving until the job is done.")
|
||||
}
|
||||
|
||||
if !char.Alive {
|
||||
return p.SendDM(ctx.Sender, "Your adventurer is dead. The babysitter does not work with corpses.")
|
||||
return babysitOutcome{}, refuseAdv(errBabysitDead, "Your adventurer is dead. The babysitter does not work with corpses.")
|
||||
}
|
||||
|
||||
daily := babysitDailyCost(dndLevelForUser(char.UserID))
|
||||
totalCost := daily * days
|
||||
balance := p.euro.GetBalance(char.UserID)
|
||||
if balance < float64(totalCost) {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("🍼 The babysitting service costs %s for %d days. You have %s. The service has standards. Not many, but some.", fmtEuro(totalCost), days, fmtEuro(balance)))
|
||||
if p.euro == nil {
|
||||
return babysitOutcome{}, refuseAdv(errBabysitFailed, "Coin system unavailable — try again later.")
|
||||
}
|
||||
// Skip the affordability gate on a re-offer that already paid: the fee is a
|
||||
// settled fact, and re-reading the now-lower balance would bounce a sitter the
|
||||
// player has bought.
|
||||
if !(idemKey != "" && p.euro.HasExternalTx(idemKey)) {
|
||||
balance := p.euro.GetBalance(char.UserID)
|
||||
if balance < float64(totalCost) {
|
||||
return babysitOutcome{}, refuseAdv(errBabysitBroke,
|
||||
"🍼 The babysitting service costs %s for %d days. You have %s. The service has standards. Not many, but some.",
|
||||
fmtEuro(totalCost), days, fmtEuro(balance))
|
||||
}
|
||||
}
|
||||
|
||||
if !p.euro.Debit(char.UserID, float64(totalCost), "babysit_purchase") {
|
||||
return p.SendDM(ctx.Sender, "Payment failed. The babysitter looked at your wallet and walked away.")
|
||||
debited := false
|
||||
if idemKey != "" {
|
||||
ok, _, err := p.euro.DebitIdem(char.UserID, float64(totalCost), "babysit_purchase", idemKey)
|
||||
debited = err == nil && ok
|
||||
} else {
|
||||
debited = p.euro.Debit(char.UserID, float64(totalCost), "babysit_purchase")
|
||||
}
|
||||
if !debited {
|
||||
return babysitOutcome{}, refuseAdv(errBabysitFailed, "Payment failed. The babysitter looked at your wallet and walked away.")
|
||||
}
|
||||
|
||||
clearBabysitLogs(char.UserID)
|
||||
@@ -138,23 +197,40 @@ func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) er
|
||||
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("babysit: failed to save character", "user", char.UserID, "err", err)
|
||||
p.euro.Credit(char.UserID, float64(totalCost), "babysit_refund")
|
||||
return p.SendDM(ctx.Sender, "Something went wrong activating the service. Your gold has been refunded.")
|
||||
if idemKey != "" {
|
||||
if _, _, err := p.euro.CreditIdem(char.UserID, float64(totalCost), "babysit_refund", idemKey+":refund"); err != nil {
|
||||
slog.Error("babysit: refund failed", "user", char.UserID, "order", idemKey, "err", err)
|
||||
}
|
||||
} else {
|
||||
p.euro.Credit(char.UserID, float64(totalCost), "babysit_refund")
|
||||
}
|
||||
return babysitOutcome{}, refuseAdv(errBabysitFailed, "Something went wrong activating the service. Your gold has been refunded.")
|
||||
}
|
||||
if err := upsertPlayerMetaBabysitState(char.UserID, babysitStateFromAdvChar(char)); err != nil {
|
||||
slog.Error("player_meta: babysit start dual-write failed", "user", char.UserID, "err", err)
|
||||
}
|
||||
|
||||
confirm := pickBabysitFlavor(babysitConfirmLines)
|
||||
durLabel := "1 week"
|
||||
if days == 30 {
|
||||
durLabel = "1 month"
|
||||
}
|
||||
|
||||
petLine := "No pet to tend yet — the babysitter will keep that in mind."
|
||||
if char.HasPet() {
|
||||
petLine = fmt.Sprintf("Pet: %s (L%d) — daily care included", char.PetName, char.PetLevel)
|
||||
}
|
||||
return babysitOutcome{
|
||||
Days: days, Cost: totalCost, PetName: char.PetName, PetLine: petLine,
|
||||
Confirm: pickBabysitFlavor(babysitConfirmLines),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// handleBabysitPurchase is the command framing around performBabysitPurchase.
|
||||
func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) error {
|
||||
out, err := p.performBabysitPurchase(ctx.Sender, days, "")
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, err.Error())
|
||||
}
|
||||
|
||||
durLabel := "1 week"
|
||||
if days == 30 {
|
||||
durLabel = "1 month"
|
||||
}
|
||||
|
||||
text := fmt.Sprintf("🍼 **Adventurer Babysitting Service — Activated**\n\n"+
|
||||
"Duration: %s (%d days)\n"+
|
||||
@@ -162,7 +238,7 @@ func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) er
|
||||
"%s\n"+
|
||||
"Camp safety: standard camps now rest like fortified ones\n"+
|
||||
"Rival duels: declined on your behalf\n\n"+
|
||||
"_%s_", durLabel, days, totalCost, petLine, confirm)
|
||||
"_%s_", durLabel, days, out.Cost, out.PetLine, out.Confirm)
|
||||
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
}
|
||||
@@ -209,25 +285,49 @@ func (p *AdventurePlugin) handleBabysitStatus(ctx MessageContext) error {
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleBabysitCancel(ctx MessageContext) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
// babysitCancelOutcome is what dismissing the sitter did. Summary is the Matrix
|
||||
// block of what they got through while they were here — a paragraph of counts,
|
||||
// which is right under a DM and too much for a one-line web verdict, so the
|
||||
// caller decides whether to print it.
|
||||
type babysitCancelOutcome struct {
|
||||
Summary string
|
||||
PetName string
|
||||
}
|
||||
|
||||
// errBabysitNoSitter is the one way cancelling can be refused. There is no
|
||||
// "already cancelled" race to worry about: the check and the write are both under
|
||||
// the per-user lock this takes.
|
||||
var errBabysitNoSitter = errors.New("babysit: no sitter to dismiss")
|
||||
|
||||
// performBabysitCancel is `!adventure babysit cancel` minus the command framing.
|
||||
// Shared with the web action queue.
|
||||
//
|
||||
// This one TAKES the per-user lock, unlike the abandon/leave twins beside it in
|
||||
// the order path — because its Matrix caller does not hold it (handleBabysitCmd
|
||||
// dispatches straight here, where `!expedition` holds the lock across its whole
|
||||
// switch). Its web wrapper must therefore NOT take it. The asymmetry is per verb
|
||||
// and is worth checking against the Matrix caller every time one is added.
|
||||
//
|
||||
// No refund, by design: the sitter was already here.
|
||||
func (p *AdventurePlugin) performBabysitCancel(uid id.UserID) (babysitCancelOutcome, error) {
|
||||
userMu := p.advUserLock(uid)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
char, err := loadAdvCharacter(ctx.Sender)
|
||||
char, err := loadAdvCharacter(uid)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "No adventurer found.")
|
||||
return babysitCancelOutcome{}, refuseAdv(errBabysitNoCharacter, "No adventurer found.")
|
||||
}
|
||||
|
||||
if !char.BabysitActive {
|
||||
return p.SendDM(ctx.Sender, "🍼 There's nothing to cancel. The babysitter isn't here.")
|
||||
return babysitCancelOutcome{}, refuseAdv(errBabysitNoSitter, "🍼 There's nothing to cancel. The babysitter isn't here.")
|
||||
}
|
||||
|
||||
logs, err := loadBabysitLogs(char.UserID)
|
||||
if err != nil {
|
||||
slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err)
|
||||
}
|
||||
summary := renderBabysitSummary(char, logs)
|
||||
out := babysitCancelOutcome{Summary: renderBabysitSummary(char, logs), PetName: char.PetName}
|
||||
|
||||
char.BabysitActive = false
|
||||
char.BabysitExpiresAt = nil
|
||||
@@ -239,7 +339,15 @@ func (p *AdventurePlugin) handleBabysitCancel(ctx MessageContext) error {
|
||||
slog.Error("player_meta: babysit cancel dual-write failed", "user", char.UserID, "err", err)
|
||||
}
|
||||
|
||||
return p.SendDM(ctx.Sender, "🍼 Service cancelled. No refund. The babysitter was already there.\n\n"+summary)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleBabysitCancel(ctx MessageContext) error {
|
||||
out, err := p.performBabysitCancel(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, err.Error())
|
||||
}
|
||||
return p.SendDM(ctx.Sender, "🍼 Service cancelled. No refund. The babysitter was already there.\n\n"+out.Summary)
|
||||
}
|
||||
|
||||
// ── Expiry Check ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -15,6 +15,11 @@ import (
|
||||
|
||||
const petXPPerAction = 1.5
|
||||
|
||||
// petMaxLevel is where the curve stops. Named because two things read it for
|
||||
// different reasons: the level-up loop stops here, and the web push reports "no
|
||||
// more to earn" from it.
|
||||
const petMaxLevel = 10
|
||||
|
||||
var petNameValid = regexp.MustCompile(`^[a-zA-Z0-9 '\-]+$`)
|
||||
|
||||
// petXPToNextLevel returns XP needed for a given pet level.
|
||||
@@ -31,6 +36,19 @@ func petXPToNextLevel(level int) int {
|
||||
}
|
||||
}
|
||||
|
||||
// petXPNeededCenti is petXPToNextLevel in the unit the stored ledger actually
|
||||
// uses — centi-XP — and 0 once the pet is capped, so a caller can tell "nothing
|
||||
// left to earn" from "needs another 10 points". Every comparison against a
|
||||
// stored PetXP multiplies by 100 (see advancePetLevelsFromXP); anything reading
|
||||
// the curve for display has to do the same, and doing it in one place is how the
|
||||
// web push avoids getting it wrong.
|
||||
func petXPNeededCenti(level int) int {
|
||||
if level >= petMaxLevel {
|
||||
return 0
|
||||
}
|
||||
return petXPToNextLevel(level) * 100
|
||||
}
|
||||
|
||||
// petGrantXP adds a per-action XP grant to the pet and handles level-ups.
|
||||
// Returns true if leveled up. Shares the level-up loop with the babysit trickle
|
||||
// via advancePetLevelsFromXP.
|
||||
@@ -90,13 +108,13 @@ func grantPetCombatXP(userID id.UserID) []string {
|
||||
// to the level-10 cap, stamping the level-10 date on first reaching it. Shared
|
||||
// by both pet slots (the babysit trickle). Returns true if the pet leveled.
|
||||
func advancePetLevelsFromXP(xp, level *int, level10Date *string, addCentiXP int) bool {
|
||||
if *level >= 10 {
|
||||
if *level >= petMaxLevel {
|
||||
return false
|
||||
}
|
||||
*xp += addCentiXP
|
||||
leveled := false
|
||||
for *level < 10 {
|
||||
needed := petXPToNextLevel(*level) * 100
|
||||
for *level < petMaxLevel {
|
||||
needed := petXPNeededCenti(*level)
|
||||
if *xp < needed {
|
||||
break
|
||||
}
|
||||
@@ -104,7 +122,7 @@ func advancePetLevelsFromXP(xp, level *int, level10Date *string, addCentiXP int)
|
||||
*level++
|
||||
leveled = true
|
||||
}
|
||||
if *level >= 10 && *level10Date == "" {
|
||||
if *level >= petMaxLevel && *level10Date == "" {
|
||||
*level10Date = time.Now().UTC().Format("2006-01-02")
|
||||
}
|
||||
return leveled
|
||||
|
||||
@@ -2,6 +2,7 @@ package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"log/slog"
|
||||
@@ -394,6 +395,7 @@ func (p *AdventurePlugin) spawnWorldBoss(eventKey string) (*worldBossState, erro
|
||||
return nil, err
|
||||
}
|
||||
p.announceWorldBossSpawn(boss, activeN)
|
||||
emitSiegeStart(boss)
|
||||
slog.Info("worldboss: spawned", "id", bossID, "name", name, "tier", tier, "hp", hpMax, "activeN", activeN)
|
||||
return boss, nil
|
||||
}
|
||||
@@ -496,6 +498,7 @@ func (p *AdventurePlugin) resolveWorldBossDefeated(boss *worldBossState) {
|
||||
}
|
||||
}
|
||||
p.announceWorldBossDefeated(boss, payouts)
|
||||
emitSiegeWin(boss, len(payouts))
|
||||
slog.Info("worldboss: defeated", "id", boss.ID, "contributors", len(payouts))
|
||||
}
|
||||
|
||||
@@ -516,6 +519,7 @@ func (p *AdventurePlugin) resolveWorldBossSurvived(boss *worldBossState) {
|
||||
paid = tribute
|
||||
}
|
||||
p.announceWorldBossSurvived(boss, paid)
|
||||
emitSiegeLoss(boss)
|
||||
slog.Info("worldboss: survived", "id", boss.ID, "tribute", paid)
|
||||
}
|
||||
|
||||
@@ -604,42 +608,58 @@ func (p *AdventurePlugin) worldBossOperatorSpawn(ctx MessageContext) error {
|
||||
boss.Name, boss.Tier, groupInt(boss.HPMax)))
|
||||
}
|
||||
|
||||
// fightWorldBoss runs one player's daily bout against the Siege: an arena-style
|
||||
// Sentinels for the four ways a bout can be refused, so a headless caller (the
|
||||
// web action queue, pete_orders.go) can turn each into its own verdict instead of
|
||||
// parsing a DM. `!adventure worldboss fight` maps them back to the prose it
|
||||
// always sent.
|
||||
var (
|
||||
errSiegeNoBoss = errors.New("siege: nothing camped outside town")
|
||||
errSiegeNoCharacter = errors.New("siege: no adventurer")
|
||||
errSiegeDead = errors.New("siege: adventurer is dead")
|
||||
errSiegeAlreadyFought = errors.New("siege: today's bout already spent")
|
||||
)
|
||||
|
||||
// takeSiegeBout runs one player's daily bout against the Siege: an arena-style
|
||||
// solo fight whose damage is subtracted from the shared pool win or lose. Real
|
||||
// HP cost, no death — a loss leaves the fighter battered (floored at 1 HP) but
|
||||
// standing. The per-user lock serialises a player's own repeat submits, so the
|
||||
// once-per-day gate can't be raced by a double-tap.
|
||||
func (p *AdventurePlugin) fightWorldBoss(ctx MessageContext) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
// once-per-day gate can't be raced by a double-tap — and that same lock is what
|
||||
// makes it safe for the web queue and a Matrix command to reach for the bout at
|
||||
// the same moment.
|
||||
//
|
||||
// The combat narration is DM'd from here whichever door the bout came through: a
|
||||
// fight is thirty lines of blow-by-blow and belongs in Matrix, not in a one-line
|
||||
// verdict on a web page. The caller gets the boss and the result to describe.
|
||||
func (p *AdventurePlugin) takeSiegeBout(uid id.UserID) (worldBossBoutResult, *worldBossState, error) {
|
||||
userMu := p.advUserLock(uid)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
boss, err := loadActiveWorldBoss()
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Something went wrong reaching the Siege. Try again in a moment.")
|
||||
return worldBossBoutResult{}, nil, fmt.Errorf("reaching the Siege: %w", err)
|
||||
}
|
||||
if boss == nil {
|
||||
return p.SendDM(ctx.Sender, "No Siege is camped outside town right now.")
|
||||
return worldBossBoutResult{}, nil, errSiegeNoBoss
|
||||
}
|
||||
|
||||
char, err := loadAdvCharacter(ctx.Sender)
|
||||
char, err := loadAdvCharacter(uid)
|
||||
if err != nil || char == nil {
|
||||
return p.SendDM(ctx.Sender, "You need an adventurer first — type `!adventure` to begin.")
|
||||
return worldBossBoutResult{}, boss, errSiegeNoCharacter
|
||||
}
|
||||
if !char.Alive {
|
||||
return p.SendDM(ctx.Sender, "You're dead. The Siege will have to wait until you're back on your feet.")
|
||||
return worldBossBoutResult{}, boss, errSiegeDead
|
||||
}
|
||||
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
if worldBossBoutUsedToday(boss.ID, ctx.Sender, today) {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"You've already taken your bout against **%s** today. Come back tomorrow — one fight per day.", boss.Name))
|
||||
if worldBossBoutUsedToday(boss.ID, uid, today) {
|
||||
return worldBossBoutResult{}, boss, errSiegeAlreadyFought
|
||||
}
|
||||
|
||||
bout, err := p.resolveWorldBossBout(ctx.Sender, boss, today)
|
||||
bout, err := p.resolveWorldBossBout(uid, boss, today)
|
||||
if err != nil {
|
||||
slog.Error("worldboss: bout failed", "user", ctx.Sender, "err", err)
|
||||
return p.SendDM(ctx.Sender, "The Siege combat hit an error. Try again in a moment.")
|
||||
slog.Error("worldboss: bout failed", "user", uid, "err", err)
|
||||
return worldBossBoutResult{}, boss, fmt.Errorf("running the bout: %w", err)
|
||||
}
|
||||
|
||||
// Resolve a defeat BEFORE streaming the (multi-second) narration. The pool is
|
||||
@@ -650,7 +670,7 @@ func (p *AdventurePlugin) fightWorldBoss(ctx MessageContext) error {
|
||||
p.resolveWorldBossDefeated(boss)
|
||||
}
|
||||
|
||||
playerName, _ := loadDisplayName(ctx.Sender)
|
||||
playerName, _ := loadDisplayName(uid)
|
||||
if playerName == "" {
|
||||
playerName = "You"
|
||||
}
|
||||
@@ -658,18 +678,45 @@ func (p *AdventurePlugin) fightWorldBoss(ctx MessageContext) error {
|
||||
fmt.Sprintf("⚔️ **The Siege — %s** (Tier %d)", boss.Name, boss.Tier),
|
||||
}, RenderCombatLog(bout.Combat, playerName, boss.Name)...)
|
||||
|
||||
<-p.sendZoneCombatMessages(uid, phaseMessages, siegeBoutFooter(bout, boss))
|
||||
return bout, boss, nil
|
||||
}
|
||||
|
||||
// siegeBoutFooter is the one-line result of a bout — the damage dealt and what
|
||||
// the pool looks like now. It closes the Matrix narration and doubles as the web
|
||||
// verdict, so the two doors can't drift into describing the same fight
|
||||
// differently.
|
||||
func siegeBoutFooter(bout worldBossBoutResult, boss *worldBossState) string {
|
||||
var footer string
|
||||
if bout.Killed {
|
||||
footer = fmt.Sprintf("💥 You deal **%d** damage — the killing blow! **%s** falls!", bout.Damage, boss.Name)
|
||||
footer = fmt.Sprintf("💥 You deal **%d** damage: the killing blow! **%s** falls!", bout.Damage, boss.Name)
|
||||
} else {
|
||||
footer = fmt.Sprintf("💥 You deal **%d** damage. **%s** has **%s / %s HP** left.",
|
||||
bout.Damage, boss.Name, groupInt(bout.Remaining), groupInt(boss.HPMax))
|
||||
}
|
||||
if bout.Battered {
|
||||
footer += "\nYou stagger out of the fight at 1 HP — rest up before your next outing."
|
||||
footer += "\nYou stagger out of the fight at 1 HP. Rest up before your next outing."
|
||||
}
|
||||
return footer
|
||||
}
|
||||
|
||||
<-p.sendZoneCombatMessages(ctx.Sender, phaseMessages, footer)
|
||||
// fightWorldBoss is `!adventure worldboss fight`: the command framing around
|
||||
// takeSiegeBout, which does the fight and the narration.
|
||||
func (p *AdventurePlugin) fightWorldBoss(ctx MessageContext) error {
|
||||
_, boss, err := p.takeSiegeBout(ctx.Sender)
|
||||
switch {
|
||||
case errors.Is(err, errSiegeNoBoss):
|
||||
return p.SendDM(ctx.Sender, "No Siege is camped outside town right now.")
|
||||
case errors.Is(err, errSiegeNoCharacter):
|
||||
return p.SendDM(ctx.Sender, "You need an adventurer first — type `!adventure` to begin.")
|
||||
case errors.Is(err, errSiegeDead):
|
||||
return p.SendDM(ctx.Sender, "You're dead. The Siege will have to wait until you're back on your feet.")
|
||||
case errors.Is(err, errSiegeAlreadyFought):
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"You've already taken your bout against **%s** today. Come back tomorrow — one fight per day.", boss.Name))
|
||||
case err != nil:
|
||||
return p.SendDM(ctx.Sender, "Something went wrong reaching the Siege. Try again in a moment.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -58,34 +58,119 @@ func (p *AdventurePlugin) bootstrapPeteNewsBackfill() {
|
||||
"zone_firsts", firsts, "deaths", deaths, "achievements", achv)
|
||||
}
|
||||
|
||||
// backfillZoneFirsts seeds news_realm_firsts from history and emits one PRIORITY
|
||||
// realm-first dispatch per zone, attributed to its earliest boss-defeating
|
||||
// clearer. SQLite returns the user_id from the same row as MIN(completed_at)
|
||||
// (bare-column min/max rule), so the (zone, first clearer, time) triple is
|
||||
// consistent. Returns the count emitted.
|
||||
func (p *AdventurePlugin) backfillZoneFirsts() int {
|
||||
// zoneFirstClear is one zone's earliest boss kill: who did it and when.
|
||||
type zoneFirstClear struct {
|
||||
zoneID, userID, completedAt string
|
||||
}
|
||||
|
||||
// zoneFirstClears reads the earliest boss-defeating run of every zone.
|
||||
//
|
||||
// The filter is `boss_defeated = 1` and NOTHING else, and that is the whole
|
||||
// point. `abandoned` does not mean anybody gave up — abandonZoneRunByID exists
|
||||
// to retire a run whose boss is ALREADY DEAD when the expedition travels onward
|
||||
// (dnd_zone_run.go), so in prod 30 of 32 boss kills carry abandoned = 1. An
|
||||
// `AND abandoned = 0` here drew a realm where 2 zones had ever been beaten
|
||||
// instead of 9. It is the same filter loadRealmClearStats documents; do not
|
||||
// reintroduce it.
|
||||
//
|
||||
// SQLite returns the user_id from the same row as MIN(completed_at) (bare-column
|
||||
// min/max rule), so the (zone, first clearer, time) triple is internally
|
||||
// consistent.
|
||||
// ok is false only when the read itself failed. A caller that is about to mark
|
||||
// a one-shot job complete has to be able to tell "no zone has ever been cleared"
|
||||
// from "the query fell over", or a transient DB fault at boot retires the repair
|
||||
// permanently.
|
||||
func zoneFirstClears() (firsts []zoneFirstClear, ok bool) {
|
||||
rows, err := db.Get().Query(
|
||||
`SELECT zone_id, user_id, MIN(completed_at)
|
||||
FROM dnd_zone_run
|
||||
WHERE boss_defeated = 1 AND completed_at IS NOT NULL AND abandoned = 0
|
||||
WHERE boss_defeated = 1 AND completed_at IS NOT NULL
|
||||
GROUP BY zone_id`)
|
||||
if err != nil {
|
||||
slog.Error("backfill: zone-firsts query", "err", err)
|
||||
return 0
|
||||
return nil, false
|
||||
}
|
||||
type first struct {
|
||||
zoneID, userID, completedAt string
|
||||
}
|
||||
var firsts []first
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var f first
|
||||
var f zoneFirstClear
|
||||
if err := rows.Scan(&f.zoneID, &f.userID, &f.completedAt); err != nil {
|
||||
slog.Error("backfill: zone-firsts scan", "err", err)
|
||||
continue
|
||||
}
|
||||
firsts = append(firsts, f)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
slog.Error("backfill: zone-firsts rows", "err", err)
|
||||
return nil, false
|
||||
}
|
||||
return firsts, true
|
||||
}
|
||||
|
||||
// bootstrapRealmFirstsReseed repairs the zone half of news_realm_firsts.
|
||||
//
|
||||
// The ledger is what claimRealmFirst tiers live dispatches against, so a zone
|
||||
// whose first clear the original one-shot missed is a spurious PRIORITY "realm
|
||||
// first" waiting to fire the next time somebody clears it, months after the
|
||||
// fact. Two things were wrong with what got seeded:
|
||||
//
|
||||
// 1. The `abandoned = 0` filter above, which is why prod holds 6 zones where
|
||||
// the run history knows 9.
|
||||
// 2. first_at is claimRealmFirst's unixepoch() — when the claim was RECORDED,
|
||||
// not when the clear happened. Every backfilled prod row carries the one
|
||||
// minute the job ran.
|
||||
//
|
||||
// This is a re-SEED, not a re-run: it writes the ledger and emits nothing at
|
||||
// all, so no historical realm-first dispatch reaches the room. It has its own
|
||||
// job name because the original one-shot's gate is already marked, and per
|
||||
// feedback_loader_rewire_needs_bootstrap it stays in place afterwards — a fresh
|
||||
// deploy runs it as an ordinary bootstrap.
|
||||
//
|
||||
// It runs unconditionally on the news seam's switches, unlike the backfill: a
|
||||
// ledger that is correct only when emission happens to be on is a ledger that
|
||||
// mis-tiers the first dispatch after somebody flips it.
|
||||
//
|
||||
// A zone claim with no surviving run behind it is left exactly as it is. The run
|
||||
// history is the better record of both who and when, but only where it has one.
|
||||
func bootstrapRealmFirstsReseed() {
|
||||
const jobName = "pete_realm_firsts_reseed_v1"
|
||||
if db.JobCompleted(jobName, "once") {
|
||||
return
|
||||
}
|
||||
|
||||
clears, ok := zoneFirstClears()
|
||||
if !ok {
|
||||
// The read failed. Leave the job unmarked so the next boot tries again —
|
||||
// marking it here would retire the repair on the strength of a transient
|
||||
// DB fault and leave the ledger wrong forever.
|
||||
return
|
||||
}
|
||||
|
||||
seeded := 0
|
||||
for _, f := range clears {
|
||||
ts, ok := parseSQLiteTime(f.completedAt)
|
||||
if !ok {
|
||||
slog.Warn("reseed: unparseable clear time", "zone", f.zoneID, "at", f.completedAt)
|
||||
continue
|
||||
}
|
||||
// Upsert, not INSERT OR IGNORE: the six rows that already exist carry the
|
||||
// wrong date and correcting them is half of what this job is for.
|
||||
db.Exec("realm-firsts reseed",
|
||||
`INSERT INTO news_realm_firsts (kind, target, first_at) VALUES ('zone', ?, ?)
|
||||
ON CONFLICT(kind, target) DO UPDATE SET first_at = excluded.first_at`,
|
||||
f.zoneID, ts.Unix())
|
||||
seeded++
|
||||
}
|
||||
|
||||
db.MarkJobCompleted(jobName, "once")
|
||||
slog.Warn("bootstrap: realm-firsts ledger reseeded", "zones", seeded)
|
||||
}
|
||||
|
||||
// backfillZoneFirsts seeds news_realm_firsts from history and emits one
|
||||
// dispatch per zone, attributed to its earliest boss-defeating clearer.
|
||||
// Returns the count emitted.
|
||||
func (p *AdventurePlugin) backfillZoneFirsts() int {
|
||||
firsts, _ := zoneFirstClears()
|
||||
|
||||
n := 0
|
||||
for _, f := range firsts {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -607,6 +607,7 @@ func emitDeathNews(userID id.UserID, location string) {
|
||||
Zone: location,
|
||||
Level: lvl,
|
||||
Outcome: "lost",
|
||||
RunID: latestRunIDForNews(userID),
|
||||
OccurredAt: ts,
|
||||
}, userID, "")
|
||||
}
|
||||
@@ -655,6 +656,7 @@ func emitRetreatNews(userID id.UserID, reason string, zoneID ZoneID, day int) {
|
||||
Level: charLevel(userID),
|
||||
Count: day, // the day they got to before it fell apart
|
||||
Outcome: "retreated",
|
||||
RunID: latestRunIDForNews(userID),
|
||||
OccurredAt: ts,
|
||||
}, userID, "")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
@@ -32,6 +33,23 @@ import (
|
||||
// in E1e. !advance / !search / !rest / !extract are out-of-scope for E1.
|
||||
|
||||
func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string) error {
|
||||
args = strings.TrimSpace(args)
|
||||
sub, rest := splitFirstWord(args)
|
||||
|
||||
// Two subcommands are aliases for top-level commands that take the per-user
|
||||
// lock themselves. Dispatch them BEFORE we take it: advUserLock is a plain
|
||||
// sync.Mutex, so grabbing it here and again in there does not merely block —
|
||||
// it wedges the lock forever, because the deferred Unlock below never runs.
|
||||
// Every later `!adventure` / `!expedition` / `!zone` command from that player
|
||||
// then hangs too. Both aliases load their own character, so nothing below is
|
||||
// being skipped.
|
||||
switch strings.ToLower(sub) {
|
||||
case "extract":
|
||||
return p.handleExtractCmd(ctx, "")
|
||||
case "resume":
|
||||
return p.handleResumeCmd(ctx, rest)
|
||||
}
|
||||
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
@@ -45,8 +63,6 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string
|
||||
"No Adv 2.0 character yet — run `!setup` (or just enter combat and we'll auto-build one).")
|
||||
}
|
||||
|
||||
args = strings.TrimSpace(args)
|
||||
sub, rest := splitFirstWord(args)
|
||||
switch strings.ToLower(sub) {
|
||||
case "":
|
||||
// If active, show status; otherwise help. A party member is on an
|
||||
@@ -100,10 +116,6 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string
|
||||
return p.expeditionCmdHire(ctx, rest)
|
||||
case "dismiss":
|
||||
return p.expeditionCmdDismiss(ctx)
|
||||
case "extract":
|
||||
return p.handleExtractCmd(ctx, "")
|
||||
case "resume":
|
||||
return p.handleResumeCmd(ctx, rest)
|
||||
case "map", "m":
|
||||
return p.handleExpeditionMapCmd(ctx, "")
|
||||
case "run", "explore", "advance":
|
||||
@@ -332,83 +344,14 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't parse supply packs: "+err.Error())
|
||||
}
|
||||
if err := purchase.Validate(zoneForCaps.Tier); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Invalid pack selection: "+err.Error())
|
||||
}
|
||||
// Reject if any expedition or zone run already active. This runs before the
|
||||
// price quote: a player who cannot leave doesn't need to hear what leaving
|
||||
// would have cost.
|
||||
//
|
||||
// The seat check spans `extracting` as well as `active` — a member of an
|
||||
// extracting party is still seated for the seven-day resume window, and
|
||||
// letting them outfit a rival expedition double-books them the moment their
|
||||
// leader types `!resume`.
|
||||
if seated, _ := seatedExpeditionFor(ctx.Sender); seated != nil {
|
||||
zone, _ := getZone(seated.ZoneID)
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"You're riding a party expedition in **%s** (Day %d). `!expedition leave` before starting your own.",
|
||||
zone.Display, seated.CurrentDay))
|
||||
}
|
||||
if existing, _ := getActiveExpedition(ctx.Sender); existing != nil {
|
||||
zone, _ := getZone(existing.ZoneID)
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"You're already on expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.",
|
||||
zone.Display, existing.CurrentDay))
|
||||
}
|
||||
// A leader who extracted still holds their roster for the resume window, and
|
||||
// `!resume` only ever reaches the *newest* extracted row. Starting fresh on
|
||||
// top of one would orphan it: unreachable, un-reapable until the sweeper
|
||||
// catches it, with every member still seated and refused a run of their own.
|
||||
//
|
||||
// Only a row with a roster blocks. A solo extraction strands nobody, so
|
||||
// walking away from it stays a normal thing to do.
|
||||
if pending, _ := getResumableExpedition(ctx.Sender); pending != nil {
|
||||
switch {
|
||||
case extractionLapsed(pending, time.Now().UTC()):
|
||||
// Past the window — reap it here rather than make them wait an hour
|
||||
// for the sweeper, and let the new expedition proceed. Route through
|
||||
// the shared reap so the freed members hear about it, same as the
|
||||
// sweeper and `!expedition abandon` do.
|
||||
if err := p.reapLapsedExtraction(pending); err != nil {
|
||||
slog.Warn("expedition: reap lapsed on start", "expedition", pending.ID, "err", err)
|
||||
}
|
||||
default:
|
||||
// A roster still holds; block. On a roster-read error, assume it is
|
||||
// occupied and refuse — proceeding would orphan a party we could not
|
||||
// confirm was empty, the one outcome this guard exists to prevent. A
|
||||
// solo extraction (n == 1) strands nobody, so walking away is fine.
|
||||
n, err := partySize(pending.ID)
|
||||
if err != nil || n > 1 {
|
||||
zone, _ := getZone(pending.ZoneID)
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"You extracted from **%s** on Day %d and your party is still waiting on you. `!resume` to lead them back in, or `!expedition abandon` to let it go — until you do one or the other, none of them can start a run of their own.",
|
||||
zone.Display, pending.CurrentDay))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cost := float64(purchase.Cost())
|
||||
if p.euro == nil {
|
||||
return p.SendDM(ctx.Sender, "Coin system unavailable — try again later.")
|
||||
}
|
||||
if balance := p.euro.GetBalance(ctx.Sender); balance < cost {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"Not enough coins. Outfitting costs **%d** but you have **%.0f**.",
|
||||
int(cost), balance))
|
||||
}
|
||||
if existing, _ := getActiveZoneRun(ctx.Sender); existing != nil {
|
||||
zone, _ := getZone(existing.ZoneID)
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"You have an active single-session zone run in **%s**. Finish or `!zone abandon` before starting an expedition.",
|
||||
zone.Display))
|
||||
}
|
||||
|
||||
zone := zoneForCaps
|
||||
_, supplies, startLine, err := p.beginExpedition(ctx.Sender, c.Level, zone, purchase, "expedition outfitting")
|
||||
out, err := p.performExpeditionStart(ctx.Sender, c, zoneForCaps, purchase, "")
|
||||
if err != nil {
|
||||
// Every refusal below carries its own finished sentence — see the sentinel
|
||||
// block on performExpeditionStart — so the command only has to say it.
|
||||
return p.SendDM(ctx.Sender, err.Error())
|
||||
}
|
||||
markActedToday(ctx.Sender)
|
||||
zone, supplies, startLine := out.Zone, out.Supplies, out.StartLine
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("🗺 **Expedition begins — %s** _(T%d)_\n\n", zone.Display, int(zone.Tier)))
|
||||
@@ -429,6 +372,182 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
|
||||
return p.SendDM(ctx.Sender, b.String())
|
||||
}
|
||||
|
||||
// ── the headless twin of `!expedition start` ────────────────────────────────
|
||||
|
||||
// Sentinels for the ways outfitting can be refused, so the web action queue
|
||||
// (pete_orders.go) can pick a verdict without parsing prose. Every refusal is
|
||||
// returned as an advRefusal, which wraps one of these AND carries the
|
||||
// finished player-facing sentence — that is how `!expedition start` keeps the
|
||||
// exact copy it always sent while the web gets a machine-readable answer.
|
||||
var (
|
||||
errExpStartResting = errors.New("expedition start: still resting")
|
||||
errExpStartZoneLocked = errors.New("expedition start: zone not available at this level")
|
||||
errExpStartBadPacks = errors.New("expedition start: invalid pack selection")
|
||||
errExpStartBusy = errors.New("expedition start: already adventuring")
|
||||
errExpStartBroke = errors.New("expedition start: cannot cover outfitting")
|
||||
errExpStartFailed = errors.New("expedition start: could not outfit")
|
||||
)
|
||||
|
||||
// advRefusal is a refusal that is both classifiable and quotable: errors.Is
|
||||
// picks the verdict, Error() is the sentence the command has always sent. Shared
|
||||
// by every headless twin in the web action family (start, resume, babysit).
|
||||
type advRefusal struct {
|
||||
kind error
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e advRefusal) Error() string { return e.msg }
|
||||
func (e advRefusal) Unwrap() error { return e.kind }
|
||||
|
||||
func refuseAdv(kind error, format string, args ...any) error {
|
||||
return advRefusal{kind: kind, msg: fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
// expStartOutcome is what outfitting did, for a caller describing it somewhere
|
||||
// other than a DM.
|
||||
type expStartOutcome struct {
|
||||
Zone ZoneDefinition
|
||||
Supplies ExpeditionSupplies
|
||||
Cost int
|
||||
Days int
|
||||
StartLine string
|
||||
}
|
||||
|
||||
// performExpeditionStart is `!expedition start` minus the command framing: the
|
||||
// eligibility guards, the price gate, the debit, and the expedition row. It is
|
||||
// shared with the web action queue so that leaving town from a phone is the
|
||||
// *same* departure — same guards, same supplies, same opening log line.
|
||||
//
|
||||
// idemKey, when set, is the web order's guid: the debit then goes through
|
||||
// DebitIdem so a re-offered order that already paid cannot pay twice. The Matrix
|
||||
// command passes "" and keeps the plain debit, which is right — a Matrix message
|
||||
// arrives exactly once.
|
||||
//
|
||||
// LOCKING, and this is the one asymmetry in the headless-twin family: this
|
||||
// function does NOT take the per-user lock. performExtraction, takeSiegeBout and
|
||||
// performBabysitPurchase all take it themselves, because their command framings
|
||||
// do not hold it — but handleDnDExpeditionCmd holds it across its whole switch,
|
||||
// so taking it here would wedge advUserLock permanently (it is a plain
|
||||
// sync.Mutex, and the deferred unlock up there would never run). The web caller
|
||||
// takes it explicitly instead; see applyAdvOrder.
|
||||
func (p *AdventurePlugin) performExpeditionStart(uid id.UserID, c *DnDCharacter, zone ZoneDefinition, purchase SupplyPurchase, idemKey string) (expStartOutcome, error) {
|
||||
if remaining := restingLockoutRemaining(c); remaining > 0 {
|
||||
return expStartOutcome{}, refuseAdv(errExpStartResting,
|
||||
"🛌 You're still resting — %s remaining. Pack up after.",
|
||||
formatRespecDuration(remaining))
|
||||
}
|
||||
// Re-resolve availability against the game's own tables rather than trusting
|
||||
// the caller. The web resolves a zone from an offer list gogobee itself
|
||||
// pushed, but that snapshot can be minutes old and is not a permission.
|
||||
if _, ok := resolveZoneInput(string(zone.ID), availableZonesFor(uid, c.Level)); !ok {
|
||||
if reason := postgameLockReason(string(zone.ID), uid, c.Level); reason != "" {
|
||||
return expStartOutcome{}, refuseAdv(errExpStartZoneLocked, "%s", reason)
|
||||
}
|
||||
return expStartOutcome{}, refuseAdv(errExpStartZoneLocked,
|
||||
"Unknown zone for your level. Try `!expedition list`.")
|
||||
}
|
||||
if err := purchase.Validate(zone.Tier); err != nil {
|
||||
return expStartOutcome{}, refuseAdv(errExpStartBadPacks,
|
||||
"Invalid pack selection: %s", err.Error())
|
||||
}
|
||||
// Reject if any expedition or zone run already active. This runs before the
|
||||
// price quote: a player who cannot leave doesn't need to hear what leaving
|
||||
// would have cost.
|
||||
//
|
||||
// The seat check spans `extracting` as well as `active` — a member of an
|
||||
// extracting party is still seated for the seven-day resume window, and
|
||||
// letting them outfit a rival expedition double-books them the moment their
|
||||
// leader types `!resume`.
|
||||
if seated, _ := seatedExpeditionFor(uid); seated != nil {
|
||||
z, _ := getZone(seated.ZoneID)
|
||||
return expStartOutcome{}, refuseAdv(errExpStartBusy,
|
||||
"You're riding a party expedition in **%s** (Day %d). `!expedition leave` before starting your own.",
|
||||
z.Display, seated.CurrentDay)
|
||||
}
|
||||
if existing, _ := getActiveExpedition(uid); existing != nil {
|
||||
// A web order that already paid and already started this expedition on an
|
||||
// earlier tick lands here on the re-offer. Saying "you're already on
|
||||
// expedition" would be a rejection for the thing the order in fact did, so
|
||||
// the settled debit is what tells the two apart.
|
||||
if idemKey != "" && p.euro != nil && p.euro.HasExternalTx(idemKey) && existing.ZoneID == zone.ID {
|
||||
z, _ := getZone(existing.ZoneID)
|
||||
return expStartOutcome{Zone: z, Supplies: existing.Supplies,
|
||||
Cost: purchase.Cost(),
|
||||
Days: estimateDays(existing.Supplies.Max, existing.Supplies.DailyBurn)}, nil
|
||||
}
|
||||
z, _ := getZone(existing.ZoneID)
|
||||
return expStartOutcome{}, refuseAdv(errExpStartBusy,
|
||||
"You're already on expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.",
|
||||
z.Display, existing.CurrentDay)
|
||||
}
|
||||
// A leader who extracted still holds their roster for the resume window, and
|
||||
// `!resume` only ever reaches the *newest* extracted row. Starting fresh on
|
||||
// top of one would orphan it: unreachable, un-reapable until the sweeper
|
||||
// catches it, with every member still seated and refused a run of their own.
|
||||
//
|
||||
// Only a row with a roster blocks. A solo extraction strands nobody, so
|
||||
// walking away from it stays a normal thing to do.
|
||||
if pending, _ := getResumableExpedition(uid); pending != nil {
|
||||
switch {
|
||||
case extractionLapsed(pending, time.Now().UTC()):
|
||||
// Past the window — reap it here rather than make them wait an hour
|
||||
// for the sweeper, and let the new expedition proceed. Route through
|
||||
// the shared reap so the freed members hear about it, same as the
|
||||
// sweeper and `!expedition abandon` do.
|
||||
if err := p.reapLapsedExtraction(pending); err != nil {
|
||||
slog.Warn("expedition: reap lapsed on start", "expedition", pending.ID, "err", err)
|
||||
}
|
||||
default:
|
||||
// A roster still holds; block. On a roster-read error, assume it is
|
||||
// occupied and refuse — proceeding would orphan a party we could not
|
||||
// confirm was empty, the one outcome this guard exists to prevent. A
|
||||
// solo extraction (n == 1) strands nobody, so walking away is fine.
|
||||
n, err := partySize(pending.ID)
|
||||
if err != nil || n > 1 {
|
||||
z, _ := getZone(pending.ZoneID)
|
||||
return expStartOutcome{}, refuseAdv(errExpStartBusy,
|
||||
"You extracted from **%s** on Day %d and your party is still waiting on you. `!resume` to lead them back in, or `!expedition abandon` to let it go — until you do one or the other, none of them can start a run of their own.",
|
||||
z.Display, pending.CurrentDay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cost := float64(purchase.Cost())
|
||||
if p.euro == nil {
|
||||
return expStartOutcome{}, refuseAdv(errExpStartFailed, "Coin system unavailable — try again later.")
|
||||
}
|
||||
// Skip the affordability gate on a re-offer that already paid: the debit is a
|
||||
// settled fact and re-reading the now-lower balance would bounce a departure
|
||||
// the player has bought. Same reasoning as purchaseEquipmentTier's.
|
||||
if !(idemKey != "" && p.euro.HasExternalTx(idemKey)) {
|
||||
if balance := p.euro.GetBalance(uid); balance < cost {
|
||||
return expStartOutcome{}, refuseAdv(errExpStartBroke,
|
||||
"Not enough coins. Outfitting costs **%d** but you have **%.0f**.",
|
||||
int(cost), balance)
|
||||
}
|
||||
}
|
||||
if existing, _ := getActiveZoneRun(uid); existing != nil {
|
||||
z, _ := getZone(existing.ZoneID)
|
||||
return expStartOutcome{}, refuseAdv(errExpStartBusy,
|
||||
"You have an active single-session zone run in **%s**. Finish or `!zone abandon` before starting an expedition.",
|
||||
z.Display)
|
||||
}
|
||||
|
||||
_, supplies, startLine, err := p.beginExpeditionIdem(uid, c.Level, zone, purchase, "expedition outfitting", idemKey)
|
||||
if err != nil {
|
||||
// beginExpedition refunds and tears down on every failure path, so the
|
||||
// player owes nothing and this is permanent rather than retryable — a retry
|
||||
// after a refund would find the guid-keyed debit already settled and hand
|
||||
// them the expedition for free.
|
||||
return expStartOutcome{}, refuseAdv(errExpStartFailed, "%s", err.Error())
|
||||
}
|
||||
markActedToday(uid)
|
||||
return expStartOutcome{
|
||||
Zone: zone, Supplies: supplies, Cost: purchase.Cost(),
|
||||
Days: estimateDays(supplies.Max, supplies.DailyBurn), StartLine: startLine,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// beginExpedition performs the non-interactive half of starting an expedition:
|
||||
// supply freebies, the coin debit, persistence, the starting region's run, and
|
||||
// the opening log entry. It refunds and tears down on every failure path, so a
|
||||
@@ -442,10 +561,38 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
|
||||
// It deliberately does NOT call markActedToday — an expedition the player did
|
||||
// not ask for must not spend their daily action or count as them showing up.
|
||||
func (p *AdventurePlugin) beginExpedition(uid id.UserID, charLevel int, zone ZoneDefinition, purchase SupplyPurchase, reason string) (*Expedition, ExpeditionSupplies, string, error) {
|
||||
return p.beginExpeditionIdem(uid, charLevel, zone, purchase, reason, "")
|
||||
}
|
||||
|
||||
// beginExpeditionIdem is beginExpedition with the money keyed to an idempotency
|
||||
// id. idemKey empty keeps the plain Debit/Credit pair, which is correct for the
|
||||
// two callers that arrive exactly once (a Matrix command, the boredom ticker).
|
||||
//
|
||||
// A non-empty key comes from the web action queue, whose wire retries: the debit
|
||||
// then lands at most once however many times the order is re-offered, and the
|
||||
// refunds are keyed too so a torn-down start cannot refund on every tick. Note
|
||||
// what this means for the caller — once a refund has happened, retrying is
|
||||
// *unsafe*, because the guid-keyed debit will not charge again and the player
|
||||
// would get the expedition for free. performExpeditionStart therefore treats
|
||||
// every error from here as permanent.
|
||||
func (p *AdventurePlugin) beginExpeditionIdem(uid id.UserID, charLevel int, zone ZoneDefinition, purchase SupplyPurchase, reason, idemKey string) (*Expedition, ExpeditionSupplies, string, error) {
|
||||
if p.euro == nil {
|
||||
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Coin system unavailable — try again later.")
|
||||
}
|
||||
cost := float64(purchase.Cost())
|
||||
debit := func(why string) bool { return p.euro.Debit(uid, cost, why) }
|
||||
refund := func(why, suffix string) { p.euro.Credit(uid, cost, why) }
|
||||
if idemKey != "" {
|
||||
debit = func(why string) bool {
|
||||
ok, _, err := p.euro.DebitIdem(uid, cost, why, idemKey)
|
||||
return err == nil && ok
|
||||
}
|
||||
refund = func(why, suffix string) {
|
||||
if _, _, err := p.euro.CreditIdem(uid, cost, why, idemKey+":refund"+suffix); err != nil {
|
||||
slog.Error("expedition: outfitting refund failed", "user", uid, "order", idemKey, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Holiday perk: a complimentary standard pack is added to the supplies
|
||||
// snapshot without inflating the coin cost. Bypasses the per-tier cap
|
||||
@@ -460,14 +607,14 @@ func (p *AdventurePlugin) beginExpedition(uid id.UserID, charLevel int, zone Zon
|
||||
supplies := makeSupplies(zone.Tier, suppliesPurchase)
|
||||
|
||||
// Debit coins; bail on debit failure (race / cap).
|
||||
if !p.euro.Debit(uid, cost, reason+": "+string(zone.ID)) {
|
||||
if !debit(reason + ": " + string(zone.ID)) {
|
||||
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't debit outfitting cost (try again).")
|
||||
}
|
||||
|
||||
exp, err := startExpedition(uid, zone.ID, "", supplies)
|
||||
if err != nil {
|
||||
// Refund on persistence failure.
|
||||
p.euro.Credit(uid, cost, "expedition outfitting refund")
|
||||
refund("expedition outfitting refund", "")
|
||||
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't start expedition: %s", err)
|
||||
}
|
||||
|
||||
@@ -478,7 +625,7 @@ func (p *AdventurePlugin) beginExpedition(uid id.UserID, charLevel int, zone Zon
|
||||
// Refund and tear the expedition row back down — without a
|
||||
// linked run, harvest and rooms can't function.
|
||||
_ = abandonExpedition(uid)
|
||||
p.euro.Credit(uid, cost, "expedition outfitting refund (run-spawn failed)")
|
||||
refund("expedition outfitting refund (run-spawn failed)", ":region")
|
||||
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't outfit the first region: %s", err)
|
||||
}
|
||||
|
||||
@@ -697,65 +844,102 @@ func formatLogTimestamp(t time.Time) string {
|
||||
|
||||
// ── abandon ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error {
|
||||
exp, isLeader, err := activeExpeditionFor(ctx.Sender)
|
||||
// Sentinels for the two ways abandoning can be refused, so the web action queue
|
||||
// can pick a verdict without reading prose. Same contract as the start/resume
|
||||
// family above: errors.Is classifies, Error() is the sentence Matrix has always
|
||||
// sent.
|
||||
var (
|
||||
errAbandonNothing = errors.New("expedition abandon: nothing to abandon")
|
||||
errAbandonNotLeader = errors.New("expedition abandon: only the leader may call it")
|
||||
)
|
||||
|
||||
// abandonOutcome is what closing the expedition did, for a caller describing it
|
||||
// somewhere other than a DM.
|
||||
type abandonOutcome struct {
|
||||
Zone ZoneDefinition
|
||||
Day int
|
||||
Extracted bool // it was already out and standing in town: loot and XP are kept
|
||||
}
|
||||
|
||||
// performExpeditionAbandon is `!expedition abandon` minus the command framing.
|
||||
// Shared with the web action queue so closing a run from a phone disbands the
|
||||
// same roster, retires the same region runs, writes the same log line and tells
|
||||
// the same party — the members hear it from their leader either way, because
|
||||
// that is a fact about the expedition and not about which door was used.
|
||||
//
|
||||
// Like performExpeditionStart this does NOT take the per-user lock: its Matrix
|
||||
// caller already holds it across the whole `!expedition` switch. applyWebAbandon
|
||||
// takes it instead. Getting that backwards does not fail loudly — it wedges the
|
||||
// player's lock forever and every later adventure command from them hangs.
|
||||
func (p *AdventurePlugin) performExpeditionAbandon(uid id.UserID) (abandonOutcome, error) {
|
||||
exp, isLeader, err := activeExpeditionFor(uid)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
||||
return abandonOutcome{}, err
|
||||
}
|
||||
if exp == nil {
|
||||
// An extracted expedition is still the owner's to close — it holds the
|
||||
// roster until the resume window lapses. Without this, a leader who
|
||||
// wanted out had to pay to `!resume` first just to abandon.
|
||||
if exp, err = getResumableExpedition(ctx.Sender); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
||||
if exp, err = getResumableExpedition(uid); err != nil {
|
||||
return abandonOutcome{}, err
|
||||
}
|
||||
isLeader = exp != nil
|
||||
}
|
||||
if exp == nil {
|
||||
return p.SendDM(ctx.Sender, "No active expedition to abandon.")
|
||||
return abandonOutcome{}, refuseAdv(errAbandonNothing, "No active expedition to abandon.")
|
||||
}
|
||||
if !isLeader {
|
||||
// Abandoning throws away everyone's day. A member leaves alone.
|
||||
return p.SendDM(ctx.Sender,
|
||||
return abandonOutcome{}, refuseAdv(errAbandonNotLeader,
|
||||
"Only your party leader can abandon the expedition. `!expedition leave` to walk out alone.")
|
||||
}
|
||||
zone, _ := getZone(exp.ZoneID)
|
||||
extracted := exp.Status == ExpeditionStatusExtracting
|
||||
out := abandonOutcome{Zone: zone, Day: exp.CurrentDay, Extracted: exp.Status == ExpeditionStatusExtracting}
|
||||
audience := expeditionAudience(exp) // read before abandonExpedition disbands the roster
|
||||
if err := abandonExpedition(ctx.Sender); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't abandon: "+err.Error())
|
||||
if err := abandonExpedition(uid); err != nil {
|
||||
return abandonOutcome{}, err
|
||||
}
|
||||
markActedToday(ctx.Sender)
|
||||
markActedToday(uid)
|
||||
_ = retireAllRegionRuns(exp)
|
||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "expedition abandoned", "")
|
||||
// The roster is being disbanded out from under the members; they hear it from
|
||||
// their leader rather than discovering it the next time a command works again.
|
||||
for _, member := range audience {
|
||||
if member == uid {
|
||||
continue
|
||||
}
|
||||
if err := p.SendDM(member, fmt.Sprintf(
|
||||
"Your leader called off the expedition in **%s** on Day %d. You're free to start a run of your own.",
|
||||
zone.Display, exp.CurrentDay)); err != nil {
|
||||
slog.Warn("expedition: abandon DM failed", "user", member, "expedition", exp.ID, "err", err)
|
||||
}
|
||||
}
|
||||
// Emergence seam: see maybeRollPetArrivalOnEmerge. Inside the twin because
|
||||
// walking out of a dungeon is what rolls it, not saying so in a room.
|
||||
p.maybeRollPetArrivalOnEmerge(uid)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error {
|
||||
out, err := p.performExpeditionAbandon(ctx.Sender)
|
||||
if err != nil {
|
||||
var refusal advRefusal
|
||||
if errors.As(err, &refusal) {
|
||||
return p.SendDM(ctx.Sender, refusal.Error())
|
||||
}
|
||||
return p.SendDM(ctx.Sender, "Couldn't abandon: "+err.Error())
|
||||
}
|
||||
// An extracted party is standing in town, not in the dungeon: their supplies
|
||||
// are already spent and their loot is already banked. Say the true thing.
|
||||
body := fmt.Sprintf(
|
||||
"Expedition in **%s** abandoned on Day %d. Supplies are forfeit. The dungeon remembers.",
|
||||
zone.Display, exp.CurrentDay)
|
||||
if extracted {
|
||||
out.Zone.Display, out.Day)
|
||||
if out.Extracted {
|
||||
body = fmt.Sprintf(
|
||||
"You let the expedition in **%s** go. Day %d is where it ends — loot, XP, and coins are kept. The dungeon remembers.",
|
||||
zone.Display, exp.CurrentDay)
|
||||
out.Zone.Display, out.Day)
|
||||
}
|
||||
// The roster is being disbanded out from under the members; they hear it from
|
||||
// their leader rather than discovering it the next time a command works again.
|
||||
for _, uid := range audience {
|
||||
if uid == ctx.Sender {
|
||||
continue
|
||||
}
|
||||
if err := p.SendDM(uid, fmt.Sprintf(
|
||||
"Your leader called off the expedition in **%s** on Day %d. You're free to start a run of your own.",
|
||||
zone.Display, exp.CurrentDay)); err != nil {
|
||||
slog.Warn("expedition: abandon DM failed", "user", uid, "expedition", exp.ID, "err", err)
|
||||
}
|
||||
}
|
||||
if err := p.SendDM(ctx.Sender, body); err != nil {
|
||||
return err
|
||||
}
|
||||
// Emergence seam: see maybeRollPetArrivalOnEmerge.
|
||||
p.maybeRollPetArrivalOnEmerge(ctx.Sender)
|
||||
return nil
|
||||
return p.SendDM(ctx.Sender, body)
|
||||
}
|
||||
|
||||
// helper: ensure we don't shadow id.UserID import in test harness.
|
||||
@@ -931,6 +1115,7 @@ func (p *AdventurePlugin) autoPickStaleFork(exp *Expedition, run *DungeonRun, pf
|
||||
if err := removeAdvInventoryItem(spendTool); err != nil {
|
||||
slog.Warn("expedition: autopilot tools spend", "user", run.UserID, "err", err)
|
||||
}
|
||||
beatLock(run, chosen.Label, "picked")
|
||||
}
|
||||
fireGraphRegionTransition(run.UserID, g.Nodes[run.CurrentNode], g.Nodes[chosen.To])
|
||||
if exp != nil {
|
||||
@@ -993,6 +1178,7 @@ func (p *AdventurePlugin) backtrackFromDeadFork(exp *Expedition, run *DungeonRun
|
||||
slog.Warn("expedition: backtrack clear fork", "run", run.RunID, "err", err)
|
||||
return false
|
||||
}
|
||||
beatLock(run, "", "sealed")
|
||||
if _, err := revisitZoneRun(run.RunID, target, run.VisitedNodes); err != nil {
|
||||
slog.Warn("expedition: backtrack from dead fork", "run", run.RunID, "err", err)
|
||||
return false
|
||||
|
||||
@@ -352,32 +352,54 @@ func resumeExpedition(expID string, supplies ExpeditionSupplies) error {
|
||||
|
||||
// ── !extract command ────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) handleExtractCmd(ctx MessageContext, _ string) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
// Sentinels for the two ways an extraction can be refused. They exist so the
|
||||
// headless caller (the web action queue, pete_orders.go) can turn a refusal into
|
||||
// its own verdict without parsing a DM. `!extract` maps them straight back to the
|
||||
// prose it always sent.
|
||||
var (
|
||||
errExtractNoRun = errors.New("extract: no active expedition")
|
||||
errExtractNotLeader = errors.New("extract: not the party leader")
|
||||
)
|
||||
|
||||
// extractOutcome is what an extraction did, for a caller that has to describe it
|
||||
// somewhere other than a DM.
|
||||
type extractOutcome struct {
|
||||
Zone string // display name
|
||||
Day int
|
||||
}
|
||||
|
||||
// performExtraction is the whole of `!extract` minus the command framing: the
|
||||
// per-user lock, the leader check, the state flip, the log line, the party
|
||||
// fan-out and the emergence pet roll. It is shared with the web action queue so
|
||||
// that pulling out from a phone is the *same* extraction, not a second
|
||||
// implementation of one — the party still gets DM'd, the log still gets its
|
||||
// line, and the resume window is the same window.
|
||||
func (p *AdventurePlugin) performExtraction(uid id.UserID) (extractOutcome, error) {
|
||||
userMu := p.advUserLock(uid)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
exp, isLeader, err := activeExpeditionFor(ctx.Sender)
|
||||
exp, isLeader, err := activeExpeditionFor(uid)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
||||
return extractOutcome{}, fmt.Errorf("reading expedition state: %w", err)
|
||||
}
|
||||
if exp == nil {
|
||||
return p.SendDM(ctx.Sender, "No active expedition to extract from.")
|
||||
return extractOutcome{}, errExtractNoRun
|
||||
}
|
||||
if !isLeader {
|
||||
// Extraction ends the expedition for the whole roster, so it is the
|
||||
// leader's call — the same reasoning that makes `!flee` leader-only.
|
||||
return p.SendDM(ctx.Sender, "Only your party leader can call the extraction. Ask them to `!extract`, or `!expedition leave` to walk out alone.")
|
||||
return extractOutcome{}, errExtractNotLeader
|
||||
}
|
||||
zone, _ := getZone(exp.ZoneID)
|
||||
updated, err := voluntaryExtractExpedition(ctx.Sender)
|
||||
updated, err := voluntaryExtractExpedition(uid)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't extract: "+err.Error())
|
||||
return extractOutcome{}, err
|
||||
}
|
||||
line := flavor.Pick(flavor.ExtractionVoluntary)
|
||||
_ = appendExpeditionLog(updated.ID, updated.CurrentDay, "narrative",
|
||||
"voluntary extraction", line)
|
||||
markActedToday(ctx.Sender)
|
||||
markActedToday(uid)
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("🚪 **Extraction — %s, Day %d**\n\n",
|
||||
@@ -401,84 +423,167 @@ func (p *AdventurePlugin) handleExtractCmd(ctx MessageContext, _ string) error {
|
||||
|
||||
// Emergence seam: surfacing from a run is when an animal may have moved
|
||||
// into the empty house. Every member surfaced, so every member rolls.
|
||||
for _, uid := range expeditionAudience(updated) {
|
||||
p.maybeRollPetArrivalOnEmerge(uid)
|
||||
for _, member := range expeditionAudience(updated) {
|
||||
p.maybeRollPetArrivalOnEmerge(member)
|
||||
}
|
||||
return extractOutcome{Zone: zone.Display, Day: updated.CurrentDay}, nil
|
||||
}
|
||||
|
||||
// handleExtractCmd is `!extract`: the command framing around performExtraction.
|
||||
// The extraction itself, including the DM everyone in the party gets, happens in
|
||||
// there — so this only has to turn a refusal back into the prose it always sent.
|
||||
func (p *AdventurePlugin) handleExtractCmd(ctx MessageContext, _ string) error {
|
||||
_, err := p.performExtraction(ctx.Sender)
|
||||
switch {
|
||||
case errors.Is(err, errExtractNoRun):
|
||||
return p.SendDM(ctx.Sender, "No active expedition to extract from.")
|
||||
case errors.Is(err, errExtractNotLeader):
|
||||
return p.SendDM(ctx.Sender, "Only your party leader can call the extraction. Ask them to `!extract`, or `!expedition leave` to walk out alone.")
|
||||
case err != nil:
|
||||
return p.SendDM(ctx.Sender, "Couldn't extract: "+err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── !resume command ─────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) handleResumeCmd(ctx MessageContext, args string) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
// Sentinels for the ways going back in can be refused. Same contract as
|
||||
// performExpeditionStart's: each one comes back inside an advRefusal that also
|
||||
// carries the finished sentence, so `!resume` keeps its copy verbatim.
|
||||
//
|
||||
// errResumeNeedLoadout is the odd one out and deliberately so: it is not a
|
||||
// refusal at all but the loadout prompt, returned as one so the whole decision
|
||||
// stays inside the lock. The web never triggers it — it always names a loadout.
|
||||
var (
|
||||
errResumeNeedLoadout = errors.New("resume: no loadout named")
|
||||
errResumeBusy = errors.New("resume: already on an expedition")
|
||||
errResumeNothing = errors.New("resume: no extracted expedition")
|
||||
errResumeLapsed = errors.New("resume: past the 7-day window")
|
||||
errResumeBadPacks = errors.New("resume: invalid pack selection")
|
||||
errResumeBroke = errors.New("resume: cannot cover outfitting")
|
||||
errResumeFailed = errors.New("resume: could not resume")
|
||||
)
|
||||
|
||||
// resumeOutcome is what going back in did, for a caller describing it somewhere
|
||||
// other than a DM.
|
||||
type resumeOutcome struct {
|
||||
Zone ZoneDefinition
|
||||
Day int
|
||||
Supplies ExpeditionSupplies
|
||||
Purchase SupplyPurchase
|
||||
Threat int
|
||||
Stack int
|
||||
Line string
|
||||
}
|
||||
|
||||
// performResume is `!resume` minus the command framing: the leader check, the
|
||||
// lapse check, the re-outfitting purchase and the fresh region run. Shared with
|
||||
// the web action queue so that walking back in from a phone is the same walk.
|
||||
//
|
||||
// loadoutTok is the raw `Ns Md` / preset token; empty asks for the prompt.
|
||||
// idemKey, when set, is the web order's guid and moves the money onto the
|
||||
// idempotent variants — see beginExpeditionIdem for why a refund then makes a
|
||||
// retry unsafe, which is why every error here is permanent for the web caller.
|
||||
func (p *AdventurePlugin) performResume(uid id.UserID, loadoutTok, idemKey string) (resumeOutcome, error) {
|
||||
userMu := p.advUserLock(uid)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
c, err := LoadDnDCharacter(ctx.Sender)
|
||||
c, err := LoadDnDCharacter(uid)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't load your character: "+err.Error())
|
||||
return resumeOutcome{}, fmt.Errorf("Couldn't load your character: %s", err)
|
||||
}
|
||||
if c == nil || c.PendingSetup {
|
||||
return p.SendDM(ctx.Sender, "No Adv 2.0 character yet — run `!setup` first.")
|
||||
return resumeOutcome{}, refuseAdv(errResumeNothing, "No Adv 2.0 character yet — run `!setup` first.")
|
||||
}
|
||||
|
||||
if existing, isLeader, _ := activeExpeditionFor(ctx.Sender); existing != nil {
|
||||
if existing, isLeader, _ := activeExpeditionFor(uid); existing != nil {
|
||||
zone, _ := getZone(existing.ZoneID)
|
||||
if !isLeader {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
return resumeOutcome{}, refuseAdv(errResumeBusy,
|
||||
"You're riding a party expedition in **%s** (Day %d). Only its leader can `!resume`.",
|
||||
zone.Display, existing.CurrentDay))
|
||||
zone.Display, existing.CurrentDay)
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
// A web order that already paid and already resumed lands here on the
|
||||
// re-offer; the settled debit is what tells that apart from a player who
|
||||
// really is already out. Same tell as performExpeditionStart's.
|
||||
if idemKey != "" && p.euro != nil && p.euro.HasExternalTx(idemKey) {
|
||||
out := resumeOutcome{Zone: zone, Day: existing.CurrentDay,
|
||||
Supplies: existing.Supplies, Threat: existing.ThreatLevel}
|
||||
// Re-price the same loadout at the same tier so the verdict this feeds
|
||||
// can still say what it cost. Leaving Purchase zero would file a
|
||||
// "re-outfitted for 0 coins" receipt for a trip that was paid for.
|
||||
if pp, perr := resolveLoadoutOrParse(strings.TrimSpace(loadoutTok), zone.Tier); perr == nil {
|
||||
out.Purchase = pp
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return resumeOutcome{}, refuseAdv(errResumeBusy,
|
||||
"You already have an active expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.",
|
||||
zone.Display, existing.CurrentDay))
|
||||
zone.Display, existing.CurrentDay)
|
||||
}
|
||||
|
||||
exp, err := getResumableExpedition(ctx.Sender)
|
||||
exp, err := getResumableExpedition(uid)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
||||
return resumeOutcome{}, fmt.Errorf("Couldn't read expedition state: %s", err)
|
||||
}
|
||||
if exp == nil {
|
||||
return p.SendDM(ctx.Sender, "No extracted expedition to resume. Use `!expedition start <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()) {
|
||||
// Expire it so it doesn't keep resurfacing. The hourly sweeper would get
|
||||
// here on its own; this keeps the refusal and the reap in one breath.
|
||||
_ = completeExpedition(exp.ID, ExpeditionStatusFailed)
|
||||
return p.SendDM(ctx.Sender,
|
||||
return resumeOutcome{}, refuseAdv(errResumeLapsed,
|
||||
"That extraction is past its 7-day resume window — the dungeon has reshaped without you. Start a new expedition.")
|
||||
}
|
||||
|
||||
resumeZone, _ := getZone(exp.ZoneID)
|
||||
zone, _ := getZone(exp.ZoneID)
|
||||
// D5-b: prompt for a preset loadout on empty args.
|
||||
if strings.TrimSpace(args) == "" {
|
||||
return p.SendDM(ctx.Sender, renderLoadoutPrompt(resumeZone, "resume"))
|
||||
if strings.TrimSpace(loadoutTok) == "" {
|
||||
return resumeOutcome{}, refuseAdv(errResumeNeedLoadout, "%s", renderLoadoutPrompt(zone, "resume"))
|
||||
}
|
||||
purchase, err := resolveLoadoutOrParse(strings.TrimSpace(args), resumeZone.Tier)
|
||||
purchase, err := resolveLoadoutOrParse(strings.TrimSpace(loadoutTok), zone.Tier)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't parse supply packs: "+err.Error())
|
||||
return resumeOutcome{}, refuseAdv(errResumeBadPacks, "Couldn't parse supply packs: %s", err.Error())
|
||||
}
|
||||
if err := purchase.Validate(resumeZone.Tier); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Invalid pack selection: "+err.Error())
|
||||
if err := purchase.Validate(zone.Tier); err != nil {
|
||||
return resumeOutcome{}, refuseAdv(errResumeBadPacks, "Invalid pack selection: %s", err.Error())
|
||||
}
|
||||
cost := float64(purchase.Cost())
|
||||
if p.euro == nil {
|
||||
return p.SendDM(ctx.Sender, "Coin system unavailable — try again later.")
|
||||
return resumeOutcome{}, refuseAdv(errResumeFailed, "Coin system unavailable — try again later.")
|
||||
}
|
||||
if balance := p.euro.GetBalance(ctx.Sender); balance < cost {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"Not enough coins. Outfitting costs **%d** but you have **%.0f**.",
|
||||
int(cost), balance))
|
||||
paid := idemKey != "" && p.euro.HasExternalTx(idemKey)
|
||||
if !paid {
|
||||
if balance := p.euro.GetBalance(uid); balance < cost {
|
||||
return resumeOutcome{}, refuseAdv(errResumeBroke,
|
||||
"Not enough coins. Outfitting costs **%d** but you have **%.0f**.",
|
||||
int(cost), balance)
|
||||
}
|
||||
}
|
||||
if !p.euro.Debit(ctx.Sender, cost, "expedition resume outfitting: "+string(exp.ZoneID)) {
|
||||
return p.SendDM(ctx.Sender, "Couldn't debit outfitting cost (try again).")
|
||||
debit := func(why string) bool { return p.euro.Debit(uid, cost, why) }
|
||||
refund := func(why, suffix string) { p.euro.Credit(uid, cost, why) }
|
||||
if idemKey != "" {
|
||||
debit = func(why string) bool {
|
||||
ok, _, err := p.euro.DebitIdem(uid, cost, why, idemKey)
|
||||
return err == nil && ok
|
||||
}
|
||||
refund = func(why, suffix string) {
|
||||
if _, _, err := p.euro.CreditIdem(uid, cost, why, idemKey+":refund"+suffix); err != nil {
|
||||
slog.Error("expedition: resume refund failed", "user", uid, "order", idemKey, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !debit("expedition resume outfitting: " + string(exp.ZoneID)) {
|
||||
return resumeOutcome{}, refuseAdv(errResumeFailed, "Couldn't debit outfitting cost (try again).")
|
||||
}
|
||||
|
||||
zone, _ := getZone(exp.ZoneID)
|
||||
supplies := makeSupplies(zone.Tier, purchase)
|
||||
if err := resumeExpedition(exp.ID, supplies); err != nil {
|
||||
p.euro.Credit(ctx.Sender, cost, "expedition resume refund")
|
||||
return p.SendDM(ctx.Sender, "Couldn't resume: "+err.Error())
|
||||
refund("expedition resume refund", "")
|
||||
return resumeOutcome{}, refuseAdv(errResumeFailed, "Couldn't resume: %s", err.Error())
|
||||
}
|
||||
exp.Status = ExpeditionStatusActive
|
||||
exp.Supplies = supplies
|
||||
@@ -489,25 +594,40 @@ func (p *AdventurePlugin) handleResumeCmd(ctx MessageContext, args string) error
|
||||
exp.RegionState[regionStateRegionRuns] = map[string]string{}
|
||||
_ = persistRegionState(exp)
|
||||
if _, err := ensureRegionRun(exp, c.Level); err != nil {
|
||||
p.euro.Credit(ctx.Sender, cost, "expedition resume refund (run-spawn failed)")
|
||||
return p.SendDM(ctx.Sender, "Couldn't outfit the resumed region: "+err.Error())
|
||||
refund("expedition resume refund (run-spawn failed)", ":region")
|
||||
return resumeOutcome{}, refuseAdv(errResumeFailed, "Couldn't outfit the resumed region: %s", err.Error())
|
||||
}
|
||||
line := flavor.Pick(flavor.ExpeditionResume)
|
||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative",
|
||||
"expedition resumed", line)
|
||||
|
||||
return resumeOutcome{
|
||||
Zone: zone, Day: exp.CurrentDay, Supplies: supplies, Purchase: purchase,
|
||||
Threat: exp.ThreatLevel, Stack: exp.TemporalStack, Line: line,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// handleResumeCmd is `!resume`: the command framing around performResume.
|
||||
func (p *AdventurePlugin) handleResumeCmd(ctx MessageContext, args string) error {
|
||||
out, err := p.performResume(ctx.Sender, args, "")
|
||||
if err != nil {
|
||||
// Every refusal — and the loadout prompt, which travels as one — arrives
|
||||
// as a finished sentence, so this only has to say it.
|
||||
return p.SendDM(ctx.Sender, err.Error())
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("🚪 **Expedition resumed — %s, Day %d**\n\n",
|
||||
zone.Display, exp.CurrentDay))
|
||||
if line != "" {
|
||||
b.WriteString(line)
|
||||
out.Zone.Display, out.Day))
|
||||
if out.Line != "" {
|
||||
b.WriteString(out.Line)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("**Re-outfitted:** %.0f SU (%d standard, %d deluxe) — %d coins\n",
|
||||
supplies.Max, purchase.StandardPacks, purchase.DeluxePacks, purchase.Cost()))
|
||||
b.WriteString(fmt.Sprintf("**Threat:** %d / 100 (resumed at extraction value)\n", exp.ThreatLevel))
|
||||
if exp.TemporalStack != 0 {
|
||||
b.WriteString(fmt.Sprintf("**Zone stack:** %d (resumed)\n", exp.TemporalStack))
|
||||
out.Supplies.Max, out.Purchase.StandardPacks, out.Purchase.DeluxePacks, out.Purchase.Cost()))
|
||||
b.WriteString(fmt.Sprintf("**Threat:** %d / 100 (resumed at extraction value)\n", out.Threat))
|
||||
if out.Stack != 0 {
|
||||
b.WriteString(fmt.Sprintf("**Zone stack:** %d (resumed)\n", out.Stack))
|
||||
}
|
||||
b.WriteString("\nUse `!expedition status` for the daily briefing.")
|
||||
return p.SendDM(ctx.Sender, b.String())
|
||||
|
||||
@@ -175,3 +175,28 @@ func TestResume_WindowExpired(t *testing.T) {
|
||||
time.Since(*got.CompletedAt), extractResumeWindow)
|
||||
}
|
||||
}
|
||||
|
||||
// `!expedition extract` and `!expedition resume` are aliases for two top-level
|
||||
// commands that take the per-user lock themselves. If the alias dispatcher takes
|
||||
// that lock first the handler blocks on it forever and, because the deferred
|
||||
// unlock never runs, every later adventure command from that player wedges too.
|
||||
// This does not fail on regression — it hangs — so the timeout is the assertion.
|
||||
func TestExpeditionAliasesDoNotWedgeTheUserLock(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
uid := id.UserID("@exp-alias-lock:example")
|
||||
t.Cleanup(func() { cleanupExpeditions(uid) })
|
||||
|
||||
for _, sub := range []string{"extract", "resume"} {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
p := &AdventurePlugin{euro: &EuroPlugin{}}
|
||||
_ = p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, sub)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatalf("!expedition %s never returned: the alias re-took advUserLock", sub)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +170,13 @@ func (p *AdventurePlugin) advanceToNextRegion(userID id.UserID, exp *Expedition,
|
||||
tc := resolveTransitWanderingCheck(exp, charClass, nil)
|
||||
_ = processTransitWanderingCheck(exp, tc)
|
||||
|
||||
// The liveblog beat goes on the *outgoing* run, and it has to be filed before
|
||||
// the run is retired — a region crossing is the last thing that happens in
|
||||
// the region being left, and it is what explains why that run's log stops.
|
||||
if outgoing, _ := getZoneRun(exp.RunID); outgoing != nil {
|
||||
beatRegion(outgoing, cur.Name, next.Name)
|
||||
}
|
||||
|
||||
// R2 — retire the outgoing region's DungeonRun before mutating
|
||||
// CurrentRegion so retireRegionRun keys the right region.
|
||||
if err := retireRegionRun(exp, cur.ID); err != nil {
|
||||
|
||||
@@ -893,6 +893,7 @@ func (p *AdventurePlugin) runHarvestForAdvance(
|
||||
if herr != nil {
|
||||
return autoHarvestResult{}, ""
|
||||
}
|
||||
beatHaul(fresh, hr.Summary)
|
||||
return hr, renderAutoHarvestFooter(hr.Summary)
|
||||
}
|
||||
|
||||
@@ -1061,7 +1062,8 @@ func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone Zo
|
||||
case RoomEntry:
|
||||
return
|
||||
case RoomTrap:
|
||||
_, narration := p.resolveTrapRoom(userID, run, zone)
|
||||
damage, narration := p.resolveTrapRoom(userID, run, zone)
|
||||
beatTrap(userID, run, damage)
|
||||
outcome = narration
|
||||
return
|
||||
case RoomExploration:
|
||||
@@ -1125,6 +1127,12 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
|
||||
result := pres.Seats[0]
|
||||
postHP, maxHP := dndHPSnapshot(userID)
|
||||
nat20s, nat1s := scanMoodEventsFromEvents(run.RunID, pres.Events)
|
||||
// One beat for the fight, filed here rather than on each of the three
|
||||
// outcome branches below — every one of them passes through this point with
|
||||
// the result already decided, and a single site can't drift out of step with
|
||||
// the others.
|
||||
beatCombat(run, monster.Name, elite, isBoss, result.PlayerWon, result.TimedOut,
|
||||
preHP, postHP, maxHP, nat20s, nat1s)
|
||||
|
||||
// Compact mode: skip TwinBee banter, skip the multi-beat play-by-play.
|
||||
// Render a single outcome line. Still records kills, threat, and drops.
|
||||
@@ -1226,6 +1234,14 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
|
||||
// tryPatrolEncounter); see retreatThreatBump in
|
||||
// dnd_expedition_combat.go.
|
||||
_, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath)
|
||||
// Ahead of abandonZoneRun, which would close the story as "abandoned" —
|
||||
// the difference between being killed and running out of clock is the
|
||||
// most interesting fact in the whole log.
|
||||
if result.TimedOut {
|
||||
beatRunEnd(run, "retreated")
|
||||
} else {
|
||||
beatRunEnd(run, "died")
|
||||
}
|
||||
_ = abandonZoneRun(userID)
|
||||
// Timeout loss = retreat; the fighters took wounds but nobody actually
|
||||
// died. Don't fire markAdventureDead — that would trigger the 6h respawn
|
||||
|
||||
@@ -238,6 +238,7 @@ func (p *AdventurePlugin) resolveSecretRoom(userID id.UserID, run *DungeonRun, z
|
||||
it := item
|
||||
if line := p.grantZoneItem(userID, &it, "🧪"); line != "" {
|
||||
rewards = append(rewards, line)
|
||||
beatTreasure(run, it.Name, "cache")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,6 +393,11 @@ func (p *AdventurePlugin) rollZoneLoot(userID id.UserID, run *DungeonRun, zone Z
|
||||
slog.Error("zone: addLoot audit", "user", userID, "item", entry.ItemID, "err", err)
|
||||
}
|
||||
granted = append(granted, entry.ItemID)
|
||||
source := "zone"
|
||||
if bossCleared {
|
||||
source = "boss"
|
||||
}
|
||||
beatTreasure(run, item.Name, source)
|
||||
}
|
||||
return granted
|
||||
}
|
||||
|
||||
@@ -295,6 +295,7 @@ func startZoneRun(userID id.UserID, zoneID ZoneID, dndLevel int, rng *rand.Rand)
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("insert zone run: %w", err)
|
||||
}
|
||||
beatRunStart(userID, run, zone)
|
||||
return run, nil
|
||||
}
|
||||
|
||||
@@ -581,6 +582,7 @@ func abandonZoneRun(userID id.UserID) error {
|
||||
if r == nil {
|
||||
return ErrNoActiveRun
|
||||
}
|
||||
beatRunEnd(r, "abandoned")
|
||||
_, err = db.Get().Exec(`
|
||||
UPDATE dnd_zone_run
|
||||
SET abandoned = 1,
|
||||
@@ -599,6 +601,12 @@ func abandonZoneRunByID(runID string) error {
|
||||
if runID == "" {
|
||||
return nil
|
||||
}
|
||||
// The generic funnel: it fires for an idle reap, a region retirement, and a
|
||||
// completed run being tidied up alike. beatRunEnd is first-writer-wins, so
|
||||
// this only ever supplies the outcome nothing more specific already did.
|
||||
if r, _ := getZoneRun(runID); r != nil {
|
||||
beatRunEnd(r, "abandoned")
|
||||
}
|
||||
_, err := db.Get().Exec(`
|
||||
UPDATE dnd_zone_run
|
||||
SET abandoned = 1,
|
||||
|
||||
@@ -280,47 +280,77 @@ func (p *AdventurePlugin) expeditionCmdParty(ctx MessageContext) error {
|
||||
return p.SendDM(ctx.Sender, b.String())
|
||||
}
|
||||
|
||||
// expeditionCmdLeave walks a member out. The leader cannot leave — their row is
|
||||
// the expedition — so they are pointed at `!extract`, which ends it for all.
|
||||
func (p *AdventurePlugin) expeditionCmdLeave(ctx MessageContext) error {
|
||||
// Sentinels for the two ways walking out can be refused, so the web action queue
|
||||
// can pick a verdict without reading prose. errLeaveIsLeader is deliberately not
|
||||
// errAbandonNotLeader inverted-and-reused: they are opposite facts about the same
|
||||
// person and a verdict that conflated them would tell a leader they weren't one.
|
||||
var (
|
||||
errLeaveNothing = errors.New("expedition leave: no expedition to leave")
|
||||
errLeaveIsLeader = errors.New("expedition leave: the leader's row is the expedition")
|
||||
)
|
||||
|
||||
// performExpeditionLeave is `!expedition leave` minus the command framing.
|
||||
// Shared with the web action queue, so a member walking out from a phone unseats
|
||||
// the same way and the leader is told either way.
|
||||
//
|
||||
// Like performExpeditionAbandon this does NOT take the per-user lock — its
|
||||
// Matrix caller holds it across the whole `!expedition` switch, and applyWebLeave
|
||||
// takes it instead. See the comment on performExpeditionAbandon for what getting
|
||||
// that backwards costs.
|
||||
func (p *AdventurePlugin) performExpeditionLeave(uid id.UserID) error {
|
||||
// Resolve the seat the way the guards that trap them do. seatedExpeditionFor
|
||||
// spans `extracting`, which activeExpeditionFor does not: a leader who
|
||||
// extracts and never resumes would otherwise leave their members seated —
|
||||
// refused a new adventure by the guard, and told "no active expedition" by
|
||||
// the very command the guard points them at. The exit has to see every state
|
||||
// the gate sees. It already excludes leaders, so they fall through below.
|
||||
seated, err := seatedExpeditionFor(ctx.Sender)
|
||||
seated, err := seatedExpeditionFor(uid)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
||||
return err
|
||||
}
|
||||
if seated != nil {
|
||||
return p.leaveSeatedParty(ctx, seated)
|
||||
return p.leaveSeatedParty(uid, seated)
|
||||
}
|
||||
|
||||
exp, isLeader, err := activeExpeditionFor(ctx.Sender)
|
||||
exp, isLeader, err := activeExpeditionFor(uid)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
||||
return err
|
||||
}
|
||||
if exp == nil {
|
||||
return p.SendDM(ctx.Sender, "No active expedition.")
|
||||
return refuseAdv(errLeaveNothing, "No active expedition.")
|
||||
}
|
||||
if isLeader {
|
||||
return p.SendDM(ctx.Sender,
|
||||
return refuseAdv(errLeaveIsLeader,
|
||||
"You're leading this one — `!extract` ends it for everyone, or `!expedition abandon` to walk away from it.")
|
||||
}
|
||||
return p.leaveSeatedParty(ctx, exp)
|
||||
return p.leaveSeatedParty(uid, exp)
|
||||
}
|
||||
|
||||
// leaveSeatedParty unseats a member and tells both ends. Shared by the two ways
|
||||
// a member's seat resolves: the `extracting` limbo and the plain active party.
|
||||
func (p *AdventurePlugin) leaveSeatedParty(ctx MessageContext, exp *Expedition) error {
|
||||
if err := leaveParty(exp.ID, ctx.Sender); err != nil {
|
||||
// expeditionCmdLeave walks a member out. The leader cannot leave — their row is
|
||||
// the expedition — so they are pointed at `!extract`, which ends it for all.
|
||||
func (p *AdventurePlugin) expeditionCmdLeave(ctx MessageContext) error {
|
||||
if err := p.performExpeditionLeave(ctx.Sender); err != nil {
|
||||
var refusal advRefusal
|
||||
if errors.As(err, &refusal) {
|
||||
return p.SendDM(ctx.Sender, refusal.Error())
|
||||
}
|
||||
return p.SendDM(ctx.Sender, "Couldn't leave: "+err.Error())
|
||||
}
|
||||
return p.SendDM(ctx.Sender, "You turn back for town. Your supplies stay with the party.")
|
||||
}
|
||||
|
||||
// leaveSeatedParty unseats a member and tells the leader. Shared by the two ways
|
||||
// a member's seat resolves: the `extracting` limbo and the plain active party.
|
||||
// The *member's* own confirmation is the caller's, because that is the one line
|
||||
// that differs between a DM and a web verdict.
|
||||
func (p *AdventurePlugin) leaveSeatedParty(uid id.UserID, exp *Expedition) error {
|
||||
if err := leaveParty(exp.ID, uid); err != nil {
|
||||
return err
|
||||
}
|
||||
// Supplies stay in the pool. They were spent on the expedition, not lent to
|
||||
// it, and clawing them back would let a member starve the party on their way
|
||||
// out of the door.
|
||||
_ = p.SendDM(id.UserID(exp.UserID), fmt.Sprintf(
|
||||
"**%s** turned back. Their supplies stay with the party.", p.DisplayName(ctx.Sender)))
|
||||
return p.SendDM(ctx.Sender, "You turn back for town. Your supplies stay with the party.")
|
||||
"**%s** turned back. Their supplies stay with the party.", p.DisplayName(uid)))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -279,8 +279,12 @@ func claimRealmFirst(kind, target string) bool {
|
||||
// *started* — every dispatch was an outcome — which is why the two live boredom
|
||||
// runs produced no news at all.
|
||||
//
|
||||
// The event_type must be one Pete already knows: an unknown type is a 400, which
|
||||
// retries and then parks the bulletin forever. Deploy Pete first.
|
||||
// The event_type no longer has to be one Pete already knows. It used to: an
|
||||
// unknown type was a 400, which retried to the cap and then parked the bulletin
|
||||
// forever, so shipping a new event type meant remembering to deploy Pete first.
|
||||
// Pete now publishes an untemplated type on a neutral fallback and counts it for
|
||||
// the operator, so the ordering rule is a property of the system rather than
|
||||
// something a human has to hold.
|
||||
func emitBoredomDeparture(userID id.UserID, zone ZoneDefinition, level int) {
|
||||
if !peteclient.Enabled() || !newsEmissionOn() {
|
||||
return
|
||||
@@ -339,6 +343,7 @@ func emitZoneClearNews(userID id.UserID, exp *Expedition) {
|
||||
Boss: zone.Boss.Name,
|
||||
Level: lvl,
|
||||
Outcome: "cleared",
|
||||
RunID: latestRunIDForNews(userID),
|
||||
OccurredAt: ts,
|
||||
}, userID, "")
|
||||
}
|
||||
|
||||
@@ -226,3 +226,55 @@ func TestDetailSnapshotSkipsDeadPlayers(t *testing.T) {
|
||||
t.Fatalf("detail set = %+v, want just the living player", snap.Players)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPetXPRidesTheCurveNotACopyOfIt pins the unit and the cap, which are the two
|
||||
// ways this can go quietly wrong on the web. XP is stored in centi-XP — a pet
|
||||
// earns 1.5 points an action and the ledger is an int — so a page that read XP
|
||||
// against a whole-number threshold would draw a bar 100x too full. And a capped
|
||||
// pet must report 0 needed rather than the next band's number, or its bar sits
|
||||
// forever short of a level it can never gain.
|
||||
func TestPetXPRidesTheCurveNotACopyOfIt(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
uid := id.UserID("@quack:test")
|
||||
seedDetailPlayer(t, uid, "Quack", 7)
|
||||
|
||||
adv, err := loadAdvCharacter(uid)
|
||||
if err != nil {
|
||||
t.Fatalf("loadAdvCharacter: %v", err)
|
||||
}
|
||||
adv.PetType = "cat"
|
||||
adv.PetName = "Mittens"
|
||||
adv.PetLevel = 4
|
||||
adv.PetXP = 750 // 7.5 of the 20 points level 4 wants
|
||||
adv.Pet2Type = "dog"
|
||||
adv.Pet2Name = "Rex"
|
||||
adv.Pet2Level = petMaxLevel
|
||||
adv.Pet2XP = 0
|
||||
if err := saveAdvCharacter(adv); err != nil {
|
||||
t.Fatalf("saveAdvCharacter: %v", err)
|
||||
}
|
||||
|
||||
snap, err := (&AdventurePlugin{}).buildDetailSnapshot(time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("buildDetailSnapshot: %v", err)
|
||||
}
|
||||
pets := snap.Players[0].Pets
|
||||
if len(pets) != 2 {
|
||||
t.Fatalf("pets = %+v, want both slots", pets)
|
||||
}
|
||||
byName := map[string]int{}
|
||||
for i, p := range pets {
|
||||
byName[p.Name] = i
|
||||
}
|
||||
mittens := pets[byName["Mittens"]]
|
||||
if mittens.XP != 750 {
|
||||
t.Errorf("Mittens XP = %d, want the stored centi-XP 750", mittens.XP)
|
||||
}
|
||||
if want := petXPToNextLevel(4) * 100; mittens.XPNeeded != want {
|
||||
t.Errorf("Mittens XPNeeded = %d, want %d — the curve is in centi-XP too",
|
||||
mittens.XPNeeded, want)
|
||||
}
|
||||
if rex := pets[byName["Rex"]]; rex.XPNeeded != 0 {
|
||||
t.Errorf("a capped pet needs %d more XP; want 0, meaning nothing left to earn", rex.XPNeeded)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ func authorDispatch(f peteclient.Fact) (headline, lede string) {
|
||||
}
|
||||
|
||||
prompt := buildDispatchPrompt(f)
|
||||
raw, err := callOllamaDispatch(host, model, prompt)
|
||||
raw, err := callOllamaDispatch(dispatchHTTP, host, model, prompt)
|
||||
if err != nil {
|
||||
slog.Warn("pete dispatch: LLM authoring failed, Pete will template", "guid", f.GUID, "err", err)
|
||||
return "", ""
|
||||
@@ -129,9 +129,11 @@ The event:
|
||||
}
|
||||
|
||||
// callOllamaDispatch posts a single non-streaming generation and returns the raw
|
||||
// completion (think-tags stripped). Its own bounded client, separate from the
|
||||
// interactive callOllama, because the game loop cannot wait 120s on the news.
|
||||
func callOllamaDispatch(host, model, prompt string) (string, error) {
|
||||
// completion (think-tags stripped). The client is a parameter because the two
|
||||
// callers have genuinely different patience: a dispatch is authored on a game
|
||||
// chokepoint and must not stall it, while a run summary rides a background
|
||||
// ticker and can afford to wait for a bigger model. See runSummaryHTTP.
|
||||
func callOllamaDispatch(client *http.Client, host, model, prompt string) (string, error) {
|
||||
payload := map[string]interface{}{
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
@@ -146,7 +148,7 @@ func callOllamaDispatch(host, model, prompt string) (string, error) {
|
||||
return "", fmt.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
apiURL := strings.TrimRight(host, "/") + "/api/generate"
|
||||
resp, err := dispatchHTTP.Post(apiURL, "application/json", bytes.NewReader(data))
|
||||
resp, err := client.Post(apiURL, "application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ollama request: %w", err)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,18 @@ func (p *AdventurePlugin) peteRosterTicker() {
|
||||
}
|
||||
p.pushRoster()
|
||||
p.pushDetails()
|
||||
p.pushSiege()
|
||||
// Self-rate-limited to realmPushInterval: the realm is aggregate scans
|
||||
// over the whole run history and none of it moves at roster speed.
|
||||
p.pushRealm()
|
||||
p.pushRunBeats()
|
||||
// After the beats, not before: the summary is the last beat of a run's
|
||||
// story and has no business overtaking the log it is about. It is also the
|
||||
// only step here that can talk to the model, which is why it lives on a
|
||||
// ticker at all rather than at the moment a run ends — and why it starts
|
||||
// beside the ticker rather than on it, since a cold model takes longer to
|
||||
// load than the interval between two pushes.
|
||||
p.sweepRunSummariesAsync()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +227,11 @@ func (p *AdventurePlugin) buildDetailSnapshot(now time.Time) (peteclient.DetailS
|
||||
if p.euro != nil {
|
||||
pd.Balance = p.euro.GetBalance(uid)
|
||||
}
|
||||
// W5b: what this owner may ask for from the web, priced. See pete_offers.go
|
||||
// on why a quote is not a permission.
|
||||
pd.Zones = zoneOffersFor(uid)
|
||||
pd.Resume = resumeOfferFor(uid)
|
||||
pd.Babysit = babysitOfferFor(adv)
|
||||
snap.Players = append(snap.Players, pd)
|
||||
}
|
||||
return snap, nil
|
||||
@@ -445,23 +462,84 @@ func equippedViews(uid id.UserID) []peteclient.ItemView {
|
||||
|
||||
// petViews returns the player's live pet slots. A pet that was chased away is
|
||||
// omitted — it isn't with them right now, and the self-view shows the present.
|
||||
//
|
||||
// XPNeeded rides along so the web can draw the progress toward the next level.
|
||||
// It is the engine's number, not Pete's: the curve steps by level band and a
|
||||
// copy of it on the web side would be a second answer to "how close is my dog"
|
||||
// that drifts the first time the band moves.
|
||||
func petViews(adv *AdventureCharacter) []peteclient.PetView {
|
||||
var out []peteclient.PetView
|
||||
if adv.PetType != "" && !adv.PetChasedAway {
|
||||
out = append(out, peteclient.PetView{
|
||||
Type: adv.PetType, Name: adv.PetName, Level: adv.PetLevel,
|
||||
XP: adv.PetXP, ArmorTier: adv.PetArmorTier,
|
||||
XP: adv.PetXP, XPNeeded: petXPNeededCenti(adv.PetLevel),
|
||||
ArmorTier: adv.PetArmorTier,
|
||||
})
|
||||
}
|
||||
if adv.Pet2Type != "" && !adv.Pet2ChasedAway {
|
||||
out = append(out, peteclient.PetView{
|
||||
Type: adv.Pet2Type, Name: adv.Pet2Name, Level: adv.Pet2Level,
|
||||
XP: adv.Pet2XP, ArmorTier: adv.Pet2ArmorTier,
|
||||
XP: adv.Pet2XP, XPNeeded: petXPNeededCenti(adv.Pet2Level),
|
||||
ArmorTier: adv.Pet2ArmorTier,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// partySeatViews describes who is on this expedition for the public detail page.
|
||||
// Returns nil for a solo run: expeditionParty always hands back at least the
|
||||
// leader, and a "party" of one chair is a worse thing to draw than nothing.
|
||||
//
|
||||
// The opt-out rule is the Siege contributor's, not the realm occupant's: a seat
|
||||
// belonging to an opted-out player is kept and anonymised. Deleting it would
|
||||
// make a party of three read as a pair, and the numbers beside it — the supply
|
||||
// burn, the threat, the enemy scaling — all felt three bodies. What an unnamed
|
||||
// seat discloses is that somebody else is down there, which the zone and day on
|
||||
// this same page already say about everyone in the party.
|
||||
//
|
||||
// Levels come from a per-seat character load. Parties cap at three and the
|
||||
// companion needs no load at all, so this is at most two extra reads for a
|
||||
// player who is actually in one.
|
||||
func partySeatViews(exp *Expedition) []peteclient.PartySeatView {
|
||||
seats, err := expeditionParty(exp.ID, exp.UserID)
|
||||
if err != nil {
|
||||
slog.Debug("pete: party seats unavailable", "expedition", exp.ID, "err", err)
|
||||
return nil
|
||||
}
|
||||
if len(seats) < 2 {
|
||||
return nil // solo, or a roster that only holds its leader
|
||||
}
|
||||
out := make([]peteclient.PartySeatView, 0, len(seats))
|
||||
for _, s := range seats {
|
||||
if s.Kind == SeatCompanion {
|
||||
// The hireling is named unconditionally: he is not a player, has no
|
||||
// board row to link to and no privacy to protect.
|
||||
out = append(out, peteclient.PartySeatView{
|
||||
Kind: "companion", Name: companionDisplayName,
|
||||
})
|
||||
continue
|
||||
}
|
||||
kind := "member"
|
||||
if s.Kind == SeatLeader {
|
||||
kind = "leader"
|
||||
}
|
||||
v := peteclient.PartySeatView{Kind: kind}
|
||||
if !isNewsOptedOut(s.UserID) {
|
||||
v.Name = charName(s.UserID)
|
||||
if v.Name != "" {
|
||||
// Token only alongside a name: a link to a page that says who they are
|
||||
// would undo the anonymising below all by itself.
|
||||
v.Token = eventToken(s.UserID, "roster")
|
||||
if c, cerr := LoadDnDCharacter(s.UserID); cerr == nil && c != nil {
|
||||
v.Level = c.Level
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildRosterSnapshot assembles the complete board.
|
||||
//
|
||||
// Complete is the contract: Pete *replaces* its board with this, so anyone we
|
||||
@@ -548,7 +626,13 @@ func buildRosterSnapshot(now time.Time, euro *EuroPlugin) (peteclient.RosterSnap
|
||||
|
||||
e.Detail = rosterDetail(pl.uid, c)
|
||||
|
||||
if exp, _ := getActiveExpedition(pl.uid); exp != nil {
|
||||
// activeExpeditionFor, not getActiveExpedition: the latter keys on
|
||||
// dnd_expedition.user_id and is blind to members, so a player seated on
|
||||
// somebody else's run has been reading as "idle in town" on the public board
|
||||
// for the whole life of N3 parties — standing in a tier-4 dungeon. The
|
||||
// expedition it resolves to is the leader's row, which is the right answer:
|
||||
// a party shares one clock, one supply pool and one run.
|
||||
if exp, _, _ := activeExpeditionFor(pl.uid); exp != nil {
|
||||
zone := zoneOrFallback(exp.ZoneID)
|
||||
e.Status = "expedition"
|
||||
e.Zone = zone.Display
|
||||
@@ -561,6 +645,7 @@ func buildRosterSnapshot(now time.Time, euro *EuroPlugin) (peteclient.RosterSnap
|
||||
if e.Detail != nil {
|
||||
e.Detail.Supplies = int(exp.Supplies.Current)
|
||||
e.Detail.ThreatLevel = exp.ThreatLevel
|
||||
e.Detail.Party = partySeatViews(exp)
|
||||
if exp.RunID != "" {
|
||||
if run, rerr := getZoneRun(exp.RunID); rerr == nil && run != nil && run.TotalRooms > 0 {
|
||||
e.Detail.Room = fmt.Sprintf("%d / %d", run.CurrentRoom+1, run.TotalRooms)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -436,6 +436,20 @@ func completeRunAtNode(runID string, boss bool) error {
|
||||
if boss {
|
||||
bossI = 1
|
||||
}
|
||||
// Only a boss kill is a clear. This function also closes a non-boss
|
||||
// dead-end — the party simply ran out of map — and beatRunEnd is
|
||||
// first-writer-wins, so calling that "cleared" would put a lie in the
|
||||
// liveblog and the run summary that nothing downstream could correct.
|
||||
// "ended" is deliberately outside the {cleared,died,retreated,abandoned}
|
||||
// set: the prompt renderer already degrades an unknown outcome to a
|
||||
// neutral "the run ended", which is exactly what happened.
|
||||
if run, _ := getZoneRun(runID); run != nil {
|
||||
outcome := "ended"
|
||||
if boss {
|
||||
outcome = "cleared"
|
||||
}
|
||||
beatRunEnd(run, outcome)
|
||||
}
|
||||
_, err := db.Get().Exec(`
|
||||
UPDATE dnd_zone_run
|
||||
SET boss_defeated = ?,
|
||||
@@ -474,7 +488,12 @@ func advanceZoneRunNode(runID, nextNode string) (int, error) {
|
||||
nextNode, string(visitedJSON), runID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return pathIndexOf(visited, nextNode), nil
|
||||
idx := pathIndexOf(visited, nextNode)
|
||||
// Every forward move in the game funnels through here — auto-advance, a
|
||||
// player's `!zone go`, and the autopilot's stale-fork pick alike — which is
|
||||
// what makes this the one honest place to say "the party is now in room N".
|
||||
beatRoom(r, nextNode, idx, "entered")
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
// resolveForkChoice takes a 1-based choice index against a pending
|
||||
|
||||
@@ -69,7 +69,11 @@ func revisitZoneRun(runID, targetNode string, visited []string) (int, error) {
|
||||
WHERE run_id = ?`, targetNode, runID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return pathIndexOf(visited, targetNode), nil
|
||||
idx := pathIndexOf(visited, targetNode)
|
||||
if run, _ := getZoneRun(runID); run != nil {
|
||||
beatRoom(run, targetNode, idx, "doubled back")
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
// handleRevisitCmd implements `!revisit <N>` (also reachable as
|
||||
|
||||
Reference in New Issue
Block a user