mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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>
493 lines
16 KiB
Go
493 lines
16 KiB
Go
package plugin
|
||
|
||
import (
|
||
"database/sql"
|
||
"fmt"
|
||
"log/slog"
|
||
"strings"
|
||
"time"
|
||
|
||
"gogobee/internal/db"
|
||
"gogobee/internal/flavor"
|
||
"maunium.net/go/mautrix/id"
|
||
)
|
||
|
||
// Phase 12 E1d — real-time day cycle engine.
|
||
// Spec: gogobee_expedition_system.md §3, §12.
|
||
//
|
||
// Two tickers run at 1-minute granularity: briefings fire once per UTC day
|
||
// at 06:00, recaps once per UTC day at 21:00. Each tick walks all active
|
||
// expeditions and fires for any whose last_briefing_at / last_recap_at is
|
||
// stale relative to today's threshold. Per-expedition gating (rather than
|
||
// global JobCompleted) lets a player who started mid-day still receive a
|
||
// briefing the next morning even if some other expedition already triggered
|
||
// the global ticker.
|
||
//
|
||
// Day rollover: the briefing also bumps current_day by one and applies one
|
||
// day's supply burn. Day 1's briefing fires the first morning *after* the
|
||
// expedition begins (so an expedition started at 23:00 doesn't immediately
|
||
// flip to Day 2 at midnight; the spec treats Day 1 as the calendar day
|
||
// of departure).
|
||
|
||
const (
|
||
expeditionBriefingHour = 6
|
||
expeditionRecapHour = 21
|
||
)
|
||
|
||
// expeditionBriefingTicker — 06:00 UTC daily briefing.
|
||
func (p *AdventurePlugin) expeditionBriefingTicker() {
|
||
ticker := time.NewTicker(1 * time.Minute)
|
||
defer ticker.Stop()
|
||
for {
|
||
select {
|
||
case <-p.stopCh:
|
||
return
|
||
case <-ticker.C:
|
||
now := time.Now().UTC()
|
||
if now.Hour() != expeditionBriefingHour || now.Minute() != 0 {
|
||
continue
|
||
}
|
||
p.fireExpeditionBriefings(now)
|
||
}
|
||
}
|
||
}
|
||
|
||
// expeditionRecapTicker — 21:00 UTC daily recap.
|
||
func (p *AdventurePlugin) expeditionRecapTicker() {
|
||
ticker := time.NewTicker(1 * time.Minute)
|
||
defer ticker.Stop()
|
||
for {
|
||
select {
|
||
case <-p.stopCh:
|
||
return
|
||
case <-ticker.C:
|
||
now := time.Now().UTC()
|
||
if now.Hour() != expeditionRecapHour || now.Minute() != 0 {
|
||
continue
|
||
}
|
||
p.fireExpeditionRecaps(now)
|
||
}
|
||
}
|
||
}
|
||
|
||
// fireExpeditionBriefings finds every active expedition without a briefing
|
||
// for today's UTC date and fires one per. Public-on-package for testing.
|
||
func (p *AdventurePlugin) fireExpeditionBriefings(now time.Time) {
|
||
threshold := time.Date(now.Year(), now.Month(), now.Day(),
|
||
expeditionBriefingHour, 0, 0, 0, time.UTC)
|
||
exps, err := loadExpeditionsNeedingBriefing(threshold)
|
||
if err != nil {
|
||
slog.Error("expedition: load briefings", "err", err)
|
||
return
|
||
}
|
||
for _, e := range exps {
|
||
if err := p.deliverBriefing(e, now); err != nil {
|
||
slog.Error("expedition: briefing", "expedition", e.ID, "err", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// fireExpeditionRecaps finds every active expedition without a recap for
|
||
// today's UTC date and fires one per.
|
||
func (p *AdventurePlugin) fireExpeditionRecaps(now time.Time) {
|
||
threshold := time.Date(now.Year(), now.Month(), now.Day(),
|
||
expeditionRecapHour, 0, 0, 0, time.UTC)
|
||
exps, err := loadExpeditionsNeedingRecap(threshold)
|
||
if err != nil {
|
||
slog.Error("expedition: load recaps", "err", err)
|
||
return
|
||
}
|
||
for _, e := range exps {
|
||
if err := p.deliverRecap(e, now); err != nil {
|
||
slog.Error("expedition: recap", "expedition", e.ID, "err", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// loadExpeditionsNeedingBriefing — active expeditions whose last_briefing_at
|
||
// is NULL or strictly before today's 06:00 UTC.
|
||
func loadExpeditionsNeedingBriefing(threshold time.Time) ([]*Expedition, error) {
|
||
rows, err := db.Get().Query(`
|
||
SELECT expedition_id, user_id, zone_id, run_id, status,
|
||
start_date, current_day, current_region, boss_defeated,
|
||
supplies_json, camp_json, threat_level, threat_siege,
|
||
threat_events, temporal_stack, region_state,
|
||
xp_earned, coins_earned, gm_mood,
|
||
last_briefing_at, last_recap_at, last_activity, completed_at
|
||
FROM dnd_expedition
|
||
WHERE status = 'active'
|
||
AND start_date < ?
|
||
AND (last_briefing_at IS NULL OR last_briefing_at < ?)`,
|
||
threshold, threshold)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
return scanExpeditionRows(rows)
|
||
}
|
||
|
||
// loadExpeditionsNeedingRecap — same but for the 21:00 threshold.
|
||
func loadExpeditionsNeedingRecap(threshold time.Time) ([]*Expedition, error) {
|
||
rows, err := db.Get().Query(`
|
||
SELECT expedition_id, user_id, zone_id, run_id, status,
|
||
start_date, current_day, current_region, boss_defeated,
|
||
supplies_json, camp_json, threat_level, threat_siege,
|
||
threat_events, temporal_stack, region_state,
|
||
xp_earned, coins_earned, gm_mood,
|
||
last_briefing_at, last_recap_at, last_activity, completed_at
|
||
FROM dnd_expedition
|
||
WHERE status = 'active'
|
||
AND (last_recap_at IS NULL OR last_recap_at < ?)`, threshold)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
return scanExpeditionRows(rows)
|
||
}
|
||
|
||
func scanExpeditionRows(rows *sql.Rows) ([]*Expedition, error) {
|
||
var out []*Expedition
|
||
for rows.Next() {
|
||
e, err := scanExpedition(rows)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, e)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// deliverBriefing rolls a day forward, applies supply burn, posts the
|
||
// morning briefing DM, appends a log entry, and stamps last_briefing_at.
|
||
//
|
||
// Idempotency: the first thing we do is an atomic compare-and-set on
|
||
// last_briefing_at. If another invocation (clock skew, restart, double
|
||
// fire) already claimed today's rollover, rowsAffected == 0 and we bail
|
||
// without re-applying supply burn / day++ / threat drift.
|
||
func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
|
||
threshold := time.Date(now.Year(), now.Month(), now.Day(),
|
||
expeditionBriefingHour, 0, 0, 0, time.UTC)
|
||
res, err := db.Get().Exec(`
|
||
UPDATE dnd_expedition
|
||
SET last_briefing_at = ?,
|
||
last_activity = ?
|
||
WHERE expedition_id = ?
|
||
AND status = 'active'
|
||
AND (last_briefing_at IS NULL OR last_briefing_at < ?)`,
|
||
now, now, e.ID, threshold)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if n, _ := res.RowsAffected(); n == 0 {
|
||
return nil // already delivered for this day
|
||
}
|
||
|
||
// E3: zone-specific temporal events fire BEFORE applyDailyBurn and
|
||
// can override the entire burn calculation with a fixed multiplier
|
||
// (Sunken Temple tidal 2.0×, Feywild half-day 0.5×, etc.).
|
||
burnOverride := applyZoneTemporalPreBurn(e, e.CurrentDay+1)
|
||
|
||
// Advance day + supply burn happen together at the morning rollover.
|
||
var newSupplies ExpeditionSupplies
|
||
var burn float32
|
||
if burnOverride.Multiplier > 0 {
|
||
burn = e.Supplies.DailyBurn * burnOverride.Multiplier
|
||
newSupplies = e.Supplies
|
||
newSupplies.Current -= burn
|
||
if newSupplies.Current < 0 {
|
||
newSupplies.Current = 0
|
||
}
|
||
newSupplies.ForagedToday = false
|
||
} else {
|
||
harsh := e.ThreatLevel > 60 || zoneTemporalHarsh(e)
|
||
newSupplies, burn = applyDailyBurn(e.Supplies, harsh, e.SiegeMode)
|
||
}
|
||
if err := updateSupplies(e.ID, newSupplies); err != nil {
|
||
return err
|
||
}
|
||
if err := advanceExpeditionDay(e.ID); err != nil {
|
||
return err
|
||
}
|
||
e.Supplies = newSupplies
|
||
e.CurrentDay++
|
||
|
||
// E2d: overnight camp rest effects (HP/spells/threat). Auto-breaks
|
||
// the camp after applying. Run before threat drift so a fortified
|
||
// camp's −5 lands first and a same-day +3 drift can't push back over
|
||
// a threshold the rest just dropped.
|
||
restSummary := processOvernightCamp(e)
|
||
if restSummary != "" {
|
||
if fresh, err := getExpedition(e.ID); err == nil && fresh != nil {
|
||
e.ThreatLevel = fresh.ThreatLevel
|
||
e.SiegeMode = fresh.SiegeMode
|
||
e.Camp = fresh.Camp
|
||
}
|
||
}
|
||
|
||
// E2a: daily threat drift (+3 base, GM-mood modifier). No-op after
|
||
// boss kill. May cross a threshold and append a flavor-bearing log.
|
||
if _, _, err := applyDailyThreatDrift(e); err != nil {
|
||
slog.Warn("expedition: threat drift", "expedition", e.ID, "err", err)
|
||
}
|
||
|
||
// E3: zone temporal events post-rollover narration (after the day
|
||
// has advanced, so e.CurrentDay reflects the new day).
|
||
temporalLines := applyZoneTemporalPostRollover(e)
|
||
|
||
// §4.3: starvation triggers forced extraction. With no CON tracking
|
||
// in this layer, the briefing-time check is the practical equivalent
|
||
// of "CON reaches 0" — a starvation morning means the player can't
|
||
// reasonably press on. Apply the §10.2 coin tax and flip status.
|
||
if supplyDepletion(e.Supplies) == SupplyStarvation && e.Status == ExpeditionStatusActive {
|
||
_, _, _ = forcedExtractExpedition(e.ID, "starvation")
|
||
e.Status = ExpeditionStatusAbandoned
|
||
line := flavor.Pick(flavor.ExtractionForced)
|
||
_ = appendExpeditionLog(e.ID, e.CurrentDay, "narrative",
|
||
"forced extraction: starvation", line)
|
||
}
|
||
|
||
// E5b: if a temporal event (or starvation above) forced extraction,
|
||
// apply the §10.2 coin tax. The temporal layer flips the row to
|
||
// 'abandoned'; the cycle holds the euro handle to do the debit.
|
||
if e.Status == ExpeditionStatusAbandoned && p.euro != nil {
|
||
tax := int(float64(e.CoinsEarned) * forcedExtractCoinTaxFrac)
|
||
if tax > 0 {
|
||
p.euro.Debit(id.UserID(e.UserID), float64(tax),
|
||
"forced extraction tax")
|
||
}
|
||
}
|
||
|
||
// E6b: sample today's threat into RegionState["max_threat_seen"] before
|
||
// any milestone check reads it; then award daily milestones reached by
|
||
// the new day count (First Night day 2, Week One day 8, Two Weeks day 15).
|
||
_ = recordMaxThreat(e)
|
||
milestoneLines := p.checkDailyMilestones(e)
|
||
|
||
line := pickMorningBriefing(e.CurrentDay)
|
||
body := renderMorningBriefing(e, line, burn)
|
||
if restSummary != "" {
|
||
body += "\n💤 _" + restSummary + "_\n"
|
||
}
|
||
for _, tl := range temporalLines {
|
||
body += "\n🌀 " + tl + "\n"
|
||
}
|
||
for _, ml := range milestoneLines {
|
||
body += "\n" + ml
|
||
}
|
||
|
||
if uid := id.UserID(e.UserID); uid != "" {
|
||
if err := p.SendDM(uid, body); err != nil {
|
||
slog.Warn("expedition: send briefing DM", "user", uid, "err", err)
|
||
}
|
||
}
|
||
if err := appendExpeditionLog(e.ID, e.CurrentDay, "briefing",
|
||
fmt.Sprintf("morning briefing — %.1f SU consumed overnight", burn), line); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// deliverRecap posts the evening recap DM, appends a log entry, and stamps
|
||
// last_recap_at. No supply burn here — that's the briefing's job.
|
||
// Idempotency: same pattern as deliverBriefing — claim last_recap_at first.
|
||
func (p *AdventurePlugin) deliverRecap(e *Expedition, now time.Time) error {
|
||
threshold := time.Date(now.Year(), now.Month(), now.Day(),
|
||
expeditionRecapHour, 0, 0, 0, time.UTC)
|
||
res, err := db.Get().Exec(`
|
||
UPDATE dnd_expedition
|
||
SET last_recap_at = ?,
|
||
last_activity = ?
|
||
WHERE expedition_id = ?
|
||
AND status = 'active'
|
||
AND (last_recap_at IS NULL OR last_recap_at < ?)`,
|
||
now, now, e.ID, threshold)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if n, _ := res.RowsAffected(); n == 0 {
|
||
return nil
|
||
}
|
||
|
||
// E2b: night phase wandering check fires before the recap so its
|
||
// outcome is part of today's log when the recap renders.
|
||
var night *NightCheck
|
||
if e.Camp != nil && e.Camp.Active {
|
||
c, _ := LoadDnDCharacter(id.UserID(e.UserID))
|
||
var charClass DnDClass
|
||
if c != nil {
|
||
charClass = c.Class
|
||
}
|
||
nc := resolveWanderingCheck(e, charClass, nil)
|
||
if err := processNightCheck(e, nc); err != nil {
|
||
slog.Warn("expedition: night check", "expedition", e.ID, "err", err)
|
||
}
|
||
night = &nc
|
||
// §7.4: Feywild double-day fires an extra wandering check.
|
||
if e.ZoneID == ZoneFeywildCrossing {
|
||
if today, _ := e.RegionState["feywild_today"].(string); today == string(FeywildDistortionDouble) {
|
||
extra := resolveWanderingCheck(e, charClass, nil)
|
||
if err := processNightCheck(e, extra); err != nil {
|
||
slog.Warn("expedition: feywild extra night check", "expedition", e.ID, "err", err)
|
||
}
|
||
}
|
||
}
|
||
// Refresh in-memory threat after possible signs-of-passage bump.
|
||
if fresh, err := getExpedition(e.ID); err == nil && fresh != nil {
|
||
e.ThreatLevel = fresh.ThreatLevel
|
||
e.SiegeMode = fresh.SiegeMode
|
||
}
|
||
}
|
||
|
||
dayEntries, err := dayLogEntries(e.ID, e.CurrentDay)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
line := pickEveningRecap(e, dayEntries)
|
||
body := renderEveningRecap(e, line, dayEntries)
|
||
if night != nil {
|
||
body += "\n" + renderNightCheck(*night)
|
||
}
|
||
|
||
if uid := id.UserID(e.UserID); uid != "" {
|
||
if err := p.SendDM(uid, body); err != nil {
|
||
slog.Warn("expedition: send recap DM", "user", uid, "err", err)
|
||
}
|
||
}
|
||
if err := appendExpeditionLog(e.ID, e.CurrentDay, "recap",
|
||
fmt.Sprintf("evening recap — %d log entries today", len(dayEntries)), line); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// pickMorningBriefing returns a flavor line based on day-band: Day 1, 3, 7,
|
||
// 14, 21 each have their own pool; everything else uses the generic pool.
|
||
func pickMorningBriefing(day int) string {
|
||
var pool []string
|
||
switch day {
|
||
case 1:
|
||
pool = flavor.MorningBriefingDay1
|
||
case 3:
|
||
pool = flavor.MorningBriefingDay3
|
||
case 7:
|
||
pool = flavor.MorningBriefingDay7
|
||
case 14:
|
||
pool = flavor.MorningBriefingDay14
|
||
case 21:
|
||
pool = flavor.MorningBriefingDay21
|
||
default:
|
||
pool = flavor.MorningBriefingGeneric
|
||
}
|
||
if line := flavor.Pick(pool); line != "" {
|
||
return line
|
||
}
|
||
return flavor.Pick(flavor.MorningBriefingGeneric)
|
||
}
|
||
|
||
// pickEveningRecap chooses a recap line based on what happened today.
|
||
// Boss kill > nothing-happened > generic. Close-call detection isn't wired
|
||
// in E1d (no HP-delta tracking yet); deferred to E2 alongside the threat
|
||
// clock combat hooks.
|
||
func pickEveningRecap(e *Expedition, today []ExpeditionEntry) string {
|
||
bossKilled := false
|
||
combatCount := 0
|
||
for _, en := range today {
|
||
if en.Type == "combat" {
|
||
combatCount++
|
||
}
|
||
if en.Type == "boss" || strings.Contains(strings.ToLower(en.Summary), "boss defeated") {
|
||
bossKilled = true
|
||
}
|
||
}
|
||
if bossKilled {
|
||
if l := flavor.Pick(flavor.EveningRecapBossKilled); l != "" {
|
||
return l
|
||
}
|
||
}
|
||
// "Nothing happened" = no combat, no narrative-action entries today.
|
||
if combatCount == 0 && len(today) <= 1 {
|
||
if l := flavor.Pick(flavor.EveningRecapNothingHappened); l != "" {
|
||
return l
|
||
}
|
||
}
|
||
return flavor.Pick(flavor.EveningRecapGeneric)
|
||
}
|
||
|
||
// dayLogEntries returns log entries for a specific expedition-day, oldest first.
|
||
func dayLogEntries(expID string, day int) ([]ExpeditionEntry, error) {
|
||
rows, err := db.Get().Query(`
|
||
SELECT entry_id, expedition_id, day, timestamp, entry_type, summary, flavor
|
||
FROM dnd_expedition_log
|
||
WHERE expedition_id = ? AND day = ?
|
||
ORDER BY timestamp ASC, entry_id ASC`, expID, day)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
var out []ExpeditionEntry
|
||
for rows.Next() {
|
||
var e ExpeditionEntry
|
||
if err := rows.Scan(
|
||
&e.EntryID, &e.ExpeditionID, &e.Day, &e.Timestamp,
|
||
&e.Type, &e.Summary, &e.Flavor,
|
||
); err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, e)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// renderMorningBriefing builds the §12.1 block.
|
||
func renderMorningBriefing(e *Expedition, line string, burnConsumed float32) string {
|
||
zone, _ := getZone(e.ZoneID)
|
||
burn := currentBurn(e)
|
||
var b strings.Builder
|
||
b.WriteString(fmt.Sprintf("🎭 **TwinBee — Morning Briefing, Day %d**\n\n", e.CurrentDay))
|
||
b.WriteString(fmt.Sprintf("📍 **Zone:** %s _(T%d)_\n", zone.Display, int(zone.Tier)))
|
||
b.WriteString(fmt.Sprintf("🎒 **Supplies:** %.1f / %.1f SU _(burned %.1f overnight, ~%d days left)_\n",
|
||
e.Supplies.Current, e.Supplies.Max, burnConsumed, estimateDays(e.Supplies.Current, burn)))
|
||
b.WriteString(fmt.Sprintf("⏰ **Threat:** %d / 100 — %s\n",
|
||
e.ThreatLevel, threatThresholdLabel(e.ThreatLevel, e.SiegeMode)))
|
||
if e.TemporalStack != 0 {
|
||
b.WriteString(fmt.Sprintf("🌡 **Zone stack:** %d\n", e.TemporalStack))
|
||
}
|
||
if state := supplyDepletion(e.Supplies); state != SupplyNormal {
|
||
b.WriteString(fmt.Sprintf("⚠ **%s** (roll %d)\n",
|
||
depletionLabel(state), supplyRollModifier(state)))
|
||
}
|
||
if line != "" {
|
||
b.WriteString("\n")
|
||
b.WriteString(line)
|
||
b.WriteString("\n")
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// renderEveningRecap builds the §12.2 block.
|
||
func renderEveningRecap(e *Expedition, line string, today []ExpeditionEntry) string {
|
||
zone, _ := getZone(e.ZoneID)
|
||
combatCount := 0
|
||
for _, en := range today {
|
||
if en.Type == "combat" {
|
||
combatCount++
|
||
}
|
||
}
|
||
var b strings.Builder
|
||
b.WriteString(fmt.Sprintf("🎭 **TwinBee — Evening Recap, Day %d**\n\n", e.CurrentDay))
|
||
b.WriteString(fmt.Sprintf("📍 **Zone:** %s\n", zone.Display))
|
||
b.WriteString(fmt.Sprintf("🎒 **Supplies:** %.1f / %.1f SU\n", e.Supplies.Current, e.Supplies.Max))
|
||
b.WriteString(fmt.Sprintf("⚔ **Combat encounters today:** %d\n", combatCount))
|
||
b.WriteString(fmt.Sprintf("⏰ **Threat:** %d / 100 — %s\n",
|
||
e.ThreatLevel, threatThresholdLabel(e.ThreatLevel, e.SiegeMode)))
|
||
if e.Camp == nil || !e.Camp.Active {
|
||
b.WriteString("\n_You haven't camped tonight. The dungeon does not._\n")
|
||
}
|
||
if line != "" {
|
||
b.WriteString("\n")
|
||
b.WriteString(line)
|
||
b.WriteString("\n")
|
||
}
|
||
return b.String()
|
||
}
|