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.
This commit is contained in:
prosolis
2026-07-09 23:56:16 -07:00
parent b333d05443
commit a063e0ccd0
14 changed files with 448 additions and 40 deletions

View File

@@ -1,8 +1,11 @@
package plugin
import (
"database/sql"
"errors"
"log/slog"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
@@ -52,6 +55,57 @@ func activeZoneRunFor(userID id.UserID) (run *DungeonRun, isLeader bool, err 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.
//