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