From a063e0ccd08e76a38cd0885aad9ad4d914cea007 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:56:16 -0700 Subject: [PATCH] N3/P6d: a party where every member can see the dungeon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/plugin/dnd_economy.go | 5 +- internal/plugin/dnd_expedition_camp.go | 8 +- internal/plugin/dnd_expedition_cmd.go | 55 +++- internal/plugin/dnd_expedition_extract.go | 7 +- internal/plugin/dnd_expedition_harvest.go | 12 +- internal/plugin/dnd_expedition_map.go | 4 +- internal/plugin/dnd_expedition_region_cmd.go | 7 +- internal/plugin/dnd_expedition_threat.go | 2 +- internal/plugin/dnd_zone_cmd.go | 62 ++++- internal/plugin/dnd_zone_cmd_graph.go | 2 +- internal/plugin/dnd_zone_narration.go | 13 +- .../plugin/expedition_party_reads_test.go | 246 ++++++++++++++++++ internal/plugin/expedition_party_resolve.go | 54 ++++ internal/plugin/zone_revisit.go | 11 +- 14 files changed, 448 insertions(+), 40 deletions(-) create mode 100644 internal/plugin/expedition_party_reads_test.go diff --git a/internal/plugin/dnd_economy.go b/internal/plugin/dnd_economy.go index fdc7845..64cb924 100644 --- a/internal/plugin/dnd_economy.go +++ b/internal/plugin/dnd_economy.go @@ -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.") } diff --git a/internal/plugin/dnd_expedition_camp.go b/internal/plugin/dnd_expedition_camp.go index e309a09..23e3972 100644 --- a/internal/plugin/dnd_expedition_camp.go +++ b/internal/plugin/dnd_expedition_camp.go @@ -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) } diff --git a/internal/plugin/dnd_expedition_cmd.go b/internal/plugin/dnd_expedition_cmd.go index 3a5439c..1035c58 100644 --- a/internal/plugin/dnd_expedition_cmd.go +++ b/internal/plugin/dnd_expedition_cmd.go @@ -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) diff --git a/internal/plugin/dnd_expedition_extract.go b/internal/plugin/dnd_expedition_extract.go index bb8b250..76beacc 100644 --- a/internal/plugin/dnd_expedition_extract.go +++ b/internal/plugin/dnd_expedition_extract.go @@ -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)) diff --git a/internal/plugin/dnd_expedition_harvest.go b/internal/plugin/dnd_expedition_harvest.go index b9b8b44..74f5df1 100644 --- a/internal/plugin/dnd_expedition_harvest.go +++ b/internal/plugin/dnd_expedition_harvest.go @@ -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 diff --git a/internal/plugin/dnd_expedition_map.go b/internal/plugin/dnd_expedition_map.go index 68777cf..9dfb5e5 100644 --- a/internal/plugin/dnd_expedition_map.go +++ b/internal/plugin/dnd_expedition_map.go @@ -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") diff --git a/internal/plugin/dnd_expedition_region_cmd.go b/internal/plugin/dnd_expedition_region_cmd.go index 456b063..fe67b76 100644 --- a/internal/plugin/dnd_expedition_region_cmd.go +++ b/internal/plugin/dnd_expedition_region_cmd.go @@ -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()) diff --git a/internal/plugin/dnd_expedition_threat.go b/internal/plugin/dnd_expedition_threat.go index 9d557c8..bf97766 100644 --- a/internal/plugin/dnd_expedition_threat.go +++ b/internal/plugin/dnd_expedition_threat.go @@ -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()) } diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index 869d6e3..b8458b5 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -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 ` — 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 `.") } - _ = 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 `.") } - _ = 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 { diff --git a/internal/plugin/dnd_zone_cmd_graph.go b/internal/plugin/dnd_zone_cmd_graph.go index dc97414..20ee53e 100644 --- a/internal/plugin/dnd_zone_cmd_graph.go +++ b/internal/plugin/dnd_zone_cmd_graph.go @@ -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`.") diff --git a/internal/plugin/dnd_zone_narration.go b/internal/plugin/dnd_zone_narration.go index 8602a85..cc2009b 100644 --- a/internal/plugin/dnd_zone_narration.go +++ b/internal/plugin/dnd_zone_narration.go @@ -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()) diff --git a/internal/plugin/expedition_party_reads_test.go b/internal/plugin/expedition_party_reads_test.go new file mode 100644 index 0000000..c7461d6 --- /dev/null +++ b/internal/plugin/expedition_party_reads_test.go @@ -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) + } +} diff --git a/internal/plugin/expedition_party_resolve.go b/internal/plugin/expedition_party_resolve.go index 83fc7d6..8f7ee4f 100644 --- a/internal/plugin/expedition_party_resolve.go +++ b/internal/plugin/expedition_party_resolve.go @@ -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. // diff --git a/internal/plugin/zone_revisit.go b/internal/plugin/zone_revisit.go index 51f5f58..607ad83 100644 --- a/internal/plugin/zone_revisit.go +++ b/internal/plugin/zone_revisit.go @@ -76,16 +76,23 @@ func revisitZoneRun(runID, targetNode string, visited []string) (int, error) { // `!zone revisit `). 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 `.") } - 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