mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Combat: guard daily-cron paths against mid-fight combat sessions
A turn-based elite/boss combat session locks the run for up to 1h and can straddle any scheduled tick. Add hasActiveCombatSession() and consult it from the morning DM, midnight idle reaper, expedition briefing/recap, and mid-day random event paths so none of them fire DMs or mutate run state into a live fight. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -120,6 +120,13 @@ func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mid-fight: a turn-based session locks the run. Don't drop a random
|
||||||
|
// overworld event into a live fight — the player can't act on it without
|
||||||
|
// finishing the fight first, and the trigger DM talks over the combat feed.
|
||||||
|
if hasActiveCombatSession(userID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 0.5% chance
|
// 0.5% chance
|
||||||
if rand.Float64() >= 0.005 {
|
if rand.Float64() >= 0.005 {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -98,6 +98,15 @@ func (p *AdventurePlugin) sendMorningDMs() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mid-fight: a turn-based elite/boss session locks the run. The
|
||||||
|
// per-round combat DMs are the player's feed right now — don't talk
|
||||||
|
// over them with the overworld morning menu. (A combat session always
|
||||||
|
// sits inside an active expedition, so the expedition skip below would
|
||||||
|
// usually catch this too; the explicit guard is cheap insurance.)
|
||||||
|
if char.Alive && hasActiveCombatSession(char.UserID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Active expedition: the expedition cycle delivers its own morning
|
// Active expedition: the expedition cycle delivers its own morning
|
||||||
// briefing at 06:00 UTC (deliverBriefing). The legacy overworld
|
// briefing at 06:00 UTC (deliverBriefing). The legacy overworld
|
||||||
// morning DM is irrelevant — and confusing — while underground.
|
// morning DM is irrelevant — and confusing — while underground.
|
||||||
@@ -402,14 +411,22 @@ func (p *AdventurePlugin) midnightReset() error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active expedition counts as activity. The expedition system tracks
|
// An active expedition — or a turn-based fight locked open across
|
||||||
// its own action flow (zone/harvest/combat/transit/extract) and never
|
// midnight — counts as activity. Both track their own action flow
|
||||||
// touches the legacy CombatActionsUsed/HarvestActionsUsed counters, so
|
// (zone/harvest/combat/transit/extract) and never touch the legacy
|
||||||
// HasActedToday() reports false for expeditioners. Treat them like the
|
// CombatActionsUsed/HarvestActionsUsed counters, so HasActedToday()
|
||||||
// acted-today branch below: advance the streak and bail out.
|
// reports false. Treat them like the acted-today branch below:
|
||||||
|
// advance the streak and bail out (no idle-shame, no streak decay).
|
||||||
|
busy := false
|
||||||
if exp, err := getActiveExpedition(char.UserID); err != nil {
|
if exp, err := getActiveExpedition(char.UserID); err != nil {
|
||||||
slog.Warn("adventure: failed to check active expedition for idle reaper", "user", char.UserID, "err", err)
|
slog.Warn("adventure: failed to check active expedition for idle reaper", "user", char.UserID, "err", err)
|
||||||
} else if exp != nil {
|
} else if exp != nil {
|
||||||
|
busy = true
|
||||||
|
}
|
||||||
|
if !busy && hasActiveCombatSession(char.UserID) {
|
||||||
|
busy = true
|
||||||
|
}
|
||||||
|
if busy {
|
||||||
if char.LastActionDate == yesterday || char.LastActionDate == today {
|
if char.LastActionDate == yesterday || char.LastActionDate == today {
|
||||||
char.CurrentStreak++
|
char.CurrentStreak++
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
@@ -256,6 +257,23 @@ func getActiveCombatSession(userID id.UserID) (*CombatSession, error) {
|
|||||||
return s, err
|
return s, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hasActiveCombatSession reports whether the player is currently locked into a
|
||||||
|
// turn-based elite/boss fight. Daily-cron and periodic-ticker paths consult
|
||||||
|
// this to avoid firing DMs or mutating run state into a live fight: an active
|
||||||
|
// session locks the run (no `!zone advance`), so the player can't move it
|
||||||
|
// forward and a cron path shouldn't either. The session carries a 1h TTL, so
|
||||||
|
// the collision window is bounded — but real, since a fight can straddle any
|
||||||
|
// scheduled tick. On a lookup error this returns false (fail-open): a missed
|
||||||
|
// guard is a confusing DM, not corruption.
|
||||||
|
func hasActiveCombatSession(userID id.UserID) bool {
|
||||||
|
s, err := getActiveCombatSession(userID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("combat: cron guard failed to check active session", "user", userID, "err", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return s != nil
|
||||||
|
}
|
||||||
|
|
||||||
// getCombatSession fetches by ID regardless of status. Test/admin/reaper use.
|
// getCombatSession fetches by ID regardless of status. Test/admin/reaper use.
|
||||||
func getCombatSession(sessionID string) (*CombatSession, error) {
|
func getCombatSession(sessionID string) (*CombatSession, error) {
|
||||||
row := db.Get().QueryRow(`SELECT `+combatSessionCols+`
|
row := db.Get().QueryRow(`SELECT `+combatSessionCols+`
|
||||||
|
|||||||
@@ -138,6 +138,34 @@ func TestListExpiredCombatSessions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHasActiveCombatSession(t *testing.T) {
|
||||||
|
setupZoneRunTestDB(t)
|
||||||
|
uid := id.UserID("@combat-guard:example.org")
|
||||||
|
defer cleanupCombatSessions(uid)
|
||||||
|
|
||||||
|
if hasActiveCombatSession(uid) {
|
||||||
|
t.Fatal("no session yet, want false")
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := startCombatSession(uid, "r", "n", "boss", 60, 60, 120, 120)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !hasActiveCombatSession(uid) {
|
||||||
|
t.Error("active session, want true")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A terminal session no longer counts as active.
|
||||||
|
s.Status = CombatStatusWon
|
||||||
|
s.Phase = CombatPhaseOver
|
||||||
|
if err := saveCombatSession(s); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if hasActiveCombatSession(uid) {
|
||||||
|
t.Error("session is won, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── State machine ──────────────────────────────────────────────────────────
|
// ── State machine ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// turnSession builds an in-memory CombatSession for state-machine tests that
|
// turnSession builds an in-memory CombatSession for state-machine tests that
|
||||||
|
|||||||
@@ -81,6 +81,16 @@ func (p *AdventurePlugin) fireExpeditionBriefings(now time.Time) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, e := range exps {
|
for _, e := range exps {
|
||||||
|
// Don't roll the expedition day forward into a live turn-based fight:
|
||||||
|
// an active combat session locks the run, and the briefing burns
|
||||||
|
// supply / advances the day / processes overnight camp. last_briefing_at
|
||||||
|
// stays stale, so the rollover simply lands on the next 06:00 tick —
|
||||||
|
// the expedition holds on its current day for one extra real day, which
|
||||||
|
// is mild and player-favorable versus mutating a locked run.
|
||||||
|
if hasActiveCombatSession(id.UserID(e.UserID)) {
|
||||||
|
slog.Info("expedition: briefing deferred — player in combat session", "expedition", e.ID, "user", e.UserID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
if err := p.deliverBriefing(e, now); err != nil {
|
if err := p.deliverBriefing(e, now); err != nil {
|
||||||
slog.Error("expedition: briefing", "expedition", e.ID, "err", err)
|
slog.Error("expedition: briefing", "expedition", e.ID, "err", err)
|
||||||
}
|
}
|
||||||
@@ -98,6 +108,13 @@ func (p *AdventurePlugin) fireExpeditionRecaps(now time.Time) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, e := range exps {
|
for _, e := range exps {
|
||||||
|
// Same guard as briefings: the recap runs the night wandering check,
|
||||||
|
// which can bump threat. Don't mutate a run locked by a live fight —
|
||||||
|
// last_recap_at stays stale and the recap lands on the next tick.
|
||||||
|
if hasActiveCombatSession(id.UserID(e.UserID)) {
|
||||||
|
slog.Info("expedition: recap deferred — player in combat session", "expedition", e.ID, "user", e.UserID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
if err := p.deliverRecap(e, now); err != nil {
|
if err := p.deliverRecap(e, now); err != nil {
|
||||||
slog.Error("expedition: recap", "expedition", e.ID, "err", err)
|
slog.Error("expedition: recap", "expedition", e.ID, "err", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user