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

@@ -213,8 +213,9 @@ func (p *AdventurePlugin) handleResourceSellCmd(ctx MessageContext, args string)
args = strings.TrimSpace(args)
lower := strings.ToLower(args)
// Post-expedition gate.
exp, err := getActiveExpedition(ctx.Sender)
// Post-expedition gate. A seated member is mid-expedition too — reading
// only their own row would let them run a shop from inside the dungeon.
exp, _, err := activeExpeditionFor(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't check expedition status.")
}

View File

@@ -59,7 +59,7 @@ func (p *AdventurePlugin) handleCampCmd(ctx MessageContext, args string) error {
"No Adv 2.0 character yet — run `!setup` first.")
}
exp, err := getActiveExpedition(ctx.Sender)
exp, isLeader, err := activeExpeditionFor(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
}
@@ -73,6 +73,12 @@ func (p *AdventurePlugin) handleCampCmd(ctx MessageContext, args string) error {
if requested == "" {
return p.SendDM(ctx.Sender, campHelpText(exp))
}
// Bare `!camp` reads the party's tent; pitching and striking it are the
// leader's, since the camp is a property of the expedition everyone rides.
if !isLeader {
return p.SendDM(ctx.Sender,
"Your party leader pitches the camp. `!camp` on its own shows what's already up.")
}
if requested == "break" || requested == "down" || requested == "leave" {
return p.campBreak(ctx, exp)
}

View File

@@ -154,10 +154,14 @@ func (p *AdventurePlugin) expeditionCmdList(ctx MessageContext, c *DnDCharacter)
i+1, z.Display, int(z.Tier), z.LevelMin, z.LevelMax, z.ID, suffix))
b.WriteString(fmt.Sprintf(" %s\n", z.Atmosphere))
}
if exp, _ := getActiveExpedition(ctx.Sender); exp != nil {
if exp, isLeader, _ := activeExpeditionFor(ctx.Sender); exp != nil {
zone := zoneOrFallback(exp.ZoneID)
b.WriteString(fmt.Sprintf("\n_⚠ Active expedition: **%s**, day %d. Use `!expedition status` or `!expedition abandon`._",
zone.Display, exp.CurrentDay))
tail := "Use `!expedition status` or `!expedition abandon`."
if !isLeader {
tail = "Use `!expedition status` or `!expedition leave`."
}
b.WriteString(fmt.Sprintf("\n_⚠ Active expedition: **%s**, day %d. %s_",
zone.Display, exp.CurrentDay, tail))
}
return p.SendDM(ctx.Sender, b.String())
}
@@ -298,6 +302,27 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
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))
}
cost := float64(purchase.Cost())
if p.euro == nil {
return p.SendDM(ctx.Sender, "Coin system unavailable — try again later.")
@@ -307,14 +332,6 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
"Not enough coins. Outfitting costs **%d** but you have **%.0f**.",
int(cost), balance))
}
// Reject if any expedition or zone run already active.
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))
}
if existing, _ := getActiveZoneRun(ctx.Sender); existing != nil {
zone, _ := getZone(existing.ZoneID)
return p.SendDM(ctx.Sender, fmt.Sprintf(
@@ -553,7 +570,7 @@ func depletionLabel(s SupplyDepletionState) string {
// ── log ─────────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) expeditionCmdLog(ctx MessageContext) error {
exp, err := getActiveExpedition(ctx.Sender)
exp, _, err := activeExpeditionFor(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
}
@@ -589,13 +606,18 @@ func formatLogTimestamp(t time.Time) string {
// ── abandon ─────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error {
exp, err := getActiveExpedition(ctx.Sender)
exp, isLeader, err := activeExpeditionFor(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
}
if exp == nil {
return p.SendDM(ctx.Sender, "No active expedition to abandon.")
}
if !isLeader {
// Abandoning throws away everyone's day. A member leaves alone.
return p.SendDM(ctx.Sender,
"Only your party leader can abandon the expedition. `!expedition leave` to walk out alone.")
}
zone, _ := getZone(exp.ZoneID)
if err := abandonExpedition(ctx.Sender); err != nil {
return p.SendDM(ctx.Sender, "Couldn't abandon: "+err.Error())
@@ -667,6 +689,13 @@ type autopilotWalkResult struct {
// combat already auto-resolves inside resolveCombatRoom; elite/boss
// doorways stop here so the player can choose !fight on their own terms.
func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
// runAutopilotWalk resolves through getActiveExpedition, so a member would
// be told they are not on an expedition at all. Same refusal `!zone advance`
// gives — this is the same walk, reached by its other name.
if isPartyMember(ctx.Sender) {
return p.SendDM(ctx.Sender,
"Your party leader sets the pace. `!map` to see where you're standing.")
}
r := p.runAutopilotWalk(ctx, autopilotRoomCap, false, false)
if r.initErr != "" {
return p.SendDM(ctx.Sender, r.initErr)

View File

@@ -304,8 +304,13 @@ func (p *AdventurePlugin) handleResumeCmd(ctx MessageContext, args string) error
return p.SendDM(ctx.Sender, "No Adv 2.0 character yet — run `!setup` first.")
}
if existing, _ := getActiveExpedition(ctx.Sender); existing != nil {
if existing, isLeader, _ := activeExpeditionFor(ctx.Sender); existing != nil {
zone, _ := getZone(existing.ZoneID)
if !isLeader {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"You're riding a party expedition in **%s** (Day %d). Only its leader can `!resume`.",
zone.Display, existing.CurrentDay))
}
return p.SendDM(ctx.Sender, fmt.Sprintf(
"You already have an active expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.",
zone.Display, existing.CurrentDay))

View File

@@ -492,7 +492,7 @@ func zoneTierFromID(zoneID ZoneID) int {
// handleResourcesCmd lists active nodes in the current room.
func (p *AdventurePlugin) handleResourcesCmd(ctx MessageContext) error {
exp, err := getActiveExpedition(ctx.Sender)
exp, isLeader, err := activeExpeditionFor(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't load expedition state.")
}
@@ -512,7 +512,15 @@ func (p *AdventurePlugin) handleResourcesCmd(ctx MessageContext) error {
}
nodeID := harvestNodeIDFor(run)
nodes := loadHarvestNodes(exp, nodeID)
_ = saveHarvestNodes(exp, nodeID, nodes) // persist seed if first touch
// Seed-persist only for the owner. saveHarvestNodes rewrites the whole
// region_state blob (persistRegionState is a last-write-wins UPDATE), and a
// member holds neither the expedition row nor the leader's lock — their
// `!resources` would clobber whatever the leader's walk wrote since the
// snapshot this command read. loadHarvestNodes re-derives the same nodes
// from the seed, so a member simply reads without persisting.
if isLeader {
_ = saveHarvestNodes(exp, nodeID, nodes) // persist seed if first touch
}
zone, _ := getZone(exp.ZoneID)
regionLabel := exp.CurrentRegion

View File

@@ -89,7 +89,7 @@ func renderRegionLegend(e *Expedition) string {
// ── !map command ────────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleExpeditionMapCmd(ctx MessageContext, _ string) error {
exp, err := getActiveExpedition(ctx.Sender)
exp, _, err := activeExpeditionFor(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
}
@@ -112,7 +112,7 @@ func (p *AdventurePlugin) handleExpeditionMapCmd(ctx MessageContext, _ string) e
// Per-run room map. The expedition's zone run is the per-region
// dungeon instance; for single-region zones it's the only run.
run, _ := getActiveZoneRun(ctx.Sender)
run, _, _ := activeZoneRunFor(ctx.Sender)
if run != nil {
if g, ok := loadZoneGraph(run.ZoneID); ok {
b.WriteString("```\n")

View File

@@ -33,7 +33,7 @@ func (p *AdventurePlugin) handleRegionCmd(ctx MessageContext, args string) error
userMu.Lock()
defer userMu.Unlock()
exp, err := getActiveExpedition(ctx.Sender)
exp, isLeader, err := activeExpeditionFor(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
}
@@ -52,6 +52,11 @@ func (p *AdventurePlugin) handleRegionCmd(ctx MessageContext, args string) error
case "", "list", "ls", "status":
return p.SendDM(ctx.Sender, renderRegionList(exp))
case "travel", "advance", "go", "next":
if !isLeader {
// Transit burns the shared supply pool and moves everyone.
return p.SendDM(ctx.Sender,
"Your party leader calls the march. `!region` to see where you are.")
}
return p.regionCmdTravel(ctx, exp)
case "help", "?":
return p.SendDM(ctx.Sender, regionHelpText())

View File

@@ -191,7 +191,7 @@ func appendThreatTransitionLog(e *Expedition, band ThreatBand) error {
// handleThreatCmd surfaces the current threat clock state — level, band,
// per-band combat effects, and the last few ThreatEvent log entries.
func (p *AdventurePlugin) handleThreatCmd(ctx MessageContext) error {
exp, err := getActiveExpedition(ctx.Sender)
exp, _, err := activeExpeditionFor(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
}

View File

@@ -118,10 +118,14 @@ func (p *AdventurePlugin) zoneCmdList(ctx MessageContext, c *DnDCharacter) error
i+1, z.Display, int(z.Tier), z.LevelMin, z.LevelMax, z.ID))
b.WriteString(fmt.Sprintf(" %s\n", z.Atmosphere))
}
if active, _ := getActiveZoneRun(ctx.Sender); active != nil {
if active, isLeader, _ := activeZoneRunFor(ctx.Sender); active != nil {
zone := zoneOrFallback(active.ZoneID)
b.WriteString(fmt.Sprintf("\n_⚠ Active run: **%s**, room %d/%d. Use `!zone status` or `!zone abandon`._",
zone.Display, active.CurrentRoom+1, active.TotalRooms))
tail := "Use `!zone status` or `!zone abandon`."
if !isLeader {
tail = "Use `!zone status`; your leader ends it."
}
b.WriteString(fmt.Sprintf("\n_⚠ Active run: **%s**, room %d/%d. %s_",
zone.Display, active.CurrentRoom+1, active.TotalRooms, tail))
}
return p.SendDM(ctx.Sender, b.String())
}
@@ -174,9 +178,19 @@ func atoiSafe(s string) int {
}
func (p *AdventurePlugin) zoneCmdEnter(ctx MessageContext, c *DnDCharacter, rest string) error {
if cs, _ := getActiveCombatSession(ctx.Sender); cs != nil {
if cs, _ := activeCombatSessionFor(ctx.Sender); cs != nil {
return p.SendDM(ctx.Sender, "⚔️ Finish your fight first — `!attack` or `!flee`.")
}
// startZoneRun only refuses a player who owns an active run. A seated party
// member owns neither run nor expedition row, so without this they could
// open a private dungeon while riding the leader's — and activeZoneRunFor
// would then hand every party read their solo run instead of the party's.
// seatedExpeditionFor, not activeExpeditionFor: the seat outlives an
// `extracting` expedition, and so must the refusal.
if seated, _ := seatedExpeditionFor(ctx.Sender); seated != nil {
return p.SendDM(ctx.Sender,
"You're already in your party's dungeon. `!expedition leave` to strike out on your own.")
}
if rest == "" {
return p.SendDM(ctx.Sender,
"`!zone enter <id|#>` — pick from `!zone list`. Example: `!zone enter goblin_warrens` or `!zone enter 1`.")
@@ -238,14 +252,14 @@ func (p *AdventurePlugin) zoneCmdEnter(ctx MessageContext, c *DnDCharacter, rest
// ── status ──────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) zoneCmdStatus(ctx MessageContext) error {
run, err := getActiveZoneRun(ctx.Sender)
run, isLeader, err := activeZoneRunFor(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error())
}
if run == nil {
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone list` then `!zone enter <id>`.")
}
_ = applyMoodDecayIfStale(run)
_ = applyMoodDecayIfStale(run, isLeader)
zone := zoneOrFallback(run.ZoneID)
var b strings.Builder
b.WriteString(fmt.Sprintf("**%s** — room %d/%d (%s)\n",
@@ -297,7 +311,7 @@ func prettyRoomType(rt RoomType) string {
// ── map ─────────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) zoneCmdMap(ctx MessageContext) error {
run, err := getActiveZoneRun(ctx.Sender)
run, isLeader, err := activeZoneRunFor(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error())
}
@@ -314,7 +328,10 @@ func (p *AdventurePlugin) zoneCmdMap(ctx MessageContext) error {
if path := renderVisitedPath(g, run); path != "" {
b.WriteString("\n**Path:** " + path)
}
if targets := revisitTargets(g, run); len(targets) > 0 {
// A member reads the same map but can't walk it — `!revisit` is the
// leader's call, like the fork. Offering them the numbers would only
// earn a refusal.
if targets := revisitTargets(g, run); len(targets) > 0 && isLeader {
nums := make([]string, len(targets))
for i, n := range targets {
nums[i] = fmt.Sprintf("`!revisit %d`", n)
@@ -464,6 +481,16 @@ type advanceResult struct {
// aborts the run with a mood penalty and player-death flavor; boss win
// drops the zone Loot table.
func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
// The party walks on the leader's row. advanceOnce would tell a member they
// have no run at all — right outcome, wrong story. Ask membership directly
// rather than `run != nil && !isLeader`: that spelling misses the window
// where the leader has an expedition but has not taken the first step (no
// run row yet), and it would re-resolve the run advanceOnce is about to
// resolve anyway, firing the §4.3 idle reap twice per keystroke.
if isPartyMember(ctx.Sender) {
return p.SendDM(ctx.Sender,
"Your party leader sets the pace. `!map` to see where you're standing.")
}
res, err := p.advanceOnce(ctx)
if err != nil {
return p.SendDM(ctx.Sender, err.Error())
@@ -508,7 +535,9 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact, inlin
reason: stopBlocked,
}, nil
}
_ = applyMoodDecayIfStale(run)
// getActiveZoneRun above resolves the sender's own row, so the walker is
// always this run's owner.
_ = applyMoodDecayIfStale(run, true)
zone := zoneOrFallback(run.ZoneID)
// A pending fork means advanceTransitionGraph already cleared the
// current room and stopped — re-running resolveRoom would re-fire
@@ -1245,14 +1274,19 @@ func (p *AdventurePlugin) zoneMoodInteraction(
render func(runID string, roomIdx int) string,
icon string,
) error {
run, err := getActiveZoneRun(ctx.Sender)
// Mood is a property of the run, not of the player who typed at TwinBee — so
// a member's taunt or compliment moves the party's gauge. That is only safe
// because applyMoodEvent lands an atomic `gm_mood + delta`; the decay write
// it sits next to is absolute, and applyMoodDecayIfStale refuses it for a
// non-owner for exactly that reason.
run, isLeader, err := activeZoneRunFor(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error())
}
if run == nil {
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.")
}
_ = applyMoodDecayIfStale(run)
_ = applyMoodDecayIfStale(run, isLeader)
newMood, err := applyMoodEvent(run.RunID, ev)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't apply mood event: "+err.Error())
@@ -1282,7 +1316,7 @@ func (p *AdventurePlugin) zoneMoodInteraction(
// repeated `!zone lore` in the same room returns the same prose, but
// cross-room calls vary.
func (p *AdventurePlugin) zoneCmdLore(ctx MessageContext) error {
run, err := getActiveZoneRun(ctx.Sender)
run, _, err := activeZoneRunFor(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error())
}
@@ -1300,6 +1334,10 @@ func (p *AdventurePlugin) zoneCmdLore(ctx MessageContext) error {
// ── abandon ─────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) zoneCmdAbandon(ctx MessageContext) error {
if isPartyMember(ctx.Sender) {
return p.SendDM(ctx.Sender,
"Only your party leader can call off the run. `!expedition leave` to walk out alone.")
}
run, _ := getActiveZoneRun(ctx.Sender)
if err := abandonZoneRun(ctx.Sender); err != nil {
if err == ErrNoActiveRun {

View File

@@ -163,7 +163,7 @@ func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error {
}
if !isLeader {
// The fork is one choice for one party, and the run is the leader's row.
return p.SendDM(ctx.Sender, "Your party leader picks the path. `!map` to see where you're standing.")
return p.SendDM(ctx.Sender, msgLeaderPicksPath)
}
if cs, _ := activeCombatSessionFor(ctx.Sender); cs != nil {
return p.SendDM(ctx.Sender, "⚔️ Finish your fight first — `!attack` or `!flee`.")

View File

@@ -561,8 +561,17 @@ func dmMoodCombatTilt(mood int) DMMoodCombatTilt {
// applyMoodDecayIfStale persists a passive-decay correction when the
// run has been idle long enough to drift. Cheap no-op on fresh runs.
func applyMoodDecayIfStale(r *DungeonRun) error {
if r == nil {
//
// N3/P6d: owner-only, and the `owner` argument is not a convenience — the
// UPDATE below writes gm_mood as an *absolute* value derived from the snapshot
// the caller read, while adjustGMMood writes an atomic `gm_mood + delta`. Every
// command in the module takes the *sender's* advUserLock, so a party member
// running this against the leader's run holds the wrong mutex and would drop
// whatever the leader's concurrent walk banked between the read and this write.
// Decay is time-based maintenance: skipping it on a member's read loses nothing,
// because the owner's next command applies exactly the same correction.
func applyMoodDecayIfStale(r *DungeonRun, owner bool) error {
if r == nil || !owner {
return nil
}
newMood := passiveDecayMood(r.DMMood, r.LastActionAt, time.Now().UTC())

View File

@@ -0,0 +1,246 @@
package plugin
import (
"testing"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// N3/P6d — the player-facing `getActive*` reads, rewired.
//
// SendDM is a no-op without a Matrix client, so these tests can only observe the
// state a command path left behind. That covers the cases that carry state:
//
// - reads that were blind to a member and let them adventure twice
// (`!zone enter`, `!expedition start`, `!sell`), including across the
// `extracting` limbo where the seat outlives the expedition's 'active' status;
// - reads that write, and so must not write on a member's behalf
// (`!resources` seed-persists the leader's whole region_state blob;
// applyMoodDecayIfStale writes an absolute gm_mood);
// - the one read whose rewire deliberately does move shared state — `!zone
// taunt` swings the party's DM mood, safe only because that write is an
// atomic delta.
//
// The remaining rewires are copy-only — a member used to be told they had no
// expedition, and is now told whose it is — and both spellings leave the database
// exactly as they found it, so there is nothing here to assert. Their seam,
// activeExpeditionFor / activeZoneRunFor / isPartyMember, is pinned in
// expedition_party_resolve_test.go.
// seatedMember stands up a leader with an active expedition and zone run, plus
// a seated member holding a real character sheet. Returns both user ids.
func seatedMember(t *testing.T, tag string) (leader, member id.UserID) {
t.Helper()
leader = id.UserID("@lead-" + tag + ":example.org")
member = id.UserID("@member-" + tag + ":example.org")
seedExpedition(t, "exp-"+tag, leader, "active")
seedZoneRun(t, "run-exp-"+tag, leader)
if err := joinParty("exp-"+tag, member); err != nil {
t.Fatal(err)
}
zoneCmdTestCharacter(t, leader, 1)
zoneCmdTestCharacter(t, member, 1)
return leader, member
}
// startZoneRun only refuses a player who owns an active run, and a member owns
// none — so before P6d a member could `!zone enter` a private dungeon while
// riding the leader's. That run would then win every activeZoneRunFor lookup
// they made, quietly cutting them out of the party's state.
func TestZoneCmdEnter_SeatedMemberCannotOpenTheirOwnRun(t *testing.T) {
setupEmptyTestDB(t)
_, member := seatedMember(t, "enter")
p := &AdventurePlugin{}
if err := p.handleDnDZoneCmd(MessageContext{Sender: member}, "enter goblin_warrens"); err != nil {
t.Fatalf("enter: %v", err)
}
if run, err := getActiveZoneRun(member); err != nil || run != nil {
t.Fatalf("member opened their own run %v (err %v); they are seated in a party", run, err)
}
}
// The same hole at the expedition seam: getActiveExpedition is blind to a
// member, so the "already on expedition" guard waved them through.
func TestExpeditionCmdStart_SeatedMemberIsRefused(t *testing.T) {
setupEmptyTestDB(t)
_, member := seatedMember(t, "start")
// Fund them: a member refused for want of coins would pass this test for
// the wrong reason.
euro := &EuroPlugin{}
euro.ensureBalance(member)
euro.Credit(member, 500, "party reads test")
p := &AdventurePlugin{euro: euro}
c, err := LoadDnDCharacter(member)
if err != nil {
t.Fatal(err)
}
if err := p.expeditionCmdStart(MessageContext{Sender: member}, c, "goblin_warrens 1s"); err != nil {
t.Fatalf("start: %v", err)
}
if own, err := getActiveExpedition(member); err != nil || own != nil {
t.Fatalf("member started their own expedition %v (err %v) while seated in a party", own, err)
}
}
// A member is mid-expedition even though they own no expedition row, so Thom
// Krooke must turn them away like anyone else standing in a dungeon.
func TestSellCmd_SeatedMemberIsRefusedMidExpedition(t *testing.T) {
setupEmptyTestDB(t)
_, member := seatedMember(t, "sell")
if err := addAdvInventoryItem(member, AdvItem{
Name: "goblin ear", Type: "material", Tier: 1, Value: 5,
}); err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
if err := p.handleResourceSellCmd(MessageContext{Sender: member}, "all"); err != nil {
t.Fatalf("sell: %v", err)
}
items, err := loadAdvInventory(member)
if err != nil {
t.Fatal(err)
}
if len(items) != 1 {
t.Fatalf("inventory holds %d items, want the 1 seeded; a seated member sold mid-expedition", len(items))
}
}
// `!resources` looks like a pure read, but it seed-persists harvest nodes — and
// saveHarvestNodes rewrites the leader's whole region_state blob (a last-write-
// wins UPDATE). A member holds only their own lock, so their snapshot must never
// be written back over the leader's walk. loadHarvestNodes re-derives the same
// nodes from seedRoomNodes, so the member loses nothing by not persisting.
func TestResourcesCmd_MemberDoesNotRewriteTheLeadersRegionState(t *testing.T) {
setupEmptyTestDB(t)
leader, member := seatedMember(t, "res")
// A sentinel only the leader's walk could have written. If the member's
// !resources persists its snapshot, this is clobbered by the harvest table.
sentinel := `{"party_only_marker":"survive"}`
if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET region_state = ? WHERE user_id = ?`,
sentinel, string(leader),
); err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
if err := p.handleResourcesCmd(MessageContext{Sender: member}); err != nil {
t.Fatalf("resources: %v", err)
}
var got string
if err := db.Get().QueryRow(
`SELECT region_state FROM dnd_expedition WHERE user_id = ?`, string(leader),
).Scan(&got); err != nil {
t.Fatal(err)
}
if got != sentinel {
t.Errorf("member's !resources rewrote the leader's region_state:\n got %s\nwant %s", got, sentinel)
}
}
// applyMoodDecayIfStale writes gm_mood as an absolute value derived from the
// caller's snapshot, so it must refuse a non-owner: a member running it against
// the leader's run holds the wrong lock and would drop a concurrent update.
func TestApplyMoodDecayIfStale_RefusesANonOwner(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@decay:example.org")
seedZoneRun(t, "run-decay", owner)
run, err := getActiveZoneRun(owner)
if err != nil || run == nil {
t.Fatalf("run: %v, %v", run, err)
}
// Drag the run far enough into the past that passive decay has something
// to correct, then hand it to a non-owner.
run.LastActionAt = time.Now().UTC().Add(-72 * time.Hour)
run.DMMood = 90
if err := applyMoodDecayIfStale(run, false); err != nil {
t.Fatal(err)
}
if run.DMMood != 90 {
t.Errorf("non-owner decay mutated the snapshot: mood = %d, want 90", run.DMMood)
}
fresh, err := getZoneRun("run-decay")
if err != nil || fresh == nil {
t.Fatalf("reload: %v, %v", fresh, err)
}
if fresh.DMMood != 50 {
t.Errorf("non-owner decay wrote gm_mood = %d; the row should be untouched at its 50 default", fresh.DMMood)
}
// The owner still gets the correction — the guard narrows who writes, not what.
if err := applyMoodDecayIfStale(run, true); err != nil {
t.Fatal(err)
}
if run.DMMood == 90 {
t.Error("owner decay did not apply; the guard should only exclude non-owners")
}
}
// `extracting` is a seven-day resumable limbo: releaseParty is deliberately NOT
// called, so the roster survives and `!resume` brings the party back. A member is
// still seated for that whole window, and a guard keyed on status='active' would
// wave them into a private run that then wins every activeZoneRunFor lookup once
// the leader resumes.
func TestSeatedGuards_HoldThroughTheExtractingWindow(t *testing.T) {
setupEmptyTestDB(t)
leader, member := seatedMember(t, "extract")
if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET status = 'extracting' WHERE user_id = ?`, string(leader),
); err != nil {
t.Fatal(err)
}
// activeExpeditionFor goes blind here — that is the trap seatedExpeditionFor exists for.
if exp, _, err := activeExpeditionFor(member); err != nil || exp != nil {
t.Fatalf("activeExpeditionFor during extracting = %v, %v; want nil (status filter)", exp, err)
}
seated, err := seatedExpeditionFor(member)
if err != nil || seated == nil {
t.Fatalf("seatedExpeditionFor during extracting = %v, %v; the seat outlives the status", seated, err)
}
p := &AdventurePlugin{}
if err := p.handleDnDZoneCmd(MessageContext{Sender: member}, "enter goblin_warrens"); err != nil {
t.Fatalf("enter: %v", err)
}
if run, err := getActiveZoneRun(member); err != nil || run != nil {
t.Fatalf("member opened a private run %v (err %v) while seated on an extracting party", run, err)
}
}
// Mood belongs to the run, not to whoever typed at TwinBee. A member's taunt
// moves the gauge the whole party is walking under — before P6d it moved
// nothing, because the member resolved to no run at all.
func TestZoneMoodInteraction_MemberMovesThePartyMood(t *testing.T) {
setupEmptyTestDB(t)
leader, member := seatedMember(t, "mood")
before, err := getActiveZoneRun(leader)
if err != nil || before == nil {
t.Fatalf("leader's run: %v, %v", before, err)
}
p := &AdventurePlugin{}
if err := p.handleDnDZoneCmd(MessageContext{Sender: member}, "taunt"); err != nil {
t.Fatalf("taunt: %v", err)
}
after, err := getActiveZoneRun(leader)
if err != nil || after == nil {
t.Fatalf("leader's run after taunt: %v, %v", after, err)
}
if after.DMMood >= before.DMMood {
t.Errorf("DM mood %d → %d; a member's taunt should have annoyed TwinBee",
before.DMMood, after.DMMood)
}
}

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.
//

View File

@@ -76,16 +76,23 @@ func revisitZoneRun(runID, targetNode string, visited []string) (int, error) {
// `!zone revisit <N>`). N is the 1-indexed room number from `!map`'s Path
// strip.
func (p *AdventurePlugin) handleRevisitCmd(ctx MessageContext, rest string) error {
run, err := getActiveZoneRun(ctx.Sender)
run, isLeader, err := activeZoneRunFor(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error())
}
if run == nil {
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.")
}
if cs, _ := getActiveCombatSession(ctx.Sender); cs != nil {
// The fight outranks the path: a member swinging at a monster should hear
// about the monster, not about who picks the route.
if cs, _ := activeCombatSessionFor(ctx.Sender); cs != nil {
return p.SendDM(ctx.Sender, "⚔️ Finish the fight first — `!attack` or `!flee`.")
}
if !isLeader {
// Backtracking moves the whole party and costs the whole party a point
// of threat. Same call as the fork: the leader's.
return p.SendDM(ctx.Sender, msgLeaderPicksPath)
}
// A pending fork lives at the node the player is standing on, and both
// `!zone advance` and `!zone go` resolve it without checking where that
// is. Walking away would leave a prompt pointing at a room the player