mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
!expedition invite / accept / decline / party / leave. The invitee buys their own loadout and it pools -- a party is a shared burden, not a free ride. The plan said invites close "before the first walk". That is not a window, it is a race: autoRunMinExpeditionAge leaves a fresh expedition alone for thirty minutes and then the autopilot starts walking it, and the leader's own !expedition run can beat it there. Thirty minutes is not enough to ask a friend who is asleep. So two changes to what the plan specified: - The window is all of Day 1, not the first step. Supplies burn at the night rollover, so a companion who arrives three rooms in pays and receives exactly what one who arrived at the gate does. - An unanswered invite pins the autopilot: loadExpeditionsForAutoRun skips any expedition somebody has been asked to join. The leader must not be dragged into a boss room while their friend reads the DM. Bounded by expeditionInviteTTL (2h) in the query itself, so a forgotten invite costs an afternoon, not the expedition. New table expedition_invite. Absent == nobody was asked, which is true of every expedition predating N3 -- nothing to backfill, same reading that let expedition_party and roster_size ship without one. Details worth keeping: - Outstanding invites count against expeditionPartyMax. Otherwise a leader asks four people and three accept. - Pooling raises Current *and* Max. supplyDepletion reads the ratio, so folding in only Current would read as the party suddenly starving. - A member's supplies stay in the pool when they !leave. They were spent on the expedition, not lent to it; clawing them back would let someone starve the party on their way out. - assertNotAdventuring guards expeditions and rosters but not bare zone runs, so accept checks getActiveZoneRun itself -- startExpedition does. - A party is not a taxi: zoneOpenToLevel gates the invitee on the same tier rule !expedition start applies to the leader. - releaseParty now clears invites too, or someone could accept onto a corpse. - expeditionCmdStatus and the bare `!expedition` switched to activeExpeditionFor, and a member typing `!expedition go 2` is told the leader picks the path instead of falling through to `start` and being told "2" is an unknown zone. Combat still seats one player -- handleFightCmd is P6c. go test ./... green, golden byte-identical.
285 lines
11 KiB
Go
285 lines
11 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)
|
|
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++
|
|
}
|
|
pooled := addSupplyPurchase(exp.Supplies, suppliesPurchase)
|
|
if err := updateSupplies(exp.ID, pooled); 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 {
|
|
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._")
|
|
}
|
|
return p.SendDM(ctx.Sender, b.String())
|
|
}
|
|
|
|
// 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 {
|
|
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.")
|
|
}
|
|
if isLeader {
|
|
return p.SendDM(ctx.Sender,
|
|
"You're leading this one — `!extract` ends it for everyone, or `!expedition abandon` to walk away from it.")
|
|
}
|
|
if err := leaveParty(exp.ID, ctx.Sender); err != nil {
|
|
return p.SendDM(ctx.Sender, "Couldn't leave: "+err.Error())
|
|
}
|
|
// 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(ctx.Sender)))
|
|
return p.SendDM(ctx.Sender, "You turn back for town. Your supplies stay with the party.")
|
|
}
|