Files
gogobee/internal/plugin/adventure_mortgage.go
prosolis 45863bd5f3 Adv 2.0 D&D Phase R7: post-completion audit hardening
High-priority fixes from the multi-agent audit of Adventure 2.0:

- Phase 11 nil-deref cluster: zoneOrFallback() helper replaces 8 unsafe
  getZone(_) sites in dnd_zone_cmd.go. Corrupted zone IDs render a
  placeholder instead of panicking.
- Briefing/recap idempotency: deliverBriefing/deliverRecap now claim
  the rollover via a conditional UPDATE. Double-fires from clock skew
  or restart become no-ops; supply burn and day++ no longer reapply.
- Graceful ticker shutdown: AdventurePlugin gains stopCh + Stop(); all
  11 background tickers now select on stopCh in addition to ticker.C.
- Mood decay (§3.2): math.Round(elapsed*2) replaces int() truncation
  so sub-hour gaps decay correctly.
- 24h auto-abandon (§4.3): getActiveZoneRun returns clean slate and
  abandons stale runs whose LastActionAt is over 24h old.
- Respec / auto-migrate orphan cleanup: !respec and the auto-migrated
  draft wipe now abandon active zone runs and expeditions before
  deleting the dnd_character row.
- Phase R combat-link: applyBossDefeatThreat now wired from
  resolveBossRoom (-20 threat); applyRoomCombatThreatForUser adds
  +5/+8 from non-boss/elite kills (§8.1).
- Starvation → forced extraction: briefing-time check forces extract
  with §10.2 coin tax when supplies hit zero.
- GMNat20/Nat1 narration wired into resolveCombatRoom and
  resolveBossRoom (nat-20 takes precedence over nat-1).
- Treasure-undo race: LoadAndDelete on both timer-fire and `undo`
  paths so only one side wins.
- Battle Master: Disarming, Menacing, Parry maneuvers added (3 → 6
  of 10). Remaining 4 (Pushing, Goading, Riposte, Commander's Strike)
  documented inline as needing ally/reaction mechanics the engine
  doesn't model.
- Threat-70 warning: tracked in RegionState["siege_warning_fired"]
  so a drop-and-recross doesn't re-fire the beat.
- region_state JSON decode error now logged via slog.Warn instead
  of silently discarded.

Failing TestProdDB_DnDLayer fixed via option (a): track migrated
characters and only run round-trip / idempotency assertions on those,
skipping pre-existing prod-DB rows accumulated from live bot use.

New tests in dnd_audit_phase_R7_test.go cover: 24h auto-abandon,
briefing double-fire idempotency, threat-70 warning idempotency,
multi-region extract→resume state preservation, and starvation
forced-extraction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00

164 lines
4.0 KiB
Go

package plugin
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"strconv"
"time"
"gogobee/internal/db"
)
// ── FRED API Integration ───────────────────────────────────────────────────
//
// Pulls the 5/1 ARM rate weekly from FRED (series: MORTGAGE5US).
// Requires FRED_API_KEY environment variable.
// Falls back to a hardcoded default if the API is unavailable.
const fredSeries = "MORTGAGE5US"
const defaultMortgageRate = 6.5
const currentRateCacheKey = "mortgage_current_rate"
type fredResponse struct {
Observations []struct {
Date string `json:"date"`
Value string `json:"value"`
} `json:"observations"`
}
// fetchFREDRate pulls the latest 5/1 ARM rate from the FRED API.
func fetchFREDRate() (float64, error) {
apiKey := os.Getenv("FRED_API_KEY")
if apiKey == "" {
return 0, fmt.Errorf("FRED_API_KEY not set")
}
url := fmt.Sprintf(
"https://api.stlouisfed.org/fred/series/observations?series_id=%s&api_key=%s&file_type=json&sort_order=desc&limit=1",
fredSeries, apiKey,
)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
return 0, fmt.Errorf("FRED API request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return 0, fmt.Errorf("FRED API status %d", resp.StatusCode)
}
var data fredResponse
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return 0, fmt.Errorf("FRED JSON decode: %w", err)
}
if len(data.Observations) == 0 {
return 0, fmt.Errorf("FRED: no observations returned")
}
val := data.Observations[0].Value
if val == "." {
return 0, fmt.Errorf("FRED: value is placeholder")
}
rate, err := strconv.ParseFloat(val, 64)
if err != nil {
return 0, fmt.Errorf("FRED: parse rate %q: %w", val, err)
}
// Clamp to sane range
if rate < 1.0 {
rate = 1.0
}
if rate > 15.0 {
rate = 15.0
}
return rate, nil
}
// getCurrentMortgageRate returns the cached rate, or fetches fresh if stale (daily).
func getCurrentMortgageRate() float64 {
// Check DB cache (24h TTL)
if val := db.CacheGet(currentRateCacheKey, 24*3600); val != "" {
if rate, err := strconv.ParseFloat(val, 64); err == nil {
return rate
}
}
rate, err := fetchFREDRate()
if err != nil {
slog.Warn("mortgage: FRED fetch failed, using default", "err", err)
return defaultMortgageRate
}
db.CacheSet(currentRateCacheKey, strconv.FormatFloat(rate, 'f', 2, 64))
return rate
}
// ── Weekly Mortgage Ticker ─────────────────────────────────────────────────
func (p *AdventurePlugin) mortgageTicker() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for {
select {
case <-p.stopCh:
return
case <-ticker.C:
now := time.Now().UTC()
// Run on Mondays only (FRED updates Thursdays, we process Mondays)
if now.Weekday() != time.Monday {
continue
}
_, week := now.ISOWeek()
dateKey := fmt.Sprintf("%d-W%02d", now.Year(), week)
jobName := "mortgage_weekly"
if db.JobCompleted(jobName, dateKey) {
continue
}
slog.Info("mortgage: running weekly payment processing")
// Fetch fresh rate and compare with previous
newRate := getCurrentMortgageRate()
oldRate := p.getLastKnownRate()
if oldRate > 0 && oldRate != newRate {
p.sendMortgageRateChangeDMs(oldRate, newRate)
}
p.setLastKnownRate(newRate)
// Process payments
p.processMortgagePayments()
db.MarkJobCompleted(jobName, dateKey)
}
}
}
const lastKnownRateCacheKey = "mortgage_last_known_rate"
func (p *AdventurePlugin) getLastKnownRate() float64 {
val := db.CacheGet(lastKnownRateCacheKey, 365*24*3600) // 1 year TTL
if val == "" {
return 0
}
rate, err := strconv.ParseFloat(val, 64)
if err != nil {
return 0
}
return rate
}
func (p *AdventurePlugin) setLastKnownRate(rate float64) {
db.CacheSet(lastKnownRateCacheKey, strconv.FormatFloat(rate, 'f', 2, 64))
}