From 1928f75c1999a2bcf3eb355590fa84a791a9e5d2 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:30:11 -0700 Subject: [PATCH] N3/P6b: a party you can actually ask someone to join !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. --- internal/db/db.go | 22 ++ internal/plugin/adventure_events.go | 4 + internal/plugin/dnd_expedition.go | 5 + internal/plugin/dnd_expedition_cmd.go | 34 ++- internal/plugin/dnd_expedition_supplies.go | 17 ++ internal/plugin/expedition_autorun.go | 14 +- internal/plugin/expedition_party_cmd.go | 284 ++++++++++++++++++ internal/plugin/expedition_party_invite.go | 190 ++++++++++++ .../plugin/expedition_party_invite_test.go | 265 ++++++++++++++++ 9 files changed, 827 insertions(+), 8 deletions(-) create mode 100644 internal/plugin/expedition_party_cmd.go create mode 100644 internal/plugin/expedition_party_invite.go create mode 100644 internal/plugin/expedition_party_invite_test.go diff --git a/internal/db/db.go b/internal/db/db.go index e0b461b..47453d4 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -2142,6 +2142,28 @@ CREATE TABLE IF NOT EXISTS expedition_party ( ); CREATE INDEX IF NOT EXISTS idx_expedition_party_user ON expedition_party(user_id); + +-- ── N3/P6b — pending party invites ──────────────────────────────────────── +-- An invite the leader has sent and the invitee has not yet answered. It is +-- deleted on accept, on decline, and when the expedition ends; it expires on +-- read past expeditionInviteTTL, so a forgotten invite cannot pin an +-- expedition's autopilot forever. +-- +-- Absent == nobody has been asked, which is true of every expedition that +-- existed before N3, so there is nothing to backfill. +-- +-- While any row here names an expedition, the autopilot will not walk it: the +-- leader must not be dragged into a boss room while their friend is still +-- reading the invite DM. +CREATE TABLE IF NOT EXISTS expedition_invite ( + expedition_id TEXT NOT NULL, + user_id TEXT NOT NULL, + invited_by TEXT NOT NULL, + invited_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (expedition_id, user_id) +); +CREATE INDEX IF NOT EXISTS idx_expedition_invite_user + ON expedition_invite(user_id); ` // SeedSchedulerDefaults inserts default scheduler jobs if they don't exist. diff --git a/internal/plugin/adventure_events.go b/internal/plugin/adventure_events.go index 5bb6351..f250a3f 100644 --- a/internal/plugin/adventure_events.go +++ b/internal/plugin/adventure_events.go @@ -103,6 +103,10 @@ func (p *AdventurePlugin) eventTicker() { // can't hold a co-op fight hostage (N3/P5). Solo fights are never // listed — they answer to the reaper above. p.nudgeStalledPartyTurns() + + // Reclaim invites nobody answered (N3/P6b). Every read already + // filters on the TTL; this just stops the rows accumulating. + purgeExpiredInvites() } } } diff --git a/internal/plugin/dnd_expedition.go b/internal/plugin/dnd_expedition.go index 42e9345..19b64b4 100644 --- a/internal/plugin/dnd_expedition.go +++ b/internal/plugin/dnd_expedition.go @@ -399,6 +399,11 @@ func releaseParty(expID string) { if err := disbandParty(expID); err != nil { slog.Warn("expedition: disband party", "expedition", expID, "err", err) } + // An unanswered invite to a finished expedition would otherwise sit there + // until its TTL, letting someone accept their way onto a corpse. + if err := clearExpeditionInvites(expID); err != nil { + slog.Warn("expedition: clear invites", "expedition", expID, "err", err) + } } // applyThreatDelta clamps the threat level to [0,100], records the event, diff --git a/internal/plugin/dnd_expedition_cmd.go b/internal/plugin/dnd_expedition_cmd.go index e9f405c..3a5439c 100644 --- a/internal/plugin/dnd_expedition_cmd.go +++ b/internal/plugin/dnd_expedition_cmd.go @@ -48,8 +48,9 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string sub, rest := splitFirstWord(args) switch strings.ToLower(sub) { case "": - // If active, show status; otherwise help. - if exp, _ := getActiveExpedition(ctx.Sender); exp != nil { + // If active, show status; otherwise help. A party member is on an + // expedition they do not own, so ask the leader-or-member resolver. + if exp, _, _ := activeExpeditionFor(ctx.Sender); exp != nil { return p.expeditionCmdStatus(ctx, "") } return p.SendDM(ctx.Sender, expeditionHelpText()) @@ -65,8 +66,16 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string // player doesn't have to remember the !zone seam). Otherwise // fall back to the historical alias for `!expedition start`. if rest != "" && isAllDigits(strings.TrimSpace(rest)) { - if exp, _ := getActiveExpedition(ctx.Sender); exp != nil { + exp, isLeader, _ := activeExpeditionFor(ctx.Sender) + switch { + case exp != nil && isLeader: return p.zoneCmdGo(ctx, rest) + case exp != nil: + // A member reaching a fork must not silently fall through to + // `!expedition start` and be told their path number is an + // unknown zone. The leader picks the way (C1); P6c enforces + // that at the !zone seam too. + return p.SendDM(ctx.Sender, "The party leader picks the path.") } } return p.expeditionCmdStart(ctx, c, rest) @@ -76,6 +85,16 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string return p.expeditionCmdLog(ctx) case "abandon", "quit": return p.expeditionCmdAbandon(ctx) + case "invite": + return p.expeditionCmdInvite(ctx, rest) + case "accept", "join": + return p.expeditionCmdAccept(ctx, c, rest) + case "decline": + return p.expeditionCmdDecline(ctx) + case "party", "roster": + return p.expeditionCmdParty(ctx) + case "leave": + return p.expeditionCmdLeave(ctx) case "extract": return p.handleExtractCmd(ctx, "") case "resume": @@ -99,6 +118,11 @@ func expeditionHelpText() string { b.WriteString("`!expedition start ` — prompts a loadout: `lean` / `balanced` / `heavy`\n") b.WriteString("`!expedition run` — start (or resume) the autopilot walk (alias `!explore`)\n") b.WriteString("`!expedition status` — day, rooms, supplies, recent events\n\n") + b.WriteString("**Bring someone** _(Day 1, up to three of you)_:\n") + b.WriteString("`!expedition invite @user` — they buy their own supplies into the party pool\n") + b.WriteString("`!expedition accept` / `!expedition decline` — answer an invite\n") + b.WriteString("`!expedition party` — who's with you\n") + b.WriteString("`!expedition leave` — turn back for town (members only)\n\n") b.WriteString("**Mid-expedition:**\n") b.WriteString("`!extract` — bail safely (resumable for 7 days)\n") b.WriteString("`!resume [loadout]` — resume an extracted expedition\n") @@ -393,7 +417,7 @@ func (p *AdventurePlugin) expeditionCmdStatus(ctx MessageContext, args string) e } func (p *AdventurePlugin) expeditionCmdStatusImpl(ctx MessageContext, debug bool) error { - exp, err := getActiveExpedition(ctx.Sender) + exp, _, err := activeExpeditionFor(ctx.Sender) if err != nil { return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error()) } @@ -417,7 +441,7 @@ func (p *AdventurePlugin) expeditionCmdStatusImpl(ctx MessageContext, debug bool b.WriteString(fmt.Sprintf("🗺 **Region:** %s (%d/%d)%s\n", r.Name, r.Order, len(regionsForZone(exp.ZoneID)), marker)) } - if run, rerr := getActiveZoneRun(ctx.Sender); rerr == nil && run != nil && run.TotalRooms > 0 { + if run, _, rerr := activeZoneRunFor(ctx.Sender); rerr == nil && run != nil && run.TotalRooms > 0 { b.WriteString(fmt.Sprintf("🚪 **Rooms:** %d / %d in this region\n", run.CurrentRoom+1, run.TotalRooms)) } diff --git a/internal/plugin/dnd_expedition_supplies.go b/internal/plugin/dnd_expedition_supplies.go index fa3f0fe..1357dd4 100644 --- a/internal/plugin/dnd_expedition_supplies.go +++ b/internal/plugin/dnd_expedition_supplies.go @@ -298,6 +298,23 @@ func makeSupplies(tier ZoneTier, p SupplyPurchase) ExpeditionSupplies { } } +// addSupplyPurchase folds a joining member's packs into the party's pool +// (N3/P6b). Everyone carries their own rations in, so both Current and Max rise +// by what they bought. +// +// Raising Max alongside Current is what keeps supplyDepletion honest: it reads +// the ratio, and a member arriving with a full pack must not read as the party +// suddenly starving. On Day 1 — the only day an invite is legal — nothing has +// burned yet, so Current == Max and the fold is exact. +func addSupplyPurchase(s ExpeditionSupplies, p SupplyPurchase) ExpeditionSupplies { + total := p.Total() + s.Current += total + s.Max += total + s.PacksStandard += p.StandardPacks + s.PacksDeluxe += p.DeluxePacks + return s +} + // applyDailyBurn deducts one day's supplies from the snapshot (caller // persists). Returns the new snapshot and the SU consumed. // diff --git a/internal/plugin/expedition_autorun.go b/internal/plugin/expedition_autorun.go index 706153b..b2e5c07 100644 --- a/internal/plugin/expedition_autorun.go +++ b/internal/plugin/expedition_autorun.go @@ -116,13 +116,21 @@ func (p *AdventurePlugin) fireExpeditionAutoRuns(now time.Time) { // is older than ageCutoff so a freshly-started expedition isn't yanked // out from under the player on tick 1. func loadExpeditionsForAutoRun(runCutoff, ageCutoff time.Time) ([]string, error) { + // N3/P6b: an expedition with an unanswered invite does not walk. The + // leader must not be dragged into a boss room while their friend is still + // reading the DM. Bounded by expeditionInviteTTL, which the invited_at + // comparison enforces here rather than trusting the purge to have run. rows, err := db.Get().Query(` SELECT expedition_id - FROM dnd_expedition + FROM dnd_expedition e WHERE status = 'active' AND start_date < ? - AND (last_autorun_at IS NULL OR last_autorun_at < ?)`, - ageCutoff, runCutoff) + AND (last_autorun_at IS NULL OR last_autorun_at < ?) + AND NOT EXISTS ( + SELECT 1 FROM expedition_invite i + WHERE i.expedition_id = e.expedition_id + AND i.invited_at > ?)`, + ageCutoff, runCutoff, inviteCutoff()) if err != nil { return nil, err } diff --git a/internal/plugin/expedition_party_cmd.go b/internal/plugin/expedition_party_cmd.go new file mode 100644 index 0000000..23ee69d --- /dev/null +++ b/internal/plugin/expedition_party_cmd.go @@ -0,0 +1,284 @@ +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 ` 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.") +} diff --git a/internal/plugin/expedition_party_invite.go b/internal/plugin/expedition_party_invite.go new file mode 100644 index 0000000..3df1923 --- /dev/null +++ b/internal/plugin/expedition_party_invite.go @@ -0,0 +1,190 @@ +package plugin + +import ( + "database/sql" + "errors" + "fmt" + "log/slog" + "time" + + "gogobee/internal/db" + "maunium.net/go/mautrix/id" +) + +// N3/P6b — the invite handshake. +// +// A party forms in the gap between "I started an expedition" and "we set off", +// and that gap is short: the autopilot leaves a fresh expedition alone for +// autoRunMinExpeditionAge (30 minutes) and then starts walking it. Thirty +// minutes is not long enough to ask a friend who is asleep. +// +// So an outstanding invite pins the autopilot in place — loadExpeditionsForAutoRun +// skips any expedition somebody has been asked to join. The pin is bounded by +// expeditionInviteTTL, because a leader who invites someone on holiday must not +// have their expedition frozen forever. +// +// The window the *leader* sees is wider than the plan's "before the first walk": +// an invite is legal for the whole of Day 1. Tying it to the first step would +// have made it a race against a ticker the player cannot see, and lost the +// leader's own `!expedition run` as well. Supplies barely move on day one — the +// pool burns at the night rollover — so a companion who arrives three rooms in +// pays and receives the same as one who arrives at the gate. + +const ( + // expeditionInviteTTL bounds how long an unanswered invite holds the + // autopilot. Long enough to catch someone between sessions of an async chat + // bot; short enough that a forgotten invite costs the leader one afternoon, + // not their expedition. + expeditionInviteTTL = 2 * time.Hour +) + +// Errors returned by the invite layer. +var ( + ErrInviteNotFound = errors.New("no pending invite") + ErrInviteWindowShut = errors.New("this expedition has already set off") + ErrAlreadyInvited = errors.New("player already has a pending invite here") +) + +// ExpeditionInvite is one unanswered ask. +type ExpeditionInvite struct { + ExpeditionID string + UserID string + InvitedBy string + InvitedAt time.Time +} + +// inviteWindowOpen reports whether a party may still take on a member. Day 1 +// only, and never after the boss is down — joining a finished expedition would +// seat someone into a payout they did not walk to. +func inviteWindowOpen(e *Expedition) bool { + return e != nil && e.Status == ExpeditionStatusActive && + e.CurrentDay <= 1 && !e.BossDefeated +} + +// inviteToParty records a pending invite. It refuses a full roster — counting +// outstanding invites against the ceiling, so a leader cannot ask four people +// and let them race for two seats — and refuses anyone already committed to an +// expedition of their own. +// +// The count, the guard, and the insert share a transaction for the same reason +// joinParty's do: two invites sent at once must not both find the last seat free. +func inviteToParty(expeditionID string, invitee, leader id.UserID) error { + tx, err := db.Get().Begin() + if err != nil { + return err + } + defer tx.Rollback() + + if err := seatLeader(tx, expeditionID); err != nil { + return err + } + + var seated, pending int + if err := tx.QueryRow( + `SELECT COUNT(*) FROM expedition_party WHERE expedition_id = ?`, + expeditionID).Scan(&seated); err != nil { + return err + } + if err := tx.QueryRow( + `SELECT COUNT(*) FROM expedition_invite WHERE expedition_id = ? AND invited_at > ?`, + expeditionID, inviteCutoff()).Scan(&pending); err != nil { + return err + } + if seated+pending >= expeditionPartyMax { + return ErrPartyFull + } + + if err := assertNotAdventuring(tx, expeditionID, invitee); err != nil { + return err + } + + res, err := tx.Exec(` + INSERT INTO expedition_invite (expedition_id, user_id, invited_by, invited_at) + VALUES (?, ?, ?, ?) + ON CONFLICT (expedition_id, user_id) DO NOTHING`, + expeditionID, string(invitee), string(leader), time.Now().UTC()) + if err != nil { + return fmt.Errorf("invite %s: %w", invitee, err) + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrAlreadyInvited + } + return tx.Commit() +} + +// inviteCutoff is the age past which an invite no longer counts as pending. +func inviteCutoff() time.Time { return time.Now().UTC().Add(-expeditionInviteTTL) } + +// latestInviteFor returns the freshest unanswered invite addressed to a player, +// or ErrInviteNotFound. +// +// A player may hold invites from several leaders at once; `!expedition accept` +// takes the most recent, which is the one whose DM is still on their screen. The +// first accept wins the player — joinParty's assertNotAdventuring refuses the +// rest — so there is nothing to reconcile. +func latestInviteFor(userID id.UserID) (*ExpeditionInvite, error) { + var inv ExpeditionInvite + err := db.Get().QueryRow(` + SELECT expedition_id, user_id, invited_by, invited_at + FROM expedition_invite + WHERE user_id = ? AND invited_at > ? + ORDER BY invited_at DESC + LIMIT 1`, string(userID), inviteCutoff()).Scan( + &inv.ExpeditionID, &inv.UserID, &inv.InvitedBy, &inv.InvitedAt) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrInviteNotFound + } + if err != nil { + return nil, err + } + return &inv, nil +} + +// pendingInvites lists an expedition's unanswered invites, for the roster view. +func pendingInvites(expeditionID string) ([]ExpeditionInvite, error) { + rows, err := db.Get().Query(` + SELECT expedition_id, user_id, invited_by, invited_at + FROM expedition_invite + WHERE expedition_id = ? AND invited_at > ? + ORDER BY invited_at ASC`, expeditionID, inviteCutoff()) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []ExpeditionInvite + for rows.Next() { + var inv ExpeditionInvite + if err := rows.Scan(&inv.ExpeditionID, &inv.UserID, &inv.InvitedBy, &inv.InvitedAt); err != nil { + return nil, err + } + out = append(out, inv) + } + return out, rows.Err() +} + +// clearInvite removes one answered invite. +func clearInvite(expeditionID string, userID id.UserID) error { + _, err := db.Get().Exec( + `DELETE FROM expedition_invite WHERE expedition_id = ? AND user_id = ?`, + expeditionID, string(userID)) + return err +} + +// clearExpeditionInvites drops every invite an expedition ever sent. Called +// beside disbandParty when the run reaches a terminal status. +func clearExpeditionInvites(expeditionID string) error { + _, err := db.Get().Exec( + `DELETE FROM expedition_invite WHERE expedition_id = ?`, expeditionID) + return err +} + +// purgeExpiredInvites deletes invites nobody answered. Every read already +// filters on the TTL, so this reclaims rows rather than enforcing a rule; it +// rides the existing one-minute eventTicker (D4: no net-new ticker). +func purgeExpiredInvites() { + if _, err := db.Get().Exec( + `DELETE FROM expedition_invite WHERE invited_at <= ?`, inviteCutoff()); err != nil { + slog.Warn("expedition: purge expired invites", "err", err) + } +} diff --git a/internal/plugin/expedition_party_invite_test.go b/internal/plugin/expedition_party_invite_test.go new file mode 100644 index 0000000..cdab7f7 --- /dev/null +++ b/internal/plugin/expedition_party_invite_test.go @@ -0,0 +1,265 @@ +package plugin + +import ( + "errors" + "testing" + "time" + + "gogobee/internal/db" + "maunium.net/go/mautrix/id" +) + +func TestInvite_SeatsLeaderAndRecordsTheAsk(t *testing.T) { + setupEmptyTestDB(t) + leader := id.UserID("@lead:example.org") + invitee := id.UserID("@friend:example.org") + seedExpedition(t, "exp-1", leader, "active") + + if err := inviteToParty("exp-1", invitee, leader); err != nil { + t.Fatal(err) + } + + // The leader is seated even though nobody has accepted — a roster whose + // leader is missing drops them from every fan-out. + members, err := partyMembers("exp-1") + if err != nil { + t.Fatal(err) + } + if len(members) != 1 || !members[0].IsLeader() { + t.Fatalf("roster = %v, want the leader alone", members) + } + + inv, err := latestInviteFor(invitee) + if err != nil { + t.Fatal(err) + } + if inv.ExpeditionID != "exp-1" || inv.InvitedBy != string(leader) { + t.Errorf("invite = %+v", inv) + } +} + +func TestInvite_RefusesADuplicateAsk(t *testing.T) { + setupEmptyTestDB(t) + leader := id.UserID("@lead:example.org") + invitee := id.UserID("@friend:example.org") + seedExpedition(t, "exp-1", leader, "active") + + if err := inviteToParty("exp-1", invitee, leader); err != nil { + t.Fatal(err) + } + if err := inviteToParty("exp-1", invitee, leader); !errors.Is(err, ErrAlreadyInvited) { + t.Errorf("second invite err = %v, want ErrAlreadyInvited", err) + } +} + +// Outstanding invites count against the ceiling. Otherwise a leader asks four +// people, three accept, and the roster overflows expeditionPartyMax. +func TestInvite_PendingInvitesCountAgainstTheCeiling(t *testing.T) { + setupEmptyTestDB(t) + leader := id.UserID("@lead:example.org") + seedExpedition(t, "exp-1", leader, "active") + + // Leader + 2 invites == expeditionPartyMax (3). + for _, u := range []id.UserID{"@a:example.org", "@b:example.org"} { + if err := inviteToParty("exp-1", u, leader); err != nil { + t.Fatalf("invite %s: %v", u, err) + } + } + if err := inviteToParty("exp-1", "@c:example.org", leader); !errors.Is(err, ErrPartyFull) { + t.Errorf("third invite err = %v, want ErrPartyFull", err) + } +} + +func TestInvite_RefusesAPlayerWhoIsAlreadyAdventuring(t *testing.T) { + setupEmptyTestDB(t) + leader := id.UserID("@lead:example.org") + busy := id.UserID("@busy:example.org") + seedExpedition(t, "exp-1", leader, "active") + seedExpedition(t, "exp-2", busy, "active") + + if err := inviteToParty("exp-1", busy, leader); !errors.Is(err, ErrPlayerBusyElsewhere) { + t.Errorf("invite err = %v, want ErrPlayerBusyElsewhere", err) + } +} + +func TestInvite_ExpiresPastTTL(t *testing.T) { + setupEmptyTestDB(t) + leader := id.UserID("@lead:example.org") + invitee := id.UserID("@friend:example.org") + seedExpedition(t, "exp-1", leader, "active") + if err := inviteToParty("exp-1", invitee, leader); err != nil { + t.Fatal(err) + } + + stale := time.Now().UTC().Add(-2 * expeditionInviteTTL) + if _, err := db.Get().Exec( + `UPDATE expedition_invite SET invited_at = ? WHERE user_id = ?`, stale, string(invitee)); err != nil { + t.Fatal(err) + } + + if _, err := latestInviteFor(invitee); !errors.Is(err, ErrInviteNotFound) { + t.Errorf("stale invite err = %v, want ErrInviteNotFound", err) + } + // And the seat it was holding is free again. + if err := inviteToParty("exp-1", "@a:example.org", leader); err != nil { + t.Errorf("expired invite still holds a seat: %v", err) + } +} + +func TestPurgeExpiredInvites_ReclaimsOnlyStaleRows(t *testing.T) { + setupEmptyTestDB(t) + leader := id.UserID("@lead:example.org") + seedExpedition(t, "exp-1", leader, "active") + for _, u := range []id.UserID{"@fresh:example.org", "@stale:example.org"} { + if err := inviteToParty("exp-1", u, leader); err != nil { + t.Fatal(err) + } + } + if _, err := db.Get().Exec(`UPDATE expedition_invite SET invited_at = ? WHERE user_id = ?`, + time.Now().UTC().Add(-2*expeditionInviteTTL), "@stale:example.org"); err != nil { + t.Fatal(err) + } + + purgeExpiredInvites() + + var n int + if err := db.Get().QueryRow(`SELECT COUNT(*) FROM expedition_invite`).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Errorf("invite rows = %d, want 1 (the fresh one)", n) + } +} + +// ── the invite window ──────────────────────────────────────────────────────── + +func TestInviteWindowOpen(t *testing.T) { + for _, tc := range []struct { + name string + e Expedition + want bool + }{ + {"day 1, active", Expedition{Status: ExpeditionStatusActive, CurrentDay: 1}, true}, + {"day 2", Expedition{Status: ExpeditionStatusActive, CurrentDay: 2}, false}, + {"boss down", Expedition{Status: ExpeditionStatusActive, CurrentDay: 1, BossDefeated: true}, false}, + {"extracting", Expedition{Status: ExpeditionStatusExtracting, CurrentDay: 1}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := inviteWindowOpen(&tc.e); got != tc.want { + t.Errorf("inviteWindowOpen = %v, want %v", got, tc.want) + } + }) + } +} + +// ── autopilot suppression ──────────────────────────────────────────────────── + +// The invite window is 30 minutes of autopilot grace; an unanswered invite has +// to hold the expedition still, or the leader walks off without their friend. +func TestAutoRun_PendingInviteHoldsTheExpedition(t *testing.T) { + setupEmptyTestDB(t) + leader := id.UserID("@lead:example.org") + seedExpedition(t, "exp-1", leader, "active") + // Age the expedition past autoRunMinExpeditionAge so only the invite can + // be what holds it. + old := time.Now().UTC().Add(-2 * time.Hour) + if _, err := db.Get().Exec( + `UPDATE dnd_expedition SET start_date = ? WHERE expedition_id = 'exp-1'`, old); err != nil { + t.Fatal(err) + } + runCutoff := time.Now().UTC() + ageCutoff := time.Now().UTC().Add(-autoRunMinExpeditionAge) + + ids, err := loadExpeditionsForAutoRun(runCutoff, ageCutoff) + if err != nil { + t.Fatal(err) + } + if len(ids) != 1 { + t.Fatalf("without an invite, autorun sees %v; want [exp-1]", ids) + } + + if err := inviteToParty("exp-1", "@friend:example.org", leader); err != nil { + t.Fatal(err) + } + ids, err = loadExpeditionsForAutoRun(runCutoff, ageCutoff) + if err != nil { + t.Fatal(err) + } + if len(ids) != 0 { + t.Errorf("autopilot walked an expedition with a pending invite: %v", ids) + } +} + +// The hold is bounded: a forgotten invite must not freeze an expedition forever. +func TestAutoRun_ExpiredInviteReleasesTheExpedition(t *testing.T) { + setupEmptyTestDB(t) + leader := id.UserID("@lead:example.org") + seedExpedition(t, "exp-1", leader, "active") + old := time.Now().UTC().Add(-4 * time.Hour) + if _, err := db.Get().Exec( + `UPDATE dnd_expedition SET start_date = ? WHERE expedition_id = 'exp-1'`, old); err != nil { + t.Fatal(err) + } + if err := inviteToParty("exp-1", "@friend:example.org", leader); err != nil { + t.Fatal(err) + } + if _, err := db.Get().Exec(`UPDATE expedition_invite SET invited_at = ?`, + time.Now().UTC().Add(-2*expeditionInviteTTL)); err != nil { + t.Fatal(err) + } + + ids, err := loadExpeditionsForAutoRun(time.Now().UTC(), time.Now().UTC().Add(-autoRunMinExpeditionAge)) + if err != nil { + t.Fatal(err) + } + if len(ids) != 1 { + t.Errorf("expired invite still pins the autopilot: %v", ids) + } +} + +// ── terminal cleanup ───────────────────────────────────────────────────────── + +// An invite outliving its expedition would let someone accept onto a corpse. +func TestReleaseParty_ClearsPendingInvites(t *testing.T) { + setupEmptyTestDB(t) + leader := id.UserID("@lead:example.org") + invitee := id.UserID("@friend:example.org") + seedExpedition(t, "exp-1", leader, "active") + if err := inviteToParty("exp-1", invitee, leader); err != nil { + t.Fatal(err) + } + + if err := completeExpedition("exp-1", ExpeditionStatusComplete); err != nil { + t.Fatal(err) + } + + if _, err := latestInviteFor(invitee); !errors.Is(err, ErrInviteNotFound) { + t.Errorf("invite survived expedition completion: %v", err) + } +} + +// ── the supply pool ────────────────────────────────────────────────────────── + +// Both Current and Max rise: supplyDepletion reads the ratio, and a member +// arriving with a full pack must not read as the party suddenly starving. +func TestAddSupplyPurchase_PoolsBothCurrentAndMax(t *testing.T) { + base := makeSupplies(ZoneTier(1), SupplyPurchase{StandardPacks: 2}) + before := supplyDepletion(base) + + joined := addSupplyPurchase(base, SupplyPurchase{StandardPacks: 2}) + + if joined.Current != base.Current*2 || joined.Max != base.Max*2 { + t.Errorf("pooled = %.1f/%.1f, want %.1f/%.1f", + joined.Current, joined.Max, base.Current*2, base.Max*2) + } + if joined.PacksStandard != 4 { + t.Errorf("PacksStandard = %d, want 4", joined.PacksStandard) + } + if got := supplyDepletion(joined); got != before { + t.Errorf("depletion state moved on join: %v -> %v", before, got) + } + // The burn rate is the zone's, not the party's — P6c scales it by size. + if joined.DailyBurn != base.DailyBurn { + t.Errorf("DailyBurn = %v, want %v", joined.DailyBurn, base.DailyBurn) + } +}