N3/P6a: let a member find the run they're standing in

Every ownership lookup in the adventure module keys on a user id, and a
party member owns no row: not the expedition, not the zone run. P4 gave
them activeExpeditionFor; this gives them activeZoneRunFor, and gives the
DM seams the audience they never had.

- activeZoneRunFor(user) -> (run, isLeader, err). An owner's lookup is
  exactly getActiveZoneRun, side effects and all -- in particular the
  §4.3 idle reap, which force-extracts the wrapping expedition. A member
  must never re-enter it, or glancing at the map would end the leader's
  run. Pinned.

- expeditionAudience / fanOutExpeditionDM. Briefing, recap and digest all
  DM'd id.UserID(e.UserID) alone. They now loop the roster, which
  partyMemberIDs collapses to exactly the owner when there is none -- so
  a solo expedition sends the same bytes to the same user it always has.
  The briefing's body is expedition-scoped but its pet prefix is not:
  each member has their own pet and their own sheet, so the roll rides a
  per-reader decorator (the shape P5 settled on for combat narration).
  The digest's A6 event anchor rolls per member for the same reason.

- releaseParty on every terminal transition. A seated member is barred
  from adventuring elsewhere, so a roster outliving its expedition
  strands the party. Deliberately NOT on 'extracting': that is a 7-day
  resumable limbo and !resume must bring everyone back. The roster clears
  when the window lapses to 'failed', which routes through
  completeExpedition like the rest.

Rosters are still empty in production -- nothing seats a member yet -- so
every loop here has exactly one element and the whole change is a no-op
until P6b. Golden byte-identical, go test ./... green.
This commit is contained in:
prosolis
2026-07-09 22:18:40 -07:00
parent e8d06195ac
commit 0f144fa335
6 changed files with 462 additions and 57 deletions

View File

@@ -356,24 +356,49 @@ func abandonExpedition(userID id.UserID) error {
if e == nil {
return ErrNoActiveExpedition
}
_, err = db.Get().Exec(`
if _, err = db.Get().Exec(`
UPDATE dnd_expedition
SET status = 'abandoned',
completed_at = CURRENT_TIMESTAMP,
last_activity = CURRENT_TIMESTAMP
WHERE expedition_id = ?`, e.ID)
return err
WHERE expedition_id = ?`, e.ID); err != nil {
return err
}
releaseParty(e.ID)
return nil
}
// completeExpedition marks the expedition complete (boss defeated or extracted).
func completeExpedition(expID string, status string) error {
_, err := db.Get().Exec(`
if _, err := db.Get().Exec(`
UPDATE dnd_expedition
SET status = ?,
completed_at = CURRENT_TIMESTAMP,
last_activity = CURRENT_TIMESTAMP
WHERE expedition_id = ?`, status, expID)
return err
WHERE expedition_id = ?`, status, expID); err != nil {
return err
}
releaseParty(expID)
return nil
}
// releaseParty clears the roster of an expedition that has reached a terminal
// status, freeing every member to start a run of their own. A member is barred
// from adventuring anywhere else while seated (assertNotAdventuring), so a
// roster that outlives its expedition strands the whole party.
//
// It is deliberately *not* called on the 'extracting' status: that is a
// seven-day resumable limbo, and `!resume` must bring the party back with it.
// The roster is cleared when the resume window lapses and the row flips to
// 'failed' — which routes through completeExpedition like everything else.
//
// A failure here is logged, not returned: the expedition is already terminal by
// the time we get here, and refusing to finish it over a stale roster row would
// leave the leader stuck instead of the members.
func releaseParty(expID string) {
if err := disbandParty(expID); err != nil {
slog.Warn("expedition: disband party", "expedition", expID, "err", err)
}
}
// applyThreatDelta clamps the threat level to [0,100], records the event,

View File

