mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
WIP: DM window tiers, expedition activity dedup, harvest gating
- Split advDMResponseWindow into a 12h low-priority tier for passive overnight-tolerant prompts (pet arrival/type/name); bump pet arrival chance 15% -> 30%. - Skip zone_run activity lines for runs that belong to an active expedition — the expedition rollup is the source of truth. - Loosen currentRoomCleared: Entry/Exploration/Trap allow harvest unconditionally (risk is the combat-interrupt roll); Elite/Boss stay gated on RoomsCleared. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -70,7 +70,13 @@ func (p *AdventurePlugin) advUserLock(userID id.UserID) *sync.Mutex {
|
|||||||
return val.(*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 advDMResponseWindow = 3 * time.Hour
|
||||||
|
const advDMResponseWindowLow = 12 * time.Hour
|
||||||
const advTreasureUndoWindow = 10 * time.Minute
|
const advTreasureUndoWindow = 10 * time.Minute
|
||||||
|
|
||||||
// advTreasureUndoToken tracks a recent auto-swap so the player can undo it
|
// advTreasureUndoToken tracks a recent auto-swap so the player can undo it
|
||||||
|
|||||||
@@ -79,6 +79,30 @@ func loadAdvDailyActivity(date string) (map[id.UserID][]AdvDailyActivity, error)
|
|||||||
}
|
}
|
||||||
rows.Close()
|
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 <zone>" 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
|
// 2. dnd_zone_run — rows touched today. Progress count is derived
|
||||||
// from len(visited_nodes) — current_room retired in G9.
|
// from len(visited_nodes) — current_room retired in G9.
|
||||||
rows, err = d.Query(`
|
rows, err = d.Query(`
|
||||||
@@ -110,6 +134,9 @@ func loadAdvDailyActivity(date string) (map[id.UserID][]AdvDailyActivity, error)
|
|||||||
if currentRoom < 0 {
|
if currentRoom < 0 {
|
||||||
currentRoom = 0
|
currentRoom = 0
|
||||||
}
|
}
|
||||||
|
if _, onExp := activeExp[uid+"|"+zoneID]; onExp {
|
||||||
|
continue
|
||||||
|
}
|
||||||
userID := id.UserID(uid)
|
userID := id.UserID(uid)
|
||||||
zoneDef := zoneOrFallback(ZoneID(zoneID))
|
zoneDef := zoneOrFallback(ZoneID(zoneID))
|
||||||
|
|
||||||
|
|||||||
@@ -202,8 +202,8 @@ func petShouldArrive(pet PetState, house HouseState) bool {
|
|||||||
if pet.ChasedAway {
|
if pet.ChasedAway {
|
||||||
return pet.Reactivated
|
return pet.Reactivated
|
||||||
}
|
}
|
||||||
// 15% daily chance after house tier 1
|
// 30% daily chance after house tier 1 (~3 days average to arrival)
|
||||||
return rand.Float64() < 0.15
|
return rand.Float64() < 0.30
|
||||||
}
|
}
|
||||||
|
|
||||||
// petArrivalDM sends the initial "there's an animal in your house" DM.
|
// 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{
|
p.pending.Store(string(userID), &advPendingInteraction{
|
||||||
Type: "pet_arrival",
|
Type: "pet_arrival",
|
||||||
Data: &advPendingPetArrival{},
|
Data: &advPendingPetArrival{},
|
||||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
|
||||||
})
|
})
|
||||||
_ = p.SendDM(userID, text)
|
_ = p.SendDM(userID, text)
|
||||||
}
|
}
|
||||||
@@ -258,7 +258,7 @@ func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext) error {
|
|||||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||||
Type: "pet_type",
|
Type: "pet_type",
|
||||||
Data: &advPendingPetType{},
|
Data: &advPendingPetType{},
|
||||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
|
||||||
})
|
})
|
||||||
return p.SendDM(ctx.Sender, text)
|
return p.SendDM(ctx.Sender, text)
|
||||||
}
|
}
|
||||||
@@ -267,7 +267,7 @@ func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext) error {
|
|||||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||||
Type: "pet_arrival",
|
Type: "pet_arrival",
|
||||||
Data: &advPendingPetArrival{},
|
Data: &advPendingPetArrival{},
|
||||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
|
||||||
})
|
})
|
||||||
return p.SendDM(ctx.Sender, "Reply with `chase` or `feed`.")
|
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{
|
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||||
Type: "pet_type",
|
Type: "pet_type",
|
||||||
Data: &advPendingPetType{},
|
Data: &advPendingPetType{},
|
||||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
|
||||||
})
|
})
|
||||||
return p.SendDM(ctx.Sender, "Reply with `dog` or `cat`.")
|
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{
|
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||||
Type: "pet_name",
|
Type: "pet_name",
|
||||||
Data: &advPendingPetName{PetType: petType},
|
Data: &advPendingPetName{PetType: petType},
|
||||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
|
||||||
})
|
})
|
||||||
|
|
||||||
article := "a"
|
article := "a"
|
||||||
|
|||||||
@@ -354,13 +354,22 @@ func regionEventActive(e *Expedition, eventID string) bool {
|
|||||||
// ── room-state helpers ──────────────────────────────────────────────────────
|
// ── room-state helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
// currentRoomCleared reports whether the player's current room is safe
|
// currentRoomCleared reports whether the player's current room is safe
|
||||||
// for harvest. Entry rooms auto-clear; otherwise the index must be in
|
// for harvest. Entry/Exploration/Trap rooms allow harvest unconditionally
|
||||||
// run.RoomsCleared.
|
// — 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 {
|
func currentRoomCleared(run *DungeonRun) bool {
|
||||||
if run == nil {
|
if run == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if run.CurrentRoomType() == RoomEntry {
|
switch run.CurrentRoomType() {
|
||||||
|
case RoomEntry, RoomExploration, RoomTrap:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
for _, idx := range run.RoomsCleared {
|
for _, idx := range run.RoomsCleared {
|
||||||
|
|||||||
@@ -229,19 +229,40 @@ func TestPickAvailableNode_SkipsRequiresKill(t *testing.T) {
|
|||||||
func TestCurrentRoomCleared_EntryAutoPasses(t *testing.T) {
|
func TestCurrentRoomCleared_EntryAutoPasses(t *testing.T) {
|
||||||
run := &DungeonRun{
|
run := &DungeonRun{
|
||||||
CurrentRoom: 0,
|
CurrentRoom: 0,
|
||||||
RoomSeq: []RoomType{RoomEntry, RoomExploration, RoomBoss},
|
RoomSeq: []RoomType{RoomEntry, RoomExploration, RoomTrap, RoomElite, RoomBoss},
|
||||||
RoomsCleared: []int{},
|
RoomsCleared: []int{},
|
||||||
}
|
}
|
||||||
if !currentRoomCleared(run) {
|
if !currentRoomCleared(run) {
|
||||||
t.Error("entry room should auto-clear")
|
t.Error("entry room should auto-clear")
|
||||||
}
|
}
|
||||||
|
// Exploration always allowed — interrupt roll handles risk.
|
||||||
run.CurrentRoom = 1
|
run.CurrentRoom = 1
|
||||||
if currentRoomCleared(run) {
|
|
||||||
t.Error("non-cleared exploration room should not be cleared")
|
|
||||||
}
|
|
||||||
run.RoomsCleared = []int{1}
|
|
||||||
if !currentRoomCleared(run) {
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user