diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 9a85ac7..1a020d7 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -70,7 +70,13 @@ func (p *AdventurePlugin) advUserLock(userID id.UserID) *sync.Mutex { return val.(*sync.Mutex) } +// advDMResponseWindow is the default window for active state-holding +// prompts (shop/blacksmith/hospital/masterwork/treasure confirms). +// Passive overnight-tolerant prompts (pet arrival, flavor DMs) use +// advDMResponseWindowLow so a player who gets DM'd at 1am can still +// reply when they wake up. const advDMResponseWindow = 3 * time.Hour +const advDMResponseWindowLow = 12 * time.Hour const advTreasureUndoWindow = 10 * time.Minute // advTreasureUndoToken tracks a recent auto-swap so the player can undo it diff --git a/internal/plugin/adventure_activity_unified.go b/internal/plugin/adventure_activity_unified.go index 48d3ca6..5061fb2 100644 --- a/internal/plugin/adventure_activity_unified.go +++ b/internal/plugin/adventure_activity_unified.go @@ -79,6 +79,30 @@ func loadAdvDailyActivity(date string) (map[id.UserID][]AdvDailyActivity, error) } rows.Close() + // Pre-load (user_id, zone_id) of active expeditions. Zone runs are now + // exclusively spawned by the expedition layer, and the expedition flips + // a region's run to abandoned on !region travel / inactivity timeout / + // forced extraction. Those transitions are internal — the expedition + // rollup below is the source of truth for the player. Skip zone_run + // entries that match an active expedition to avoid a misleading + // "Withdrew from " line while the player is still on it. + activeExp := make(map[string]struct{}) + expRows, err := d.Query(` + SELECT user_id, zone_id FROM dnd_expedition + WHERE status IN ('active','extracting')`) + if err != nil { + return nil, fmt.Errorf("active expeditions: %w", err) + } + for expRows.Next() { + var u, z string + if err := expRows.Scan(&u, &z); err != nil { + expRows.Close() + return nil, fmt.Errorf("active expedition scan: %w", err) + } + activeExp[u+"|"+z] = struct{}{} + } + expRows.Close() + // 2. dnd_zone_run — rows touched today. Progress count is derived // from len(visited_nodes) — current_room retired in G9. rows, err = d.Query(` @@ -110,6 +134,9 @@ func loadAdvDailyActivity(date string) (map[id.UserID][]AdvDailyActivity, error) if currentRoom < 0 { currentRoom = 0 } + if _, onExp := activeExp[uid+"|"+zoneID]; onExp { + continue + } userID := id.UserID(uid) zoneDef := zoneOrFallback(ZoneID(zoneID)) diff --git a/internal/plugin/adventure_pets.go b/internal/plugin/adventure_pets.go index 1c11f9a..f3f4825 100644 --- a/internal/plugin/adventure_pets.go +++ b/internal/plugin/adventure_pets.go @@ -202,8 +202,8 @@ func petShouldArrive(pet PetState, house HouseState) bool { if pet.ChasedAway { return pet.Reactivated } - // 15% daily chance after house tier 1 - return rand.Float64() < 0.15 + // 30% daily chance after house tier 1 (~3 days average to arrival) + return rand.Float64() < 0.30 } // petArrivalDM sends the initial "there's an animal in your house" DM. @@ -221,7 +221,7 @@ func (p *AdventurePlugin) petArrivalDM(userID id.UserID) { p.pending.Store(string(userID), &advPendingInteraction{ Type: "pet_arrival", Data: &advPendingPetArrival{}, - ExpiresAt: time.Now().Add(advDMResponseWindow), + ExpiresAt: time.Now().Add(advDMResponseWindowLow), }) _ = p.SendDM(userID, text) } @@ -258,7 +258,7 @@ func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext) error { p.pending.Store(string(ctx.Sender), &advPendingInteraction{ Type: "pet_type", Data: &advPendingPetType{}, - ExpiresAt: time.Now().Add(advDMResponseWindow), + ExpiresAt: time.Now().Add(advDMResponseWindowLow), }) return p.SendDM(ctx.Sender, text) } @@ -267,7 +267,7 @@ func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext) error { p.pending.Store(string(ctx.Sender), &advPendingInteraction{ Type: "pet_arrival", Data: &advPendingPetArrival{}, - ExpiresAt: time.Now().Add(advDMResponseWindow), + ExpiresAt: time.Now().Add(advDMResponseWindowLow), }) return p.SendDM(ctx.Sender, "Reply with `chase` or `feed`.") } @@ -291,7 +291,7 @@ func (p *AdventurePlugin) resolvePetType(ctx MessageContext) error { p.pending.Store(string(ctx.Sender), &advPendingInteraction{ Type: "pet_type", Data: &advPendingPetType{}, - ExpiresAt: time.Now().Add(advDMResponseWindow), + ExpiresAt: time.Now().Add(advDMResponseWindowLow), }) return p.SendDM(ctx.Sender, "Reply with `dog` or `cat`.") } @@ -299,7 +299,7 @@ func (p *AdventurePlugin) resolvePetType(ctx MessageContext) error { p.pending.Store(string(ctx.Sender), &advPendingInteraction{ Type: "pet_name", Data: &advPendingPetName{PetType: petType}, - ExpiresAt: time.Now().Add(advDMResponseWindow), + ExpiresAt: time.Now().Add(advDMResponseWindowLow), }) article := "a" diff --git a/internal/plugin/dnd_expedition_harvest.go b/internal/plugin/dnd_expedition_harvest.go index 1dd5513..b2d1d75 100644 --- a/internal/plugin/dnd_expedition_harvest.go +++ b/internal/plugin/dnd_expedition_harvest.go @@ -354,13 +354,22 @@ func regionEventActive(e *Expedition, eventID string) bool { // ── room-state helpers ────────────────────────────────────────────────────── // currentRoomCleared reports whether the player's current room is safe -// for harvest. Entry rooms auto-clear; otherwise the index must be in -// run.RoomsCleared. +// for harvest. Entry/Exploration/Trap rooms allow harvest unconditionally +// — risk is modeled by the §4.2 Combat Interrupt roll inside the harvest +// path, not by gating the command. Elite/Boss rooms stay gated: harvest +// is only allowed once they're in run.RoomsCleared. +// +// Why this isn't "is the current room in RoomsCleared": the runtime +// flow couples combat resolution with room transition inside !zone +// advance, so the current room index is *never* in RoomsCleared until +// the player has already moved past it. A strict cleared-only gate +// makes harvest in any non-entry room impossible. func currentRoomCleared(run *DungeonRun) bool { if run == nil { return false } - if run.CurrentRoomType() == RoomEntry { + switch run.CurrentRoomType() { + case RoomEntry, RoomExploration, RoomTrap: return true } for _, idx := range run.RoomsCleared { diff --git a/internal/plugin/dnd_expedition_harvest_test.go b/internal/plugin/dnd_expedition_harvest_test.go index 7e541c2..5e7fa30 100644 --- a/internal/plugin/dnd_expedition_harvest_test.go +++ b/internal/plugin/dnd_expedition_harvest_test.go @@ -229,19 +229,40 @@ func TestPickAvailableNode_SkipsRequiresKill(t *testing.T) { func TestCurrentRoomCleared_EntryAutoPasses(t *testing.T) { run := &DungeonRun{ CurrentRoom: 0, - RoomSeq: []RoomType{RoomEntry, RoomExploration, RoomBoss}, + RoomSeq: []RoomType{RoomEntry, RoomExploration, RoomTrap, RoomElite, RoomBoss}, RoomsCleared: []int{}, } if !currentRoomCleared(run) { t.Error("entry room should auto-clear") } + // Exploration always allowed — interrupt roll handles risk. run.CurrentRoom = 1 - if currentRoomCleared(run) { - t.Error("non-cleared exploration room should not be cleared") - } - run.RoomsCleared = []int{1} if !currentRoomCleared(run) { - t.Error("room in RoomsCleared should report cleared") + t.Error("exploration room should always allow harvest") + } + // Trap always allowed. + run.CurrentRoom = 2 + if !currentRoomCleared(run) { + t.Error("trap room should always allow harvest") + } + // Elite gated until in RoomsCleared. + run.CurrentRoom = 3 + if currentRoomCleared(run) { + t.Error("uncleared elite room should block harvest") + } + run.RoomsCleared = []int{3} + if !currentRoomCleared(run) { + t.Error("elite room in RoomsCleared should report cleared") + } + // Boss gated until in RoomsCleared. + run.CurrentRoom = 4 + run.RoomsCleared = []int{3} + if currentRoomCleared(run) { + t.Error("uncleared boss room should block harvest") + } + run.RoomsCleared = []int{3, 4} + if !currentRoomCleared(run) { + t.Error("boss room in RoomsCleared should report cleared") } }