mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 10:51:09 +00:00
The web's own refusals named commands it had no button for: "!expedition abandon first", "!expedition leave to walk out alone", "no refund if you cancel early". Three verbs, no arguments, no money. Each Matrix handler becomes framing over a headless twin the order path shares, so the web runs the game's real path: an abandon still disbands the roster, still retires the region runs, still DMs the party, still rolls the emergence pet. The lock asymmetry is per verb and is the thing to be careful about. performExpeditionAbandon and performExpeditionLeave cannot take advUserLock — their Matrix caller holds it across the whole !expedition switch — so their web wrappers do. performBabysitCancel takes it itself, because its caller does not, so its wrapper must not. Both directions are pinned by tests that hang rather than fail on regression. Also flips the two W5a extract tests off setupZoneRunTestDB: it copies data/gogobee.db and skips when it is missing, so they have been green-by-skipping since they were written.
357 lines
14 KiB
Go
357 lines
14 KiB
Go
package plugin
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// N3/P6b — the player-facing party surface.
|
|
//
|
|
// !expedition invite @user (leader, Day 1)
|
|
// !expedition accept [packs] (invitee — buys their own loadout)
|
|
// !expedition decline
|
|
// !expedition party (roster + pool)
|
|
// !expedition leave (member; the leader ends it with !extract)
|
|
//
|
|
// The invitee pays for their own supplies, which is what makes a party fair
|
|
// rather than a free ride: the pool is the sum of what everyone carried in.
|
|
|
|
// zoneOpenToLevel reports whether a player at this level may enter the zone. The
|
|
// leader's expedition is no way around the tier gate — a party is not a taxi
|
|
// into content two tiers above your head.
|
|
func zoneOpenToLevel(zoneID ZoneID, level int) bool {
|
|
for _, z := range zonesForLevel(level) {
|
|
if z.ID == zoneID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// expeditionCmdInvite asks a player to join the sender's expedition.
|
|
func (p *AdventurePlugin) expeditionCmdInvite(ctx MessageContext, rest string) error {
|
|
target := strings.TrimSpace(rest)
|
|
if target == "" {
|
|
return p.SendDM(ctx.Sender, "`!expedition invite @user` — bring someone along. Day 1 only.")
|
|
}
|
|
|
|
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. `!expedition start <zone>` first.")
|
|
}
|
|
if !isLeader {
|
|
return p.SendDM(ctx.Sender, "Only the party leader can invite. You're along for the ride.")
|
|
}
|
|
if !inviteWindowOpen(exp) {
|
|
return p.SendDM(ctx.Sender,
|
|
"This expedition has already set off — companions join on Day 1 only.")
|
|
}
|
|
|
|
invitee, ok := p.ResolveUser(target, ctx.RoomID)
|
|
if !ok {
|
|
return p.SendDM(ctx.Sender, "Couldn't find that player.")
|
|
}
|
|
if invitee == ctx.Sender {
|
|
return p.SendDM(ctx.Sender, "You're already here.")
|
|
}
|
|
if c, cerr := LoadDnDCharacter(invitee); cerr != nil || c == nil || c.PendingSetup {
|
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
"**%s** hasn't made a character yet — they need `!setup` first.", p.DisplayName(invitee)))
|
|
}
|
|
|
|
zone := zoneOrFallback(exp.ZoneID)
|
|
if err := inviteToParty(exp.ID, invitee, ctx.Sender); err != nil {
|
|
return p.SendDM(ctx.Sender, inviteRefusalText(p.DisplayName(invitee), err))
|
|
}
|
|
|
|
if derr := p.SendDM(invitee, fmt.Sprintf(
|
|
"🤝 **%s** wants you on an expedition into **%s**.\n\n"+
|
|
"You'll buy your own supplies and they go into the party's pool. Loot and XP are yours alone — nothing is split.\n\n"+
|
|
"`!expedition accept` to pick a supply pack, or `!expedition decline`.\n"+
|
|
"_The offer stands for %s._",
|
|
p.DisplayName(ctx.Sender), zone.Display, formatRespecDuration(expeditionInviteTTL))); derr != nil {
|
|
// The invite row is committed; the DM is not. Tell the leader rather
|
|
// than rolling back — the invitee can still `!expedition accept`.
|
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
"Invited **%s**, but couldn't DM them (%v). They can still `!expedition accept`.",
|
|
p.DisplayName(invitee), derr))
|
|
}
|
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
"🤝 Asked **%s** to join you in **%s**. I'll hold the expedition here until they answer.",
|
|
p.DisplayName(invitee), zone.Display))
|
|
}
|
|
|
|
// inviteRefusalText turns the invite layer's sentinel errors into player-facing
|
|
// copy. Anything unrecognised surfaces raw — a silent "couldn't invite" would
|
|
// hide a real bug.
|
|
func inviteRefusalText(name string, err error) string {
|
|
switch {
|
|
case errors.Is(err, ErrPartyFull):
|
|
return fmt.Sprintf("Your party is full (%d, counting invites you haven't heard back on).", expeditionPartyMax)
|
|
case errors.Is(err, ErrAlreadyInParty):
|
|
return fmt.Sprintf("**%s** is already with you.", name)
|
|
case errors.Is(err, ErrAlreadyInvited):
|
|
return fmt.Sprintf("You've already asked **%s** — still waiting on them.", name)
|
|
case errors.Is(err, ErrPlayerBusyElsewhere):
|
|
return fmt.Sprintf("**%s** is already on an expedition of their own.", name)
|
|
default:
|
|
return "Couldn't send the invite: " + err.Error()
|
|
}
|
|
}
|
|
|
|
// expeditionCmdAccept seats the sender on the expedition they were most recently
|
|
// asked to join, once they have bought their own supplies.
|
|
func (p *AdventurePlugin) expeditionCmdAccept(ctx MessageContext, c *DnDCharacter, rest string) error {
|
|
if remaining := restingLockoutRemaining(c); remaining > 0 {
|
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
"🛌 You're still resting — %s remaining. Pack up after.", formatRespecDuration(remaining)))
|
|
}
|
|
inv, err := latestInviteFor(ctx.Sender)
|
|
if errors.Is(err, ErrInviteNotFound) {
|
|
return p.SendDM(ctx.Sender, "Nobody has invited you anywhere.")
|
|
}
|
|
if err != nil {
|
|
return p.SendDM(ctx.Sender, "Couldn't read your invites: "+err.Error())
|
|
}
|
|
|
|
exp, err := getExpedition(inv.ExpeditionID)
|
|
if err != nil {
|
|
return p.SendDM(ctx.Sender, "Couldn't read that expedition: "+err.Error())
|
|
}
|
|
if exp == nil || !inviteWindowOpen(exp) {
|
|
_ = clearInvite(inv.ExpeditionID, ctx.Sender)
|
|
return p.SendDM(ctx.Sender, "That expedition has already set off without you.")
|
|
}
|
|
// A standalone zone run isn't caught by assertNotAdventuring — it guards
|
|
// expeditions and rosters, not bare runs. startExpedition rejects one; so
|
|
// must this.
|
|
if run, _ := getActiveZoneRun(ctx.Sender); run != nil {
|
|
return p.SendDM(ctx.Sender,
|
|
"You have an active zone run. Finish or `!zone abandon` before joining a party.")
|
|
}
|
|
|
|
zone := zoneOrFallback(exp.ZoneID)
|
|
// Tier 6 zones are excluded from zonesForLevel by design, so zoneOpenToLevel
|
|
// would refuse every seat; they carry their own post-game unlock instead.
|
|
// Non-postgame zones keep the plain level gate.
|
|
if isPostgameZone(exp.ZoneID) {
|
|
// A party can't smuggle an ungated member into a Tier 6 zone: every seat
|
|
// must have cleared the post-game unlock on their own.
|
|
if ok, reason := postgameUnlocked(ctx.Sender, c.Level); !ok {
|
|
return p.SendDM(ctx.Sender, reason)
|
|
}
|
|
} else if !zoneOpenToLevel(exp.ZoneID, c.Level) {
|
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
"**%s** is beyond you for now — come back at a higher level.", zone.Display))
|
|
}
|
|
|
|
packTok := strings.TrimSpace(rest)
|
|
if packTok == "" {
|
|
return p.SendDM(ctx.Sender, renderLoadoutPrompt(zone, "expedition accept"))
|
|
}
|
|
purchase, err := resolveLoadoutOrParse(packTok, zone.Tier)
|
|
if err != nil {
|
|
return p.SendDM(ctx.Sender, "Couldn't parse supply packs: "+err.Error())
|
|
}
|
|
if err := purchase.Validate(zone.Tier); err != nil {
|
|
return p.SendDM(ctx.Sender, "Invalid pack selection: "+err.Error())
|
|
}
|
|
if p.euro == nil {
|
|
return p.SendDM(ctx.Sender, "Coin system unavailable — try again later.")
|
|
}
|
|
cost := float64(purchase.Cost())
|
|
if balance := p.euro.GetBalance(ctx.Sender); balance < cost {
|
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
"Not enough coins. Outfitting costs **%d** but you have **%.0f**.", int(cost), balance))
|
|
}
|
|
|
|
if !p.euro.Debit(ctx.Sender, cost, "expedition outfitting: "+string(exp.ZoneID)) {
|
|
return p.SendDM(ctx.Sender, "Couldn't debit outfitting cost (try again).")
|
|
}
|
|
if err := joinParty(exp.ID, ctx.Sender); err != nil {
|
|
p.euro.Credit(ctx.Sender, cost, "expedition outfitting refund")
|
|
return p.SendDM(ctx.Sender, inviteRefusalText(p.DisplayName(ctx.Sender), err))
|
|
}
|
|
_ = clearInvite(exp.ID, ctx.Sender)
|
|
|
|
// Fold their packs into the pool. Do this after the seat is committed: a
|
|
// member whose supplies landed but whose seat did not would have paid to
|
|
// feed a party they aren't in.
|
|
suppliesPurchase := purchase
|
|
if isHol, _ := isHolidayToday(); isHol {
|
|
suppliesPurchase.StandardPacks++
|
|
}
|
|
// `exp` was fetched before the coin debit, and a second invitee accepting —
|
|
// or the leader's day-burn tick — may have rewritten the row since. Folding
|
|
// onto that stale snapshot would silently drop their packs or resurrect
|
|
// spent SU, so the fold happens against a fresh read under the pool's lock.
|
|
pooled, err := p.withExpeditionSupplies(exp.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
|
|
return addSupplyPurchase(fresh.Supplies, suppliesPurchase), nil
|
|
})
|
|
if err != nil {
|
|
return p.SendDM(ctx.Sender, "You're in, but your supplies didn't reach the pool: "+err.Error())
|
|
}
|
|
|
|
leader := id.UserID(exp.UserID)
|
|
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative",
|
|
fmt.Sprintf("%s joined the party", p.DisplayName(ctx.Sender)), "")
|
|
for _, uid := range expeditionAudience(exp) {
|
|
body := fmt.Sprintf("🤝 **%s** joins the expedition into **%s**. Supplies pooled: **%.1f SU**.",
|
|
p.DisplayName(ctx.Sender), zone.Display, pooled.Current)
|
|
if uid == ctx.Sender {
|
|
body = fmt.Sprintf("🤝 You join **%s** in **%s**. Party supplies: **%.1f SU**.\n\n"+
|
|
"`!expedition party` for the roster. When a fight starts, you'll act on your own turn.",
|
|
p.DisplayName(leader), zone.Display, pooled.Current)
|
|
}
|
|
if derr := p.SendDM(uid, body); derr != nil {
|
|
return derr
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// expeditionCmdDecline drops the sender's most recent invite.
|
|
func (p *AdventurePlugin) expeditionCmdDecline(ctx MessageContext) error {
|
|
inv, err := latestInviteFor(ctx.Sender)
|
|
if errors.Is(err, ErrInviteNotFound) {
|
|
return p.SendDM(ctx.Sender, "Nobody has invited you anywhere.")
|
|
}
|
|
if err != nil {
|
|
return p.SendDM(ctx.Sender, "Couldn't read your invites: "+err.Error())
|
|
}
|
|
if err := clearInvite(inv.ExpeditionID, ctx.Sender); err != nil {
|
|
return p.SendDM(ctx.Sender, "Couldn't decline: "+err.Error())
|
|
}
|
|
leader := id.UserID(inv.InvitedBy)
|
|
_ = p.SendDM(leader, fmt.Sprintf("**%s** won't be joining you. The expedition is yours to walk.",
|
|
p.DisplayName(ctx.Sender)))
|
|
return p.SendDM(ctx.Sender, "Declined. Maybe next time.")
|
|
}
|
|
|
|
// expeditionCmdParty renders the roster, whoever asks for it.
|
|
func (p *AdventurePlugin) expeditionCmdParty(ctx MessageContext) error {
|
|
exp, _, 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.")
|
|
}
|
|
members, err := partyMembers(exp.ID)
|
|
if err != nil {
|
|
return p.SendDM(ctx.Sender, "Couldn't read the roster: "+err.Error())
|
|
}
|
|
zone := zoneOrFallback(exp.ZoneID)
|
|
|
|
var b strings.Builder
|
|
b.WriteString(fmt.Sprintf("🎒 **%s — Day %d**\n\n", zone.Display, exp.CurrentDay))
|
|
if len(members) == 0 {
|
|
b.WriteString(fmt.Sprintf("**%s** — alone.\n", p.DisplayName(id.UserID(exp.UserID))))
|
|
}
|
|
for _, m := range members {
|
|
if isCompanionUser(m.UserID) {
|
|
b.WriteString(companionRosterLine(exp.ID))
|
|
continue
|
|
}
|
|
role := "member"
|
|
if m.IsLeader() {
|
|
role = "leader"
|
|
}
|
|
b.WriteString(fmt.Sprintf("**%s** _(%s)_\n", p.DisplayName(id.UserID(m.UserID)), role))
|
|
}
|
|
if invites, ierr := pendingInvites(exp.ID); ierr == nil {
|
|
for _, inv := range invites {
|
|
b.WriteString(fmt.Sprintf("**%s** _(asked, no answer yet)_\n", p.DisplayName(id.UserID(inv.UserID))))
|
|
}
|
|
}
|
|
b.WriteString(fmt.Sprintf("\nSupplies: **%.1f / %.1f SU**", exp.Supplies.Current, exp.Supplies.Max))
|
|
if inviteWindowOpen(exp) {
|
|
b.WriteString("\n\n_`!expedition invite @user` while it's still Day 1._")
|
|
if !companionSeated(exp.ID) {
|
|
b.WriteString("\n_Short a body? `!expedition hire` brings Pete along._")
|
|
}
|
|
}
|
|
return p.SendDM(ctx.Sender, b.String())
|
|
}
|
|
|
|
// Sentinels for the two ways walking out can be refused, so the web action queue
|
|
// can pick a verdict without reading prose. errLeaveIsLeader is deliberately not
|
|
// errAbandonNotLeader inverted-and-reused: they are opposite facts about the same
|
|
// person and a verdict that conflated them would tell a leader they weren't one.
|
|
var (
|
|
errLeaveNothing = errors.New("expedition leave: no expedition to leave")
|
|
errLeaveIsLeader = errors.New("expedition leave: the leader's row is the expedition")
|
|
)
|
|
|
|
// performExpeditionLeave is `!expedition leave` minus the command framing.
|
|
// Shared with the web action queue, so a member walking out from a phone unseats
|
|
// the same way and the leader is told either way.
|
|
//
|
|
// Like performExpeditionAbandon this does NOT take the per-user lock — its
|
|
// Matrix caller holds it across the whole `!expedition` switch, and applyWebLeave
|
|
// takes it instead. See the comment on performExpeditionAbandon for what getting
|
|
// that backwards costs.
|
|
func (p *AdventurePlugin) performExpeditionLeave(uid id.UserID) error {
|
|
// Resolve the seat the way the guards that trap them do. seatedExpeditionFor
|
|
// spans `extracting`, which activeExpeditionFor does not: a leader who
|
|
// extracts and never resumes would otherwise leave their members seated —
|
|
// refused a new adventure by the guard, and told "no active expedition" by
|
|
// the very command the guard points them at. The exit has to see every state
|
|
// the gate sees. It already excludes leaders, so they fall through below.
|
|
seated, err := seatedExpeditionFor(uid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if seated != nil {
|
|
return p.leaveSeatedParty(uid, seated)
|
|
}
|
|
|
|
exp, isLeader, err := activeExpeditionFor(uid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if exp == nil {
|
|
return refuseAdv(errLeaveNothing, "No active expedition.")
|
|
}
|
|
if isLeader {
|
|
return refuseAdv(errLeaveIsLeader,
|
|
"You're leading this one — `!extract` ends it for everyone, or `!expedition abandon` to walk away from it.")
|
|
}
|
|
return p.leaveSeatedParty(uid, exp)
|
|
}
|
|
|
|
// expeditionCmdLeave walks a member out. The leader cannot leave — their row is
|
|
// the expedition — so they are pointed at `!extract`, which ends it for all.
|
|
func (p *AdventurePlugin) expeditionCmdLeave(ctx MessageContext) error {
|
|
if err := p.performExpeditionLeave(ctx.Sender); err != nil {
|
|
var refusal advRefusal
|
|
if errors.As(err, &refusal) {
|
|
return p.SendDM(ctx.Sender, refusal.Error())
|
|
}
|
|
return p.SendDM(ctx.Sender, "Couldn't leave: "+err.Error())
|
|
}
|
|
return p.SendDM(ctx.Sender, "You turn back for town. Your supplies stay with the party.")
|
|
}
|
|
|
|
// leaveSeatedParty unseats a member and tells the leader. Shared by the two ways
|
|
// a member's seat resolves: the `extracting` limbo and the plain active party.
|
|
// The *member's* own confirmation is the caller's, because that is the one line
|
|
// that differs between a DM and a web verdict.
|
|
func (p *AdventurePlugin) leaveSeatedParty(uid id.UserID, exp *Expedition) error {
|
|
if err := leaveParty(exp.ID, uid); err != nil {
|
|
return err
|
|
}
|
|
// Supplies stay in the pool. They were spent on the expedition, not lent to
|
|
// it, and clawing them back would let a member starve the party on their way
|
|
// out of the door.
|
|
_ = p.SendDM(id.UserID(exp.UserID), fmt.Sprintf(
|
|
"**%s** turned back. Their supplies stay with the party.", p.DisplayName(uid)))
|
|
return nil
|
|
}
|