mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
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:
@@ -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.")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`.")
|
||||
|
||||
@@ -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())
|
||||
|
||||
246
internal/plugin/expedition_party_reads_test.go
Normal file
246
internal/plugin/expedition_party_reads_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user