@@ -403,32 +403,14 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
body += "\n" + ml
}
if uid := id.UserID(e.UserID); uid != "" {
// The legacy overworld morning DM is skipped while underground, so
// its 25% morning pet event fires here instead, granting the one-day
// defense buff (reset at midnight via resetAllPetMorningDefense).
// Pet *arrival* is handled separately on the emergence seam below —
// not queued here — so we only roll the morning event.
if pet, perr := loadPetState(uid); perr == nil {
if petEvent := petMorningEvent(pet); petEvent != "" {
if char, cerr := loadAdvCharacter(uid); cerr == nil {
char.PetMorningDefense = true
if serr := saveAdvCharacter(char); serr != nil {
slog.Warn("expedition: save pet morning defense", "user", uid, "err", serr)
}
}
body = fmt.Sprintf("🐾 *%s*\n\n%s", petEvent, body)
}
}
if err := p.SendDM(uid, body); err != nil {
slog.Warn("expedition: send briefing DM", "user", uid, "err", err)
}
// Emergence seam: a briefing-time forced extraction (starvation /
// abyss collapse) surfaces the player alive — roll pet arrival.
// Combat/patrol deaths never reach deliverBriefing (the row is
// already abandoned), so an abandoned status here means a survived
// emergence; those death paths roll on respawn instead.
if e.Status == ExpeditionStatusAbandoned {
p.fanOutExpeditionDM(e, body, p.briefingPetPrefix)
// Emergence seam: a briefing-time forced extraction (starvation / abyss
// collapse) surfaces the players alive — roll pet arrival. Combat/patrol
// deaths never reach deliverBriefing (the row is already abandoned), so an
// abandoned status here means a survived emergence; those death paths roll
// on respawn instead. Every member emerges, so every member rolls.
if e.Status == ExpeditionStatusAbandoned {
for _, uid := range expeditionAudience(e) {
p.maybeRollPetArrivalOnEmerge(uid)
}
}
@@ -534,22 +516,9 @@ func (p *AdventurePlugin) deliverBriefingEventAnchored(e *Expedition, priorBrief
body += "\n" + ml
}
if uid := id.UserID(e.UserID); uid != "" {
if pet, perr := loadPetState(uid); perr == nil {
if petEvent := petMorningEvent(pet); petEvent != "" {
if char, cerr := loadAdvCharacter(uid); cerr == nil {
char.PetMorningDefense = true
if serr := saveAdvCharacter(char); serr != nil {
slog.Warn("expedition: save pet morning defense", "user", uid, "err", serr)
}
}
body = fmt.Sprintf("🐾 *%s*\n\n%s", petEvent, body)
}
}
if err := p.SendDM(uid, body); err != nil {
slog.Warn("expedition: send briefing DM", "user", uid, "err", err)
}
if forced && e.Status == ExpeditionStatusAbandoned {
p.fanOutExpeditionDM(e, body, p.briefingPetPrefix)
if forced && e.Status == ExpeditionStatusAbandoned {
for _, uid := range expeditionAudience(e) {
p.maybeRollPetArrivalOnEmerge(uid)
}
}
@@ -624,11 +593,7 @@ func (p *AdventurePlugin) deliverRecap(e *Expedition, now time.Time) error {
body += "\n" + renderNightCheck(*night)
}
if uid := id.UserID(e.UserID); uid != "" {
if err := p.SendDM(uid, body); err != nil {
slog.Warn("expedition: send recap DM", "user", uid, "err", err)
}
}
p.fanOutExpeditionDM(e, body, nil)
if err := appendExpeditionLog(e.ID, e.CurrentDay, "recap",
fmt.Sprintf("evening recap — %d log entries today", len(dayEntries)), line); err != nil {
return err
@@ -636,6 +601,31 @@ func (p *AdventurePlugin) deliverRecap(e *Expedition, now time.Time) error {
return nil
}
// briefingPetPrefix folds the reader's own 25% morning pet event onto the front
// of a briefing and grants the resulting one-day defense buff (reset at midnight
// by resetAllPetMorningDefense). The legacy overworld morning DM is skipped
// while underground, so this is where that roll lives.
//
// It is per-reader rather than per-expedition: each member of a party keeps
// their own pet, and the buff lands on their own character sheet.
func (p *AdventurePlugin) briefingPetPrefix(uid id.UserID, body string) string {
pet, err := loadPetState(uid)
if err != nil {
return body
}
petEvent := petMorningEvent(pet)
if petEvent == "" {
return body
}
if char, cerr := loadAdvCharacter(uid); cerr == nil {
char.PetMorningDefense = true
if serr := saveAdvCharacter(char); serr != nil {
slog.Warn("expedition: save pet morning defense", "user", uid, "err", serr)
}
}
return fmt.Sprintf("🐾 *%s*\n\n%s", petEvent, body)
}
// pickMorningBriefing returns a flavor line based on day-band: Day 1, 3, 7,
// 14, 21 each have their own pool; everything else uses the generic pool.
func pickMorningBriefing(day int) string {

View File

@@ -90,6 +90,7 @@ func forcedExtractExpedition(expID, reason string) (*Expedition, int, error) {
}
e.Status = ExpeditionStatusAbandoned
_ = retireAllRegionRuns(e)
releaseParty(e.ID)
return e, tax, nil
}

View File

@@ -295,12 +295,14 @@ func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error {
// mode: a Night-camp pitch flushes the accumulated day as a digest;
// every other quiet path stays silent until something interactive fires.
if body, ok := buildAutoRunDM(e.ID, r, campBlock, campDecision); ok {
if err := p.SendDM(uid, body); err != nil {
slog.Warn("expedition: autorun DM", "user", uid, "err", err)
}
p.fanOutExpeditionDM(e, body, nil)
// N1/A6 — the end-of-day digest is the primary mid-day event anchor.
// The anchor is a per-player roll against a per-player daily slot, so
// each member rolls their own; a party does not share one event.
if campDecision.Night {
p.maybeFireAnchoredEvent(uid, advEventChanceDigest)
for _, member := range expeditionAudience(e) {
p.maybeFireAnchoredEvent(member, advEventChanceDigest)
}
}
}

View File

@@ -0,0 +1,93 @@
package plugin
import (
"log/slog"
"maunium.net/go/mautrix/id"
)
// N3/P6a — resolving shared state for a player who owns none of it.
//
// Every ownership lookup in the adventure module keys on a user id:
// getActiveExpedition reads dnd_expedition.user_id, getActiveZoneRun reads
// dnd_zone_run.user_id. A party member owns neither row. The leader's expedition
// is the single source of truth for the clock, the threat track and the supply
// pool, and the run it points at is the party's shared position in the dungeon.
//
// So a member asking either "am I on an expedition" or "where am I standing"
// gets nil from both, and every command built on them quietly tells them they
// are not playing. activeExpeditionFor (P4) answers the first question; this
// file answers the second, and gives the DM seams the audience they never had.
// activeZoneRunFor resolves the zone run a player is walking, whether they own
// the row or ride it as a party member. isLeader reports which — it is true for
// a solo player and a standalone (non-expedition) run alike, since both own
// their run.
//
// For an owner this is exactly getActiveZoneRun, side effects and all. That
// matters: getActiveZoneRun carries the §4.3 idle-timeout reap, which
// force-extracts the wrapping expedition. It must keep firing off the owner's
// own lookup and must never be re-entered on a member's behalf, or a member
// glancing at the map could reap the leader's run out from under them.
func activeZoneRunFor(userID id.UserID) (run *DungeonRun, isLeader bool, err error) {
if r, err := getActiveZoneRun(userID); err != nil || r != nil {
return r, true, err
}
e, isLeader, err := activeExpeditionFor(userID)
switch {
case err != nil || e == nil:
return nil, false, err
case isLeader:
// getActiveZoneRun already answered for this expedition's owner: the run
// is unspawned, reaped, or finished. Don't go looking behind its back.
return nil, true, nil
case e.RunID == "":
// The leader hasn't taken the first step yet; the run is created lazily.
return nil, false, nil
}
r, err := getZoneRun(e.RunID)
if err != nil || r == nil || !r.IsActive() {
return nil, false, err
}
return r, false, nil
}
// expeditionAudience is every player an expedition-scoped DM must reach: the
// owner alone for a solo run, the whole roster for a party.
//
// partyMemberIDs collapses an empty roster to the owner, so a solo expedition
// resolves to exactly the one user it always DM'd. A roster read that fails
// degrades to the owner rather than dropping the message — a player who misses
// their briefing because SQLite hiccuped is a worse outcome than a member who
// misses theirs.
func expeditionAudience(e *Expedition) []id.UserID {
if e == nil || e.UserID == "" {
return nil
}
ids, err := partyMemberIDs(e.ID, e.UserID)
if err != nil {
slog.Warn("expedition: party roster read failed, DMing owner only",
"expedition", e.ID, "err", err)
return []id.UserID{id.UserID(e.UserID)}
}
return ids
}
// fanOutExpeditionDM sends one expedition-scoped body to every seated member.
//
// perReader, when non-nil, folds that reader's own character-scoped content into
// the shared body — the morning pet event, say. It runs once per member, in
// roster order, and may mutate that member's character sheet. This is the same
// shape P5's RenderPartyTurnRound settled on for combat narration: the body is
// the party's, the decoration is the reader's.
func (p *AdventurePlugin) fanOutExpeditionDM(e *Expedition, body string, perReader func(id.UserID, string) string) {
for _, uid := range expeditionAudience(e) {
text := body
if perReader != nil {
text = perReader(uid, text)
}
if err := p.SendDM(uid, text); err != nil {
slog.Warn("expedition: fan-out DM failed", "user", uid, "expedition", e.ID, "err", err)
}
}
}

View File

@@ -0,0 +1,294 @@
package plugin
import (
"testing"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// seedZoneRun inserts an active zone run owned by the given user. The columns
// activeZoneRunFor reads past the CREATE TABLE block (current_node, …) all carry
// defaults, so the five below are enough.
func seedZoneRun(t *testing.T, runID string, owner id.UserID) {
t.Helper()
if _, err := db.Get().Exec(`
INSERT INTO dnd_zone_run (run_id, user_id, zone_id, total_rooms, last_action_at)
VALUES (?, ?, 'goblin_warrens', 8, ?)`,
runID, string(owner), time.Now().UTC(),
); err != nil {
t.Fatal(err)
}
}
// A solo expedition must resolve exactly as it did before N3: the owner's own
// run, flagged as theirs. This is the path every existing player is on.
func TestActiveZoneRunFor_SoloOwnsItsRun(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@solo:example.org")
seedExpedition(t, "exp-solo", owner, "active")
seedZoneRun(t, "run-exp-solo", owner)
run, isLeader, err := activeZoneRunFor(owner)
if err != nil {
t.Fatal(err)
}
if run == nil || run.RunID != "run-exp-solo" {
t.Fatalf("run = %v, want run-exp-solo", run)
}
if !isLeader {
t.Error("solo player is not reported as owning their run")
}
}
// The whole point of the seam: a member owns no dnd_zone_run row, and
// getActiveZoneRun therefore tells them they are standing nowhere.
func TestActiveZoneRunFor_MemberRidesTheLeadersRun(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@lead:example.org")
member := id.UserID("@member:example.org")
seedExpedition(t, "exp-1", leader, "active")
seedZoneRun(t, "run-exp-1", leader)
if err := joinParty("exp-1", member); err != nil {
t.Fatal(err)
}
// The old lookup is blind to them — this is the bug being fixed.
if r, err := getActiveZoneRun(member); err != nil || r != nil {
t.Fatalf("getActiveZoneRun(member) = %v, %v; want nil, nil", r, err)
}
run, isLeader, err := activeZoneRunFor(member)
if err != nil {
t.Fatal(err)
}
if run == nil || run.RunID != "run-exp-1" {
t.Fatalf("member resolved run = %v, want run-exp-1", run)
}
if isLeader {
t.Error("member reported as leader")
}
// And the leader still resolves the same run, still as its owner.
run, isLeader, err = activeZoneRunFor(leader)
if err != nil || run == nil || run.RunID != "run-exp-1" || !isLeader {
t.Fatalf("leader resolved (%v, %v, %v), want run-exp-1 as leader", run, isLeader, err)
}
}
// A finished or abandoned run must not resolve for a member either. Without the
// IsActive filter getZoneRun happily hands back a corpse — it fetches by id
// "regardless of completion state".
func TestActiveZoneRunFor_MemberGetsNilForDeadRun(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@lead:example.org")
member := id.UserID("@member:example.org")
seedExpedition(t, "exp-1", leader, "active")
seedZoneRun(t, "run-exp-1", leader)
if err := joinParty("exp-1", member); err != nil {
t.Fatal(err)
}
if _, err := db.Get().Exec(
`UPDATE dnd_zone_run SET abandoned = 1 WHERE run_id = 'run-exp-1'`); err != nil {
t.Fatal(err)
}
run, _, err := activeZoneRunFor(member)
if err != nil {
t.Fatal(err)
}
if run != nil {
t.Errorf("member resolved an abandoned run: %v", run)
}
}
// A member must never trigger the §4.3 idle reap on the leader's behalf. The
// reap lives inside getActiveZoneRun and force-extracts the wrapping expedition;
// a member glancing at the map would otherwise end everyone's run.
func TestActiveZoneRunFor_MemberDoesNotReapAStaleRun(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@lead:example.org")
member := id.UserID("@member:example.org")
seedExpedition(t, "exp-1", leader, "active")
seedZoneRun(t, "run-exp-1", leader)
if err := joinParty("exp-1", member); err != nil {
t.Fatal(err)
}
stale := time.Now().UTC().Add(-2 * zoneRunInactivityTimeout)
if _, err := db.Get().Exec(
`UPDATE dnd_zone_run SET last_action_at = ? WHERE run_id = 'run-exp-1'`, stale); err != nil {
t.Fatal(err)
}
if _, _, err := activeZoneRunFor(member); err != nil {
t.Fatal(err)
}
var status string
if err := db.Get().QueryRow(
`SELECT status FROM dnd_expedition WHERE expedition_id = 'exp-1'`).Scan(&status); err != nil {
t.Fatal(err)
}
if status != "active" {
t.Errorf("member's lookup reaped the leader's expedition: status = %q", status)
}
}
// The run is created lazily on the first walk, so a member who joins before the
// leader steps off gets a clean nil rather than an error.
func TestActiveZoneRunFor_MemberBeforeFirstWalk(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@lead:example.org")
member := id.UserID("@member:example.org")
if _, err := db.Get().Exec(`
INSERT INTO dnd_expedition (expedition_id, user_id, zone_id, run_id, status, start_date)
VALUES ('exp-1', ?, 'goblin_warrens', NULL, 'active', ?)`,
string(leader), time.Now().UTC()); err != nil {
t.Fatal(err)
}
if err := joinParty("exp-1", member); err != nil {
t.Fatal(err)
}
run, isLeader, err := activeZoneRunFor(member)
if err != nil {
t.Fatal(err)
}
if run != nil {
t.Errorf("run = %v, want nil before the first walk", run)
}
if isLeader {
t.Error("member reported as leader")
}
}
// ── the DM audience ──────────────────────────────────────────────────────────
// A solo expedition must resolve to exactly one recipient: the owner. Every
// briefing, recap and digest loops this, and a solo run is every run that has
// ever happened.
func TestExpeditionAudience_SoloIsTheOwnerAlone(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@solo:example.org")
seedExpedition(t, "exp-solo", owner, "active")
e, err := getExpedition("exp-solo")
if err != nil || e == nil {
t.Fatal(err)
}
got := expeditionAudience(e)
if len(got) != 1 || got[0] != owner {
t.Errorf("audience = %v, want [%s]", got, owner)
}
}
func TestExpeditionAudience_PartyIsLeaderFirst(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@lead:example.org")
a := id.UserID("@a:example.org")
b := id.UserID("@b:example.org")
seedExpedition(t, "exp-1", leader, "active")
for _, m := range []id.UserID{a, b} {
if err := joinParty("exp-1", m); err != nil {
t.Fatal(err)
}
}
e, err := getExpedition("exp-1")
if err != nil || e == nil {
t.Fatal(err)
}
got := expeditionAudience(e)
want := []id.UserID{leader, a, b}
if len(got) != len(want) {
t.Fatalf("audience = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("audience = %v, want %v", got, want)
}
}
}
// ── the roster's lifetime ────────────────────────────────────────────────────
// Every terminal transition must free the members: assertNotAdventuring bars a
// seated player from starting anything of their own, so a roster that outlives
// its expedition strands the whole party.
func TestReleaseParty_TerminalTransitionsFreeTheMembers(t *testing.T) {
for _, tc := range []struct {
name string
end func(t *testing.T, expID string, leader id.UserID)
}{
{"complete", func(t *testing.T, expID string, _ id.UserID) {
if err := completeExpedition(expID, ExpeditionStatusComplete); err != nil {
t.Fatal(err)
}
}},
{"failed", func(t *testing.T, expID string, _ id.UserID) {
if err := completeExpedition(expID, ExpeditionStatusFailed); err != nil {
t.Fatal(err)
}
}},
{"abandon", func(t *testing.T, _ string, leader id.UserID) {
if err := abandonExpedition(leader); err != nil {
t.Fatal(err)
}
}},
{"forced-extract", func(t *testing.T, expID string, _ id.UserID) {
if _, _, err := forcedExtractExpedition(expID, "starvation"); err != nil {
t.Fatal(err)
}
}},
} {
t.Run(tc.name, func(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@lead:example.org")
member := id.UserID("@member:example.org")
seedExpedition(t, "exp-1", leader, "active")
if err := joinParty("exp-1", member); err != nil {
t.Fatal(err)
}
tc.end(t, "exp-1", leader)
members, err := partyMembers("exp-1")
if err != nil {
t.Fatal(err)
}
if len(members) != 0 {
t.Fatalf("roster survived %s: %v", tc.name, members)
}
// The freed member can now be seated somewhere else.
seedExpedition(t, "exp-2", id.UserID("@other:example.org"), "active")
if err := joinParty("exp-2", member); err != nil {
t.Errorf("member still stranded after %s: %v", tc.name, err)
}
})
}
}
// Extraction is a seven-day resumable limbo, not a terminal status. The party
// must survive it so `!resume` brings everyone back.
func TestReleaseParty_ExtractingKeepsTheRoster(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@lead:example.org")
member := id.UserID("@member:example.org")
seedExpedition(t, "exp-1", leader, "active")
if err := joinParty("exp-1", member); err != nil {
t.Fatal(err)
}
if _, err := voluntaryExtractExpedition(leader); err != nil {
t.Fatal(err)
}
members, err := partyMembers("exp-1")
if err != nil {
t.Fatal(err)
}
if len(members) != 2 {
t.Fatalf("roster = %d after extraction, want 2 (resumable)", len(members))
}
}