mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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.
148 lines
6.3 KiB
Go
148 lines
6.3 KiB
Go
package plugin
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"log/slog"
|
|
|
|
"gogobee/internal/db"
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// N3/P6a — resolving shared state for a player who owns none of it.
|
|
//
|
|
// Every ownership lookup in the adventure module keys on a user id:
|
|
// getActiveExpedition reads dnd_expedition.user_id, getActiveZoneRun reads
|
|
// dnd_zone_run.user_id. A party member owns neither row. The leader's expedition
|
|
// is the single source of truth for the clock, the threat track and the supply
|
|
// pool, and the run it points at is the party's shared position in the dungeon.
|
|
//
|
|
// So a member asking either "am I on an expedition" or "where am I standing"
|
|
// gets nil from both, and every command built on them quietly tells them they
|
|
// are not playing. activeExpeditionFor (P4) answers the first question; this
|
|
// file answers the second, and gives the DM seams the audience they never had.
|
|
|
|
// activeZoneRunFor resolves the zone run a player is walking, whether they own
|
|
// the row or ride it as a party member. isLeader reports which — it is true for
|
|
// a solo player and a standalone (non-expedition) run alike, since both own
|
|
// their run.
|
|
//
|
|
// For an owner this is exactly getActiveZoneRun, side effects and all. That
|
|
// matters: getActiveZoneRun carries the §4.3 idle-timeout reap, which
|
|
// force-extracts the wrapping expedition. It must keep firing off the owner's
|
|
// own lookup and must never be re-entered on a member's behalf, or a member
|
|
// glancing at the map could reap the leader's run out from under them.
|
|
func activeZoneRunFor(userID id.UserID) (run *DungeonRun, isLeader bool, err error) {
|
|
if r, err := getActiveZoneRun(userID); err != nil || r != nil {
|
|
return r, true, err
|
|
}
|
|
e, isLeader, err := activeExpeditionFor(userID)
|
|
switch {
|
|
case err != nil || e == nil:
|
|
return nil, false, err
|
|
case isLeader:
|
|
// getActiveZoneRun already answered for this expedition's owner: the run
|
|
// is unspawned, reaped, or finished. Don't go looking behind its back.
|
|
return nil, true, nil
|
|
case e.RunID == "":
|
|
// The leader hasn't taken the first step yet; the run is created lazily.
|
|
return nil, false, nil
|
|
}
|
|
r, err := getZoneRun(e.RunID)
|
|
if err != nil || r == nil || !r.IsActive() {
|
|
return nil, false, 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.
|
|
//
|
|
// partyMemberIDs collapses an empty roster to the owner, so a solo expedition
|
|
// resolves to exactly the one user it always DM'd. A roster read that fails
|
|
// degrades to the owner rather than dropping the message — a player who misses
|
|
// their briefing because SQLite hiccuped is a worse outcome than a member who
|
|
// misses theirs.
|
|
func expeditionAudience(e *Expedition) []id.UserID {
|
|
if e == nil || e.UserID == "" {
|
|
return nil
|
|
}
|
|
ids, err := partyMemberIDs(e.ID, e.UserID)
|
|
if err != nil {
|
|
slog.Warn("expedition: party roster read failed, DMing owner only",
|
|
"expedition", e.ID, "err", err)
|
|
return []id.UserID{id.UserID(e.UserID)}
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// fanOutExpeditionDM sends one expedition-scoped body to every seated member.
|
|
//
|
|
// perReader, when non-nil, folds that reader's own character-scoped content into
|
|
// the shared body — the morning pet event, say. It runs once per member, in
|
|
// roster order, and may mutate that member's character sheet. This is the same
|
|
// shape P5's RenderPartyTurnRound settled on for combat narration: the body is
|
|
// the party's, the decoration is the reader's.
|
|
func (p *AdventurePlugin) fanOutExpeditionDM(e *Expedition, body string, perReader func(id.UserID, string) string) {
|
|
for _, uid := range expeditionAudience(e) {
|
|
text := body
|
|
if perReader != nil {
|
|
text = perReader(uid, text)
|
|
}
|
|
if err := p.SendDM(uid, text); err != nil {
|
|
slog.Warn("expedition: fan-out DM failed", "user", uid, "expedition", e.ID, "err", err)
|
|
}
|
|
}
|
|
}
|