diff --git a/internal/plugin/dnd_expedition.go b/internal/plugin/dnd_expedition.go index b1870d3..42e9345 100644 --- a/internal/plugin/dnd_expedition.go +++ b/internal/plugin/dnd_expedition.go @@ -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, diff --git a/internal/plugin/dnd_expedition_cycle.go b/internal/plugin/dnd_expedition_cycle.go index 1695921..56b0ea7 100644 --- a/internal/plugin/dnd_expedition_cycle.go +++ b/internal/plugin/dnd_expedition_cycle.go @@ -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 { diff --git a/internal/plugin/dnd_expedition_extract.go b/internal/plugin/dnd_expedition_extract.go index c035e5f..3978180 100644 --- a/internal/plugin/dnd_expedition_extract.go +++ b/internal/plugin/dnd_expedition_extract.go @@ -90,6 +90,7 @@ func forcedExtractExpedition(expID, reason string) (*Expedition, int, error) { } e.Status = ExpeditionStatusAbandoned _ = retireAllRegionRuns(e) + releaseParty(e.ID) return e, tax, nil } diff --git a/internal/plugin/expedition_autorun.go b/internal/plugin/expedition_autorun.go index 8451f10..706153b 100644 --- a/internal/plugin/expedition_autorun.go +++ b/internal/plugin/expedition_autorun.go @@ -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) + } } } diff --git a/internal/plugin/expedition_party_resolve.go b/internal/plugin/expedition_party_resolve.go new file mode 100644 index 0000000..83fc7d6 --- /dev/null +++ b/internal/plugin/expedition_party_resolve.go @@ -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) + } + } +} diff --git a/internal/plugin/expedition_party_resolve_test.go b/internal/plugin/expedition_party_resolve_test.go new file mode 100644 index 0000000..deb1203 --- /dev/null +++ b/internal/plugin/expedition_party_resolve_test.go @@ -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)) + } +}