mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 17:02:42 +00:00
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.
This commit is contained in:
@@ -2142,6 +2142,28 @@ CREATE TABLE IF NOT EXISTS expedition_party (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_expedition_party_user
|
CREATE INDEX IF NOT EXISTS idx_expedition_party_user
|
||||||
ON expedition_party(user_id);
|
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.
|
// SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.
|
||||||
|
|||||||
@@ -103,6 +103,10 @@ func (p *AdventurePlugin) eventTicker() {
|
|||||||
// can't hold a co-op fight hostage (N3/P5). Solo fights are never
|
// can't hold a co-op fight hostage (N3/P5). Solo fights are never
|
||||||
// listed — they answer to the reaper above.
|
// listed — they answer to the reaper above.
|
||||||
p.nudgeStalledPartyTurns()
|
p.nudgeStalledPartyTurns()
|
||||||
|
|
||||||
|
// Reclaim invites nobody answered (N3/P6b). Every read already
|
||||||
|
// filters on the TTL; this just stops the rows accumulating.
|
||||||
|
purgeExpiredInvites()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -399,6 +399,11 @@ func releaseParty(expID string) {
|
|||||||
if err := disbandParty(expID); err != nil {
|
if err := disbandParty(expID); err != nil {
|
||||||
slog.Warn("expedition: disband party", "expedition", expID, "err", err)
|
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,
|
// applyThreatDelta clamps the threat level to [0,100], records the event,
|
||||||
|
|||||||
@@ -48,8 +48,9 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string
|
|||||||
sub, rest := splitFirstWord(args)
|
sub, rest := splitFirstWord(args)
|
||||||
switch strings.ToLower(sub) {
|
switch strings.ToLower(sub) {
|
||||||
case "":
|
case "":
|
||||||
// If active, show status; otherwise help.
|
// If active, show status; otherwise help. A party member is on an
|
||||||
if exp, _ := getActiveExpedition(ctx.Sender); exp != nil {
|
// 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.expeditionCmdStatus(ctx, "")
|
||||||
}
|
}
|
||||||
return p.SendDM(ctx.Sender, expeditionHelpText())
|
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
|
// player doesn't have to remember the !zone seam). Otherwise
|
||||||
// fall back to the historical alias for `!expedition start`.
|
// fall back to the historical alias for `!expedition start`.
|
||||||
if rest != "" && isAllDigits(strings.TrimSpace(rest)) {
|
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)
|
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)
|
return p.expeditionCmdStart(ctx, c, rest)
|
||||||
@@ -76,6 +85,16 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string
|
|||||||
return p.expeditionCmdLog(ctx)
|
return p.expeditionCmdLog(ctx)
|
||||||
case "abandon", "quit":
|
case "abandon", "quit":
|
||||||
return p.expeditionCmdAbandon(ctx)
|
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":
|
case "extract":
|
||||||
return p.handleExtractCmd(ctx, "")
|
return p.handleExtractCmd(ctx, "")
|
||||||
case "resume":
|
case "resume":
|
||||||
@@ -99,6 +118,11 @@ func expeditionHelpText() string {
|
|||||||
b.WriteString("`!expedition start <zone>` — prompts a loadout: `lean` / `balanced` / `heavy`\n")
|
b.WriteString("`!expedition start <zone>` — prompts a loadout: `lean` / `balanced` / `heavy`\n")
|
||||||
b.WriteString("`!expedition run` — start (or resume) the autopilot walk (alias `!explore`)\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("`!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("**Mid-expedition:**\n")
|
||||||
b.WriteString("`!extract` — bail safely (resumable for 7 days)\n")
|
b.WriteString("`!extract` — bail safely (resumable for 7 days)\n")
|
||||||
b.WriteString("`!resume [loadout]` — resume an extracted expedition\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 {
|
func (p *AdventurePlugin) expeditionCmdStatusImpl(ctx MessageContext, debug bool) error {
|
||||||
exp, err := getActiveExpedition(ctx.Sender)
|
exp, _, err := activeExpeditionFor(ctx.Sender)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
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",
|
b.WriteString(fmt.Sprintf("🗺 **Region:** %s (%d/%d)%s\n",
|
||||||
r.Name, r.Order, len(regionsForZone(exp.ZoneID)), marker))
|
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",
|
b.WriteString(fmt.Sprintf("🚪 **Rooms:** %d / %d in this region\n",
|
||||||
run.CurrentRoom+1, run.TotalRooms))
|
run.CurrentRoom+1, run.TotalRooms))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// applyDailyBurn deducts one day's supplies from the snapshot (caller
|
||||||
// persists). Returns the new snapshot and the SU consumed.
|
// persists). Returns the new snapshot and the SU consumed.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -116,13 +116,21 @@ func (p *AdventurePlugin) fireExpeditionAutoRuns(now time.Time) {
|
|||||||
// is older than ageCutoff so a freshly-started expedition isn't yanked
|
// is older than ageCutoff so a freshly-started expedition isn't yanked
|
||||||
// out from under the player on tick 1.
|
// out from under the player on tick 1.
|
||||||
func loadExpeditionsForAutoRun(runCutoff, ageCutoff time.Time) ([]string, error) {
|
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(`
|
rows, err := db.Get().Query(`
|
||||||
SELECT expedition_id
|
SELECT expedition_id
|
||||||
FROM dnd_expedition
|
FROM dnd_expedition e
|
||||||
WHERE status = 'active'
|
WHERE status = 'active'
|
||||||
AND start_date < ?
|
AND start_date < ?
|
||||||
AND (last_autorun_at IS NULL OR last_autorun_at < ?)`,
|
AND (last_autorun_at IS NULL OR last_autorun_at < ?)
|
||||||
ageCutoff, runCutoff)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
284
internal/plugin/expedition_party_cmd.go
Normal file
284
internal/plugin/expedition_party_cmd.go
Normal file
@@ -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 <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.")
|
||||||
|
}
|
||||||
190
internal/plugin/expedition_party_invite.go
Normal file
190
internal/plugin/expedition_party_invite.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
265
internal/plugin/expedition_party_invite_test.go
Normal file
265
internal/plugin/expedition_party_invite_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user