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 no row: not the expedition, not the zone run. P4 gave them activeExpeditionFor; this gives them activeZoneRunFor, and gives the DM seams the audience they never had. - activeZoneRunFor(user) -> (run, isLeader, err). An owner's lookup is exactly getActiveZoneRun, side effects and all -- in particular the §4.3 idle reap, which force-extracts the wrapping expedition. A member must never re-enter it, or glancing at the map would end the leader's run. Pinned. - expeditionAudience / fanOutExpeditionDM. Briefing, recap and digest all DM'd id.UserID(e.UserID) alone. They now loop the roster, which partyMemberIDs collapses to exactly the owner when there is none -- so a solo expedition sends the same bytes to the same user it always has. The briefing's body is expedition-scoped but its pet prefix is not: each member has their own pet and their own sheet, so the roll rides a per-reader decorator (the shape P5 settled on for combat narration). The digest's A6 event anchor rolls per member for the same reason. - releaseParty on every terminal transition. A seated member is barred from adventuring elsewhere, so a roster outliving its expedition strands the party. Deliberately NOT on 'extracting': that is a 7-day resumable limbo and !resume must bring everyone back. The roster clears when the window lapses to 'failed', which routes through completeExpedition like the rest. Rosters are still empty in production -- nothing seats a member yet -- so every loop here has exactly one element and the whole change is a no-op until P6b. Golden byte-identical, go test ./... green.
94 lines
3.8 KiB
Go
94 lines
3.8 KiB
Go
package plugin
|
|
|
|
import (
|
|
"log/slog"
|
|
|
|
"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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|