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