Files
gogobee/internal/plugin/expedition_party_resolve.go
prosolis a063e0ccd0 N3/P6d: a party where every member can see the dungeon
Every ownership lookup in the adventure module keys on a user id, and a
party member owns neither the expedition row nor the zone run. So each
player-facing read quietly told them they were not playing.

Rewire them. Reads a member should see resolve through activeExpeditionFor
/ activeZoneRunFor. Leader-only actions answer with copy that names the
leader instead of denying the expedition. Three busy-guards had to start
refusing a member outright: !zone enter, !expedition start and !sell all
keyed on the sender's own row, so a seated member could open a private
dungeon, outfit a rival expedition, or run a shop from the boss room.

Four things the rewire itself exposed:

!resources looks like a read but seed-persists harvest nodes, and
saveHarvestNodes rewrites the entire region_state blob — kills, event
gates, temporal stack — last-write-wins. Reaching it as a member would
revert the leader's walk from a stale snapshot. Persist only for the owner;
seedRoomNodes is pure, so a member re-derives the same nodes.

!zone taunt moves the party's shared mood, which is intended and safe:
applyMoodEvent lands an atomic delta. Its neighbour applyMoodDecayIfStale
writes an absolute gm_mood from the caller's snapshot, and every command
takes the *sender's* lock — a member running it against the leader's run
holds the wrong mutex. The owner check now lives on that function.

A seat outlives status='active'. releaseParty deliberately skips the
seven-day 'extracting' limbo, so the roster persists while
activeExpeditionFor goes blind — long enough for a member to open a run
that wins every lookup once the leader !resumes. seatedExpeditionFor spans
both statuses; it is what the busy-guards ask.

!expedition run was still member-blind. It is the same walk as !zone
advance, reached by its other name.

isPartyMember replaces `run != nil && !isLeader`: activeZoneRunFor reports
isLeader=false for a player with no run anywhere, so the bare test sends a
solo player to go ask their leader.

Golden byte-identical; solo T1 expedition clears end-to-end.
2026-07-09 23:56:16 -07:00

148 lines
6.3 KiB
Go

package plugin
import (
"database/sql"
"errors"
"log/slog"
"gogobee/internal/db"
"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
}
// N3/P6d — the two seams the read-rewire needed.
// msgLeaderPicksPath is the refusal a member gets from every command that would
// move the party through the dungeon graph — `!zone go`, `!revisit`. One string,
// because they are one decision from the player's side.
const msgLeaderPicksPath = "Your party leader picks the path. `!map` to see where you're standing."
// isPartyMember reports whether this player rides someone else's *active*
// expedition. It is the question the leader-only copy gates actually ask, and
// it asks it without touching getActiveZoneRun — whose §4.3 idle reap must
// never fire on a member's behalf (see activeZoneRunFor).
//
// Prefer this over `run != nil && !isLeader`: activeZoneRunFor reports
// isLeader=false for a player with no run *anywhere*, so a bare !isLeader test
// tells a solo player with nothing in flight to go ask their leader.
func isPartyMember(userID id.UserID) bool {
e, isLeader, err := activeExpeditionFor(userID)
return err == nil && e != nil && !isLeader
}
// seatedExpeditionFor answers "is this player committed to somebody else's
// expedition right now" for the guards that refuse a second adventure.
//
// It deliberately spans `extracting` as well as `active`, which
// activeExpeditionFor does not. `extracting` is a seven-day resumable limbo:
// releaseParty is *not* called on it, so the roster survives and `!resume`
// brings the party back. A member is still seated for that whole window, and a
// guard that only sees 'active' would let them open a private run which then
// wins every activeZoneRunFor lookup once the leader resumes.
func seatedExpeditionFor(userID id.UserID) (*Expedition, error) {
row := db.Get().QueryRow(`
SELECT e.expedition_id, e.user_id, e.zone_id, e.run_id, e.status,
e.start_date, e.current_day, e.current_region, e.boss_defeated,
e.supplies_json, e.camp_json, e.threat_level, e.threat_siege,
e.threat_events, e.temporal_stack, e.region_state,
e.xp_earned, e.coins_earned, e.gm_mood,
e.last_briefing_at, e.last_recap_at, e.last_ambient_kind,
e.last_activity, e.completed_at
FROM dnd_expedition e
JOIN expedition_party p ON p.expedition_id = e.expedition_id
WHERE p.user_id = ? AND p.role <> 'leader'
AND e.status IN ('active', 'extracting')
ORDER BY e.start_date DESC
LIMIT 1`, string(userID))
e, err := scanExpedition(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return e, err
}
// 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)
}
}
}