mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user