mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Five bugs found reviewing n1-restoration end to end. beginCombatTurn settles any phase the engine owes before reading whose turn it is. That settle can end the fight — and the old code then answered "you're not in a fight" and returned. The terminal status was already persisted, so nothing ever paid the party out: no XP, no loot, no death recorded, no run teardown. The reaper cannot recover it either, because listExpiredCombatSessions filters on status='active'. Close the fight out there, the way the !fight start path and the reaper already do. A party member was permanently soft-locked when their leader extracted and never resumed. seatedExpeditionFor (the guard) spans 'extracting'; expeditionForMember (what !expedition leave resolved through) saw only 'active'. So the member was refused any new adventure by the guard and told "No active expedition" by the command the guard points them at, with nothing sweeping stale rows and only the leader able to clear one. Resolve the exit through the same lookup as the gate. updateSupplies overwrites supplies_json wholesale, and expeditionCmdAccept folded a member's packs onto a snapshot read before the coin debit, unlocked. Handlers run one goroutine per event, so two invitees accepting genuinely interleave and one member's packs vanish. advUserLock cannot help — it is keyed by sender, so racing members take different mutexes. Add advExpeditionLock and re-read the pool under it. Closes accept-vs-accept; the six other updateSupplies callers still race and are written up separately. runHarvestInterrupt picked an elite enemy and elite narration off a local `elite` flag, then passed a hardcoded false as isElite to closeOutZoneWin. dropZoneLoot gates masterwork on isBoss||isElite, so beating an elite interrupt skipped the masterwork roll and took standard treasure weight — while the same elite fought via !zone paid out correctly. arenaSeasonRollover marked its job complete even when recordArenaSeasonTitle failed, and JobCompleted short-circuits every later run for that quarter, so a transient SQLite BUSY lost the crown forever. Defer completion on failure; the insert is ON CONFLICT DO NOTHING against PRIMARY KEY (season, kind) and a past season's data is frozen, so the retry is safe. Also: drop dead partySurvivors, collapse the zoneCombatRoster alias into fightRoster, route partyCasualtyLine through joinNames, fold four copies of the expedition column projection into expeditionSelectCols, stop replyDM sending a blank DM, and correct two doc comments describing a path that no longer exists. Deliberately not fixed, with reasons, in gogobee_code_review_followups.md — most notably that both turn-based close-outs skip grantCombatAchievements and persistDnDPostCombatSubclass, which the auto-resolve paths run.
319 lines
13 KiB
Go
319 lines
13 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++
|
|
}
|
|
// updateSupplies overwrites supplies_json wholesale, so the pool has to be
|
|
// re-read under the expedition's own lock: `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.
|
|
expMu := p.advExpeditionLock(exp.ID)
|
|
expMu.Lock()
|
|
fresh, err := getExpedition(exp.ID)
|
|
if err != nil || fresh == nil {
|
|
expMu.Unlock()
|
|
return p.SendDM(ctx.Sender, "You're in, but your supplies didn't reach the pool.")
|
|
}
|
|
pooled := addSupplyPurchase(fresh.Supplies, suppliesPurchase)
|
|
err = updateSupplies(exp.ID, pooled)
|
|
expMu.Unlock()
|
|
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 {
|
|
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 {
|
|
// 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(ctx.Sender)
|
|
if err != nil {
|
|
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
|
}
|
|
if seated != nil {
|
|
return p.leaveSeatedParty(ctx, seated)
|
|
}
|
|
|
|
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.")
|
|
}
|
|
return p.leaveSeatedParty(ctx, exp)
|
|
}
|
|
|
|
// leaveSeatedParty unseats a member and tells both ends. Shared by the two ways
|
|
// a member's seat resolves: the `extracting` limbo and the plain active party.
|
|
func (p *AdventurePlugin) leaveSeatedParty(ctx MessageContext, exp *Expedition) error {
|
|
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.")
|
|
}
|