mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 00:52: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)
|
args = strings.TrimSpace(args)
|
||||||
lower := strings.ToLower(args)
|
lower := strings.ToLower(args)
|
||||||
|
|
||||||
// Post-expedition gate.
|
// Post-expedition gate. A seated member is mid-expedition too — reading
|
||||||
exp, err := getActiveExpedition(ctx.Sender)
|
// only their own row would let them run a shop from inside the dungeon.
|
||||||
|
exp, _, err := activeExpeditionFor(ctx.Sender)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't check expedition status.")
|
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.")
|
"No Adv 2.0 character yet — run `!setup` first.")
|
||||||
}
|
}
|
||||||
|
|
||||||
exp, err := getActiveExpedition(ctx.Sender)
|
exp, isLeader, err := activeExpeditionFor(ctx.Sender)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
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 == "" {
|
if requested == "" {
|
||||||
return p.SendDM(ctx.Sender, campHelpText(exp))
|
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" {
|
if requested == "break" || requested == "down" || requested == "leave" {
|
||||||
return p.campBreak(ctx, exp)
|
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))
|
i+1, z.Display, int(z.Tier), z.LevelMin, z.LevelMax, z.ID, suffix))
|
||||||
b.WriteString(fmt.Sprintf(" %s\n", z.Atmosphere))
|
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)
|
zone := zoneOrFallback(exp.ZoneID)
|
||||||
b.WriteString(fmt.Sprintf("\n_⚠ Active expedition: **%s**, day %d. Use `!expedition status` or `!expedition abandon`._",
|
tail := "Use `!expedition status` or `!expedition abandon`."
|
||||||
zone.Display, exp.CurrentDay))
|
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())
|
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 {
|
if err := purchase.Validate(zoneForCaps.Tier); err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Invalid pack selection: "+err.Error())
|
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())
|
cost := float64(purchase.Cost())
|
||||||
if p.euro == nil {
|
if p.euro == nil {
|
||||||
return p.SendDM(ctx.Sender, "Coin system unavailable — try again later.")
|
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**.",
|
"Not enough coins. Outfitting costs **%d** but you have **%.0f**.",
|
||||||
int(cost), balance))
|
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 {
|
if existing, _ := getActiveZoneRun(ctx.Sender); existing != nil {
|
||||||
zone, _ := getZone(existing.ZoneID)
|
zone, _ := getZone(existing.ZoneID)
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||||
@@ -553,7 +570,7 @@ func depletionLabel(s SupplyDepletionState) string {
|
|||||||
// ── log ─────────────────────────────────────────────────────────────────────
|
// ── log ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (p *AdventurePlugin) expeditionCmdLog(ctx MessageContext) error {
|
func (p *AdventurePlugin) expeditionCmdLog(ctx MessageContext) error {
|
||||||
exp, err := getActiveExpedition(ctx.Sender)
|
exp, _, err := activeExpeditionFor(ctx.Sender)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
||||||
}
|
}
|
||||||
@@ -589,13 +606,18 @@ func formatLogTimestamp(t time.Time) string {
|
|||||||
// ── abandon ─────────────────────────────────────────────────────────────────
|
// ── abandon ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error {
|
func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error {
|
||||||
exp, err := getActiveExpedition(ctx.Sender)
|
exp, isLeader, err := activeExpeditionFor(ctx.Sender)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
||||||
}
|
}
|
||||||
if exp == nil {
|
if exp == nil {
|
||||||
return p.SendDM(ctx.Sender, "No active expedition to abandon.")
|
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)
|
zone, _ := getZone(exp.ZoneID)
|
||||||
if err := abandonExpedition(ctx.Sender); err != nil {
|
if err := abandonExpedition(ctx.Sender); err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't abandon: "+err.Error())
|
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
|
// combat already auto-resolves inside resolveCombatRoom; elite/boss
|
||||||
// doorways stop here so the player can choose !fight on their own terms.
|
// doorways stop here so the player can choose !fight on their own terms.
|
||||||
func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
|
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)
|
r := p.runAutopilotWalk(ctx, autopilotRoomCap, false, false)
|
||||||
if r.initErr != "" {
|
if r.initErr != "" {
|
||||||
return p.SendDM(ctx.Sender, 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.")
|
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)
|
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(
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||||
"You already have an active expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.",
|
"You already have an active expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.",
|
||||||
zone.Display, existing.CurrentDay))
|
zone.Display, existing.CurrentDay))
|
||||||
|
|||||||
@@ -492,7 +492,7 @@ func zoneTierFromID(zoneID ZoneID) int {
|
|||||||
|
|
||||||
// handleResourcesCmd lists active nodes in the current room.
|
// handleResourcesCmd lists active nodes in the current room.
|
||||||
func (p *AdventurePlugin) handleResourcesCmd(ctx MessageContext) error {
|
func (p *AdventurePlugin) handleResourcesCmd(ctx MessageContext) error {
|
||||||
exp, err := getActiveExpedition(ctx.Sender)
|
exp, isLeader, err := activeExpeditionFor(ctx.Sender)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't load expedition state.")
|
return p.SendDM(ctx.Sender, "Couldn't load expedition state.")
|
||||||
}
|
}
|
||||||
@@ -512,7 +512,15 @@ func (p *AdventurePlugin) handleResourcesCmd(ctx MessageContext) error {
|
|||||||
}
|
}
|
||||||
nodeID := harvestNodeIDFor(run)
|
nodeID := harvestNodeIDFor(run)
|
||||||
nodes := loadHarvestNodes(exp, nodeID)
|
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)
|
zone, _ := getZone(exp.ZoneID)
|
||||||
regionLabel := exp.CurrentRegion
|
regionLabel := exp.CurrentRegion
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ func renderRegionLegend(e *Expedition) string {
|
|||||||
// ── !map command ────────────────────────────────────────────────────────────
|
// ── !map command ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (p *AdventurePlugin) handleExpeditionMapCmd(ctx MessageContext, _ string) error {
|
func (p *AdventurePlugin) handleExpeditionMapCmd(ctx MessageContext, _ string) error {
|
||||||
exp, err := getActiveExpedition(ctx.Sender)
|
exp, _, err := activeExpeditionFor(ctx.Sender)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
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
|
// Per-run room map. The expedition's zone run is the per-region
|
||||||
// dungeon instance; for single-region zones it's the only run.
|
// dungeon instance; for single-region zones it's the only run.
|
||||||
run, _ := getActiveZoneRun(ctx.Sender)
|
run, _, _ := activeZoneRunFor(ctx.Sender)
|
||||||
if run != nil {
|
if run != nil {
|
||||||
if g, ok := loadZoneGraph(run.ZoneID); ok {
|
if g, ok := loadZoneGraph(run.ZoneID); ok {
|
||||||
b.WriteString("```\n")
|
b.WriteString("```\n")
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func (p *AdventurePlugin) handleRegionCmd(ctx MessageContext, args string) error
|
|||||||
userMu.Lock()
|
userMu.Lock()
|
||||||
defer userMu.Unlock()
|
defer userMu.Unlock()
|
||||||
|
|
||||||
exp, err := getActiveExpedition(ctx.Sender)
|
exp, isLeader, err := activeExpeditionFor(ctx.Sender)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
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":
|
case "", "list", "ls", "status":
|
||||||
return p.SendDM(ctx.Sender, renderRegionList(exp))
|
return p.SendDM(ctx.Sender, renderRegionList(exp))
|
||||||
case "travel", "advance", "go", "next":
|
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)
|
return p.regionCmdTravel(ctx, exp)
|
||||||
case "help", "?":
|
case "help", "?":
|
||||||
return p.SendDM(ctx.Sender, regionHelpText())
|
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,
|
// handleThreatCmd surfaces the current threat clock state — level, band,
|
||||||
// per-band combat effects, and the last few ThreatEvent log entries.
|
// per-band combat effects, and the last few ThreatEvent log entries.
|
||||||
func (p *AdventurePlugin) handleThreatCmd(ctx MessageContext) error {
|
func (p *AdventurePlugin) handleThreatCmd(ctx MessageContext) error {
|
||||||
exp, err := getActiveExpedition(ctx.Sender)
|
exp, _, err := activeExpeditionFor(ctx.Sender)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
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))
|
i+1, z.Display, int(z.Tier), z.LevelMin, z.LevelMax, z.ID))
|
||||||
b.WriteString(fmt.Sprintf(" %s\n", z.Atmosphere))
|
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)
|
zone := zoneOrFallback(active.ZoneID)
|
||||||
b.WriteString(fmt.Sprintf("\n_⚠ Active run: **%s**, room %d/%d. Use `!zone status` or `!zone abandon`._",
|
tail := "Use `!zone status` or `!zone abandon`."
|
||||||
zone.Display, active.CurrentRoom+1, active.TotalRooms))
|
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())
|
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 {
|
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`.")
|
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 == "" {
|
if rest == "" {
|
||||||
return p.SendDM(ctx.Sender,
|
return p.SendDM(ctx.Sender,
|
||||||
"`!zone enter <id|#>` — pick from `!zone list`. Example: `!zone enter goblin_warrens` or `!zone enter 1`.")
|
"`!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 ──────────────────────────────────────────────────────────────────
|
// ── status ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (p *AdventurePlugin) zoneCmdStatus(ctx MessageContext) error {
|
func (p *AdventurePlugin) zoneCmdStatus(ctx MessageContext) error {
|
||||||
run, err := getActiveZoneRun(ctx.Sender)
|
run, isLeader, err := activeZoneRunFor(ctx.Sender)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error())
|
return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error())
|
||||||
}
|
}
|
||||||
if run == nil {
|
if run == nil {
|
||||||
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone list` then `!zone enter <id>`.")
|
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)
|
zone := zoneOrFallback(run.ZoneID)
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(fmt.Sprintf("**%s** — room %d/%d (%s)\n",
|
b.WriteString(fmt.Sprintf("**%s** — room %d/%d (%s)\n",
|
||||||
@@ -297,7 +311,7 @@ func prettyRoomType(rt RoomType) string {
|
|||||||
// ── map ─────────────────────────────────────────────────────────────────────
|
// ── map ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (p *AdventurePlugin) zoneCmdMap(ctx MessageContext) error {
|
func (p *AdventurePlugin) zoneCmdMap(ctx MessageContext) error {
|
||||||
run, err := getActiveZoneRun(ctx.Sender)
|
run, isLeader, err := activeZoneRunFor(ctx.Sender)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error())
|
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 != "" {
|
if path := renderVisitedPath(g, run); path != "" {
|
||||||
b.WriteString("\n**Path:** " + 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))
|
nums := make([]string, len(targets))
|
||||||
for i, n := range targets {
|
for i, n := range targets {
|
||||||
nums[i] = fmt.Sprintf("`!revisit %d`", n)
|
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
|
// aborts the run with a mood penalty and player-death flavor; boss win
|
||||||
// drops the zone Loot table.
|
// drops the zone Loot table.
|
||||||
func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
|
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)
|
res, err := p.advanceOnce(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, err.Error())
|
return p.SendDM(ctx.Sender, err.Error())
|
||||||
@@ -508,7 +535,9 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact, inlin
|
|||||||
reason: stopBlocked,
|
reason: stopBlocked,
|
||||||
}, nil
|
}, 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)
|
zone := zoneOrFallback(run.ZoneID)
|
||||||
// A pending fork means advanceTransitionGraph already cleared the
|
// A pending fork means advanceTransitionGraph already cleared the
|
||||||
// current room and stopped — re-running resolveRoom would re-fire
|
// 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,
|
render func(runID string, roomIdx int) string,
|
||||||
icon string,
|
icon string,
|
||||||
) error {
|
) 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 {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error())
|
return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error())
|
||||||
}
|
}
|
||||||
if run == nil {
|
if run == nil {
|
||||||
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.")
|
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.")
|
||||||
}
|
}
|
||||||
_ = applyMoodDecayIfStale(run)
|
_ = applyMoodDecayIfStale(run, isLeader)
|
||||||
newMood, err := applyMoodEvent(run.RunID, ev)
|
newMood, err := applyMoodEvent(run.RunID, ev)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't apply mood event: "+err.Error())
|
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
|
// repeated `!zone lore` in the same room returns the same prose, but
|
||||||
// cross-room calls vary.
|
// cross-room calls vary.
|
||||||
func (p *AdventurePlugin) zoneCmdLore(ctx MessageContext) error {
|
func (p *AdventurePlugin) zoneCmdLore(ctx MessageContext) error {
|
||||||
run, err := getActiveZoneRun(ctx.Sender)
|
run, _, err := activeZoneRunFor(ctx.Sender)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error())
|
return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error())
|
||||||
}
|
}
|
||||||
@@ -1300,6 +1334,10 @@ func (p *AdventurePlugin) zoneCmdLore(ctx MessageContext) error {
|
|||||||
// ── abandon ─────────────────────────────────────────────────────────────────
|
// ── abandon ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (p *AdventurePlugin) zoneCmdAbandon(ctx MessageContext) error {
|
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)
|
run, _ := getActiveZoneRun(ctx.Sender)
|
||||||
if err := abandonZoneRun(ctx.Sender); err != nil {
|
if err := abandonZoneRun(ctx.Sender); err != nil {
|
||||||
if err == ErrNoActiveRun {
|
if err == ErrNoActiveRun {
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error {
|
|||||||
}
|
}
|
||||||
if !isLeader {
|
if !isLeader {
|
||||||
// The fork is one choice for one party, and the run is the leader's row.
|
// 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 {
|
if cs, _ := activeCombatSessionFor(ctx.Sender); cs != nil {
|
||||||
return p.SendDM(ctx.Sender, "⚔️ Finish your fight first — `!attack` or `!flee`.")
|
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
|
// applyMoodDecayIfStale persists a passive-decay correction when the
|
||||||
// run has been idle long enough to drift. Cheap no-op on fresh runs.
|
// 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
|
return nil
|
||||||
}
|
}
|
||||||
newMood := passiveDecayMood(r.DMMood, r.LastActionAt, time.Now().UTC())
|
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
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,6 +55,57 @@ func activeZoneRunFor(userID id.UserID) (run *DungeonRun, isLeader bool, err err
|
|||||||
return r, false, nil
|
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
|
// expeditionAudience is every player an expedition-scoped DM must reach: the
|
||||||
// owner alone for a solo run, the whole roster for a party.
|
// 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
|
// `!zone revisit <N>`). N is the 1-indexed room number from `!map`'s Path
|
||||||
// strip.
|
// strip.
|
||||||
func (p *AdventurePlugin) handleRevisitCmd(ctx MessageContext, rest string) error {
|
func (p *AdventurePlugin) handleRevisitCmd(ctx MessageContext, rest string) error {
|
||||||
run, err := getActiveZoneRun(ctx.Sender)
|
run, isLeader, err := activeZoneRunFor(ctx.Sender)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error())
|
return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error())
|
||||||
}
|
}
|
||||||
if run == nil {
|
if run == nil {
|
||||||
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.")
|
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`.")
|
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
|
// 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
|
// `!zone advance` and `!zone go` resolve it without checking where that
|
||||||
// is. Walking away would leave a prompt pointing at a room the player
|
// is. Walking away would leave a prompt pointing at a room the player
|
||||||
|
|||||||
Reference in New Issue
Block a user