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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user