mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Long expeditions D4-a: autopilot DM bundling + EoD digest
Suppress per-tick auto-walk DMs in compact mode. tryAutoRun now only surfaces for fork / death / run-complete / boss-safety camp / Night-camp pitch. A Night-camp pitch flushes the day as an EoD digest (counts of walk/harvest/interrupt plus threat/milestone/narrative bullets, built from dnd_expedition_log) followed by the camp block. Each successful background walk writes a `walk` log entry so the digest can count rooms without persisting raw stream narration. maybeAutoCamp and pitchBossSafetyCamp now return the autoCampDecision so callers can branch on dec.Night.
This commit is contained in:
@@ -96,12 +96,20 @@ When the gate trips, `tryAutoRun` calls `pitchBossSafetyCamp(exp)` (new in `expe
|
|||||||
|
|
||||||
### D4 — DM volume + day surfaces
|
### D4 — DM volume + day surfaces
|
||||||
The big risk: a 7-day T5 with autopilot walking, camping, fighting elites, and killing bosses will spam DMs unless we bundle. The user has already pushed back on recap chatter (`feedback_skip_recaps`).
|
The big risk: a 7-day T5 with autopilot walking, camping, fighting elites, and killing bosses will spam DMs unless we bundle. The user has already pushed back on recap chatter (`feedback_skip_recaps`).
|
||||||
**Work:**
|
|
||||||
- One **end-of-day DM** per autopilot night-camp, bundling: rooms walked, fights resolved, loot, camp pitched, day++ event. Replaces today's per-room auto-walk DM in compact mode.
|
**D4-a (shipped 2026-05-27):** autopilot DM bundling. `expedition_autorun.go:tryAutoRun` now drops per-tick auto-walk DMs in compact mode. New surface rules:
|
||||||
- One **morning re-engagement DM** when the player first opens the channel after a day-rollover (anchored to user activity, not UTC clock — drops the 06:00 wakeup if the player isn't around).
|
- Fork / death / run-complete still DM the walk stream — interactive + climax beats.
|
||||||
- Boss kill + run-complete still get their own DM (the climax beat, not bundled).
|
- A Night=true camp pitch flushes the day as an end-of-day digest (`renderEndOfDayDigest` in new `expedition_autorun_digest.go`) followed by the camp block.
|
||||||
- TwinBee voice: first-person only (`feedback_twinbee_voice`).
|
- Boss-safety camp pitch gets a short "holding before the boss" header + camp block (walk stream dropped).
|
||||||
- No trailing recaps (`feedback_skip_recaps`).
|
- Non-night auto-camp (mid-day rest / base-camp waypoint) surfaces the camp block by itself.
|
||||||
|
- Everything else — uneventful walks, preflight pauses, harvest interrupts — goes silent.
|
||||||
|
Each successful background walk now logs a `walk` entry (`appendExpeditionLog(... "walk" ...)`) so the digest can count rooms walked from structured logs (the raw `r.stream` narration is no longer persisted across ticks). Digest groups counts of walk/harvest/interrupt and inlines threat/milestone/narrative lines for the prior day (`dayExpeditionLog` helper). `maybeAutoCamp` + `pitchBossSafetyCamp` now return the `autoCampDecision` so `tryAutoRun` can branch on `dec.Night`.
|
||||||
|
|
||||||
|
**D4-b (pending):** morning re-engagement DM anchored to user activity, not 06:00 UTC. Drop the wakeup ping when the player is idle; post the morning DM lazily on the next inbound message after a rollover.
|
||||||
|
|
||||||
|
**Remaining work:**
|
||||||
|
- D4-b: activity-anchored morning DM (see above).
|
||||||
|
- TwinBee voice + no-recap copy pass on the digest once the shape settles.
|
||||||
|
|
||||||
**Exit criteria:** a 7-day T5 produces ≤ 10 DMs across the whole expedition (1 launch + 6 end-of-day + 1 boss + 1 run-complete + 1 emergency).
|
**Exit criteria:** a 7-day T5 produces ≤ 10 DMs across the whole expedition (1 launch + 6 end-of-day + 1 boss + 1 run-complete + 1 emergency).
|
||||||
|
|
||||||
|
|||||||
@@ -442,6 +442,34 @@ func appendExpeditionLog(expID string, day int, entryType, summary, flavor strin
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dayExpeditionLog returns every log entry recorded against the given
|
||||||
|
// (expedition, day) pair, oldest first. Used by the D4-a end-of-day
|
||||||
|
// digest to bundle a single rollup DM at night-camp pitch.
|
||||||
|
func dayExpeditionLog(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 entry_id`, 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()
|
||||||
|
}
|
||||||
|
|
||||||
// recentExpeditionLog returns the last `limit` entries, newest first.
|
// recentExpeditionLog returns the last `limit` entries, newest first.
|
||||||
func recentExpeditionLog(expID string, limit int) ([]ExpeditionEntry, error) {
|
func recentExpeditionLog(expID string, limit int) ([]ExpeditionEntry, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
|
|||||||
@@ -154,8 +154,10 @@ func decideAutopilotCamp(in autoCampInputs) (autoCampDecision, bool) {
|
|||||||
|
|
||||||
// maybeAutoCamp gathers DB state, calls decideAutopilotCamp, and pitches
|
// maybeAutoCamp gathers DB state, calls decideAutopilotCamp, and pitches
|
||||||
// when the decision says to. Returns the player-facing camp block (or
|
// when the decision says to. Returns the player-facing camp block (or
|
||||||
// "" if no camp was pitched) so the autorun DM can include it.
|
// "" if no camp was pitched), along with the decision and an ok flag.
|
||||||
func (p *AdventurePlugin) maybeAutoCamp(exp *Expedition) string {
|
// D4-a uses the Night bit on the decision to switch tryAutoRun's DM
|
||||||
|
// rendering into end-of-day digest mode.
|
||||||
|
func (p *AdventurePlugin) maybeAutoCamp(exp *Expedition) (string, autoCampDecision, bool) {
|
||||||
uid := id.UserID(exp.UserID)
|
uid := id.UserID(exp.UserID)
|
||||||
|
|
||||||
var run *DungeonRun
|
var run *DungeonRun
|
||||||
@@ -195,14 +197,14 @@ func (p *AdventurePlugin) maybeAutoCamp(exp *Expedition) string {
|
|||||||
}
|
}
|
||||||
d, ok := decideAutopilotCamp(in)
|
d, ok := decideAutopilotCamp(in)
|
||||||
if !ok {
|
if !ok {
|
||||||
return ""
|
return "", autoCampDecision{}, false
|
||||||
}
|
}
|
||||||
block, err := p.pitchAutopilotCamp(exp, d)
|
block, err := p.pitchAutopilotCamp(exp, d)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("autopilot camp: pitch failed", "expedition", exp.ID, "kind", d.Kind, "err", err)
|
slog.Warn("autopilot camp: pitch failed", "expedition", exp.ID, "kind", d.Kind, "err", err)
|
||||||
return ""
|
return "", autoCampDecision{}, false
|
||||||
}
|
}
|
||||||
return block
|
return block, d, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// pitchAutopilotCamp performs the same state mutations as the player
|
// pitchAutopilotCamp performs the same state mutations as the player
|
||||||
@@ -346,14 +348,14 @@ func renderAutoCampBlock(exp *Expedition, d autoCampDecision, cost float32, flav
|
|||||||
// time has elapsed since the last briefing on an event-anchored
|
// time has elapsed since the last briefing on an event-anchored
|
||||||
// expedition, the pitch carries Night=true and runs the burn/drift
|
// expedition, the pitch carries Night=true and runs the burn/drift
|
||||||
// alongside the rest.
|
// alongside the rest.
|
||||||
func (p *AdventurePlugin) pitchBossSafetyCamp(exp *Expedition) string {
|
func (p *AdventurePlugin) pitchBossSafetyCamp(exp *Expedition) (string, autoCampDecision, bool) {
|
||||||
if exp == nil || exp.Status != ExpeditionStatusActive {
|
if exp == nil || exp.Status != ExpeditionStatusActive {
|
||||||
return ""
|
return "", autoCampDecision{}, false
|
||||||
}
|
}
|
||||||
if exp.Camp != nil && exp.Camp.Active {
|
if exp.Camp != nil && exp.Camp.Active {
|
||||||
// Already resting — nothing to pitch. The dwell window will pass
|
// Already resting — nothing to pitch. The dwell window will pass
|
||||||
// and the next autorun tick will retry the boss engagement.
|
// and the next autorun tick will retry the boss engagement.
|
||||||
return ""
|
return "", autoCampDecision{}, false
|
||||||
}
|
}
|
||||||
kind := CampTypeStandard
|
kind := CampTypeStandard
|
||||||
if exp.Supplies.Current < campSupplyCost[kind] {
|
if exp.Supplies.Current < campSupplyCost[kind] {
|
||||||
@@ -363,7 +365,7 @@ func (p *AdventurePlugin) pitchBossSafetyCamp(exp *Expedition) string {
|
|||||||
// No SU even for Rough — autopilot can't help here. The walk's
|
// No SU even for Rough — autopilot can't help here. The walk's
|
||||||
// preflight will surface low-SU on the next tick if the player
|
// preflight will surface low-SU on the next tick if the player
|
||||||
// doesn't extract.
|
// doesn't extract.
|
||||||
return ""
|
return "", autoCampDecision{}, false
|
||||||
}
|
}
|
||||||
night := false
|
night := false
|
||||||
if isEventAnchored(exp) {
|
if isEventAnchored(exp) {
|
||||||
@@ -383,9 +385,9 @@ func (p *AdventurePlugin) pitchBossSafetyCamp(exp *Expedition) string {
|
|||||||
block, err := p.pitchAutopilotCamp(exp, d)
|
block, err := p.pitchAutopilotCamp(exp, d)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("autopilot boss-safety camp: pitch failed", "expedition", exp.ID, "kind", kind, "err", err)
|
slog.Warn("autopilot boss-safety camp: pitch failed", "expedition", exp.ID, "kind", kind, "err", err)
|
||||||
return ""
|
return "", autoCampDecision{}, false
|
||||||
}
|
}
|
||||||
return block
|
return block, d, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// breakAutoCampIfDue tears down an auto-pitched camp once it has dwelled
|
// breakAutoCampIfDue tears down an auto-pitched camp once it has dwelled
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -20,13 +21,15 @@ import (
|
|||||||
// quiet window, or on a very-fresh expedition (give them a beat).
|
// quiet window, or on a very-fresh expedition (give them a beat).
|
||||||
// • Walk up to autoRunRoomCap rooms per tick. Smaller than foreground's
|
// • Walk up to autoRunRoomCap rooms per tick. Smaller than foreground's
|
||||||
// cap so background DMs stay digestible.
|
// cap so background DMs stay digestible.
|
||||||
// • DM-suppression rules:
|
// • DM-suppression rules (D4-a — long expedition bundling):
|
||||||
// - 0 rooms walked → silent (still stuck at a fork / blocked / etc.;
|
// - Surface ONLY for: fork (player decision), death, run-complete,
|
||||||
// the player already knows, no point spamming).
|
// boss-safety camp pitch (explicit hold), or a Night=true camp
|
||||||
// - rooms > 0 with stopOK (hit room cap) → silent; we'll keep walking
|
// pitch (end-of-day digest).
|
||||||
// on the next tick. Cadence handles pacing; no "stretch complete"
|
// - Everything else — uneventful walks, preflight pauses, harvest
|
||||||
// footer churn.
|
// interrupts, mid-day rest camps — goes silent. The accumulated
|
||||||
// - Any other reason with rooms > 0 → DM the bundled walk.
|
// day reads as one EoD digest DM when the autopilot night-camps.
|
||||||
|
// - Each successful background walk logs a `walk` entry so the EoD
|
||||||
|
// digest can count rooms walked without persisting raw narration.
|
||||||
// • Idempotency: CAS-claim last_autorun_at before doing any work. A
|
// • Idempotency: CAS-claim last_autorun_at before doing any work. A
|
||||||
// double-fire on the same expedition is a no-op.
|
// double-fire on the same expedition is a no-op.
|
||||||
|
|
||||||
@@ -172,70 +175,121 @@ func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error {
|
|||||||
// "no expedition" / "no run" — race with abandon/extract. Silent.
|
// "no expedition" / "no run" — race with abandon/extract. Silent.
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// D4-a — drop a `walk` log entry per successful background walk so
|
||||||
|
// the EoD digest can count rooms walked from structured logs without
|
||||||
|
// persisting the raw stream narration we used to DM.
|
||||||
|
if r.rooms > 0 {
|
||||||
|
_ = appendExpeditionLog(e.ID, e.CurrentDay, "walk",
|
||||||
|
fmt.Sprintf("auto-walk: %d room(s)", r.rooms), "")
|
||||||
|
}
|
||||||
|
|
||||||
// Long-expedition D2-a — post-walk camp scheduler. After the walk
|
// Long-expedition D2-a — post-walk camp scheduler. After the walk
|
||||||
// settles, see if the autopilot should pitch a rest camp (HP low)
|
// settles, see if the autopilot should pitch a rest camp (HP low)
|
||||||
// or a base-camp waypoint (region boss just cleared). The walk's
|
// or a base-camp waypoint (region boss just cleared). The walk's
|
||||||
// own preflight handles low-SU pauses; the scheduler stays out of
|
// own preflight handles low-SU pauses; the scheduler stays out of
|
||||||
// fork/combat/death/complete branches by checking r.reason.
|
// fork/combat/death/complete branches by checking r.reason.
|
||||||
campBlock := ""
|
campBlock := ""
|
||||||
|
var campDecision autoCampDecision
|
||||||
|
campPitched := false
|
||||||
if r.reason == stopBossSafety {
|
if r.reason == stopBossSafety {
|
||||||
// D3 — the boss-engage gate tripped. Force-pitch a rest camp
|
// D3 — the boss-engage gate tripped. Force-pitch a rest camp
|
||||||
// regardless of decideAutopilotCamp's normal HP threshold and
|
// regardless of decideAutopilotCamp's normal HP threshold and
|
||||||
// its RoomBoss block. Next tick past dwell retries the boss.
|
// its RoomBoss block. Next tick past dwell retries the boss.
|
||||||
if fresh, ferr := getExpedition(e.ID); ferr == nil && fresh != nil &&
|
if fresh, ferr := getExpedition(e.ID); ferr == nil && fresh != nil &&
|
||||||
fresh.Status == ExpeditionStatusActive {
|
fresh.Status == ExpeditionStatusActive {
|
||||||
campBlock = p.pitchBossSafetyCamp(fresh)
|
campBlock, campDecision, campPitched = p.pitchBossSafetyCamp(fresh)
|
||||||
}
|
}
|
||||||
} else if r.reason != stopEnded && r.reason != stopComplete &&
|
} else if r.reason != stopEnded && r.reason != stopComplete &&
|
||||||
r.reason != stopBlocked && r.reason != stopFork {
|
r.reason != stopBlocked && r.reason != stopFork {
|
||||||
if fresh, ferr := getExpedition(e.ID); ferr == nil && fresh != nil &&
|
if fresh, ferr := getExpedition(e.ID); ferr == nil && fresh != nil &&
|
||||||
fresh.Status == ExpeditionStatusActive {
|
fresh.Status == ExpeditionStatusActive {
|
||||||
campBlock = p.maybeAutoCamp(fresh)
|
campBlock, campDecision, campPitched = p.maybeAutoCamp(fresh)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if campBlock != "" {
|
_ = autoCampBroken // hint reserved for downstream digest tweaks
|
||||||
r.finalMsg += campBlock
|
_ = campPitched // surfaced via campBlock != ""; kept for readability
|
||||||
|
|
||||||
|
// D4-a DM dispatch. The old per-tick auto-walk DM is retired in compact
|
||||||
|
// mode: a Night-camp pitch flushes the accumulated day as a digest;
|
||||||
|
// every other quiet path stays silent until something interactive fires.
|
||||||
|
if body, ok := buildAutoRunDM(e.ID, r, campBlock, campDecision); ok {
|
||||||
|
if err := p.SendDM(uid, body); err != nil {
|
||||||
|
slog.Warn("expedition: autorun DM", "user", uid, "err", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ = autoCampBroken // reserved for D2-b end-of-day DM bundling
|
|
||||||
|
|
||||||
// Emergence seam: a run-complete reached by the background ticker is
|
// Emergence seam: a run-complete reached by the background ticker is
|
||||||
// still a live emergence — roll pet arrival. See maybeRollPetArrivalOnEmerge.
|
// still a live emergence — roll pet arrival. See maybeRollPetArrivalOnEmerge.
|
||||||
// Deferred until after the run-summary DM below so the "animal in your
|
// Deferred until after the run-summary DM below so the "animal in your
|
||||||
// house" prompt lands after the summary, not ahead of it.
|
// house" prompt lands after the summary, not ahead of it.
|
||||||
//
|
|
||||||
// DM rule: surface anytime the regular suppression says to, OR
|
|
||||||
// whenever the autopilot pitched a camp this tick — a camp event
|
|
||||||
// is always worth a DM, even if the walk itself was quiet.
|
|
||||||
if shouldDMAutoRun(r) || campBlock != "" {
|
|
||||||
body := renderAutoRunDM(r)
|
|
||||||
if err := p.SendDM(uid, body); err != nil {
|
|
||||||
slog.Warn("expedition: autorun DM", "user", uid, "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if r.reason == stopComplete {
|
if r.reason == stopComplete {
|
||||||
p.maybeRollPetArrivalOnEmerge(uid)
|
p.maybeRollPetArrivalOnEmerge(uid)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// shouldDMAutoRun applies the background suppression rules. See the
|
// buildAutoRunDM applies D4-a's surface rules and returns the DM body to
|
||||||
// file-top design block for the rationale.
|
// send. ok=false means the tick is silent. Inputs:
|
||||||
func shouldDMAutoRun(r autopilotWalkResult) bool {
|
// - expID: the expedition row id (for the EoD digest log fetch).
|
||||||
if r.rooms == 0 {
|
// - r: walk result, including reason + accumulated stream.
|
||||||
return false
|
// - camp: rendered camp block from maybeAutoCamp / pitchBossSafetyCamp,
|
||||||
|
// or "" when no camp was pitched this tick.
|
||||||
|
// - dec: the camp decision; dec.Night is the trigger for the EoD
|
||||||
|
// digest variant. Zero-value when no pitch happened.
|
||||||
|
//
|
||||||
|
// Surface rules:
|
||||||
|
// - stopFork / stopEnded / stopComplete → render the walk DM. These
|
||||||
|
// are the interactive / climax beats and stay their own messages.
|
||||||
|
// - Night camp pitched → render the EoD digest +
|
||||||
|
// camp block. Walk stream is dropped (the digest summarizes the day).
|
||||||
|
// - Boss-safety camp pitched → short hold notice + camp
|
||||||
|
// block; walk stream dropped (compact bail was deliberate).
|
||||||
|
// - Anything else → silent.
|
||||||
|
func buildAutoRunDM(expID string, r autopilotWalkResult, camp string, dec autoCampDecision) (string, bool) {
|
||||||
|
switch r.reason {
|
||||||
|
case stopFork, stopEnded, stopComplete:
|
||||||
|
body := renderAutoRunWalkDM(r)
|
||||||
|
if camp != "" {
|
||||||
|
body += camp
|
||||||
}
|
}
|
||||||
if r.reason == stopOK {
|
return body, true
|
||||||
// Hit the per-tick room cap. Next tick will continue; no need to
|
|
||||||
// post a "stretch complete" filler DM.
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
return true
|
if camp == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if dec.Night {
|
||||||
|
// EoD digest. The camp pitch already bumped current_day in
|
||||||
|
// nightRolloverBurn, so the day-that-just-ended is CurrentDay-1.
|
||||||
|
// digest is the day rollup, then the camp block lays out the rest.
|
||||||
|
fresh, ferr := getExpedition(expID)
|
||||||
|
prevDay := 0
|
||||||
|
if ferr == nil && fresh != nil {
|
||||||
|
prevDay = fresh.CurrentDay - 1
|
||||||
|
}
|
||||||
|
digest := ""
|
||||||
|
if prevDay > 0 {
|
||||||
|
digest = renderEndOfDayDigest(expID, prevDay)
|
||||||
|
}
|
||||||
|
if digest == "" {
|
||||||
|
// No structured day yet — fall back to a thin header so the
|
||||||
|
// camp block isn't dropped on the player without context.
|
||||||
|
digest = "🌙 *The day winds down.*\n\n"
|
||||||
|
}
|
||||||
|
return digest + camp, true
|
||||||
|
}
|
||||||
|
if dec.Reason == "boss-safety hold — resting before re-engaging" {
|
||||||
|
return "⏸ *Holding before the boss — pitching a rest camp.*\n" + camp, true
|
||||||
|
}
|
||||||
|
// Non-night auto-camp (mid-day rest / base camp waypoint). Surface a
|
||||||
|
// short notice so the player can see the dungeon's decision; full
|
||||||
|
// digest waits for the night pitch.
|
||||||
|
return camp, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderAutoRunDM bundles the staged walk narration into a single DM.
|
// renderAutoRunWalkDM is the legacy concat-the-stream renderer, kept for
|
||||||
// Background can't pace via streamFlow, so we concatenate phases with a
|
// the surfaces D4-a still DMs (fork / death / run-complete).
|
||||||
// blank line between each beat and tack on the final message.
|
func renderAutoRunWalkDM(r autopilotWalkResult) string {
|
||||||
func renderAutoRunDM(r autopilotWalkResult) string {
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("🚶 *Auto-walk*\n\n")
|
b.WriteString("🚶 *Auto-walk*\n\n")
|
||||||
for _, s := range r.stream {
|
for _, s := range r.stream {
|
||||||
|
|||||||
121
internal/plugin/expedition_autorun_digest.go
Normal file
121
internal/plugin/expedition_autorun_digest.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Long-expedition D4-a — end-of-day digest renderer.
|
||||||
|
//
|
||||||
|
// When the autopilot pitches a Night=true camp the dungeon has effectively
|
||||||
|
// closed the day. Instead of dripping per-tick auto-walk DMs through the
|
||||||
|
// player's channel, D4-a suppresses those mid-day surfaces and rolls them
|
||||||
|
// into a single rollup posted alongside the night-camp block.
|
||||||
|
//
|
||||||
|
// Data source: dnd_expedition_log. The autorun ticker now writes a `walk`
|
||||||
|
// entry per successful background walk; combined with the existing
|
||||||
|
// harvest / interrupt / threat / milestone / narrative entries this gives
|
||||||
|
// the digest enough structure to summarize the day in counts + a small
|
||||||
|
// number of headline lines, without re-rendering the full per-room
|
||||||
|
// narration that the per-tick stream used to carry.
|
||||||
|
//
|
||||||
|
// Tone: terse, TwinBee first-person (feedback_twinbee_voice), no trailing
|
||||||
|
// recap paragraph (feedback_skip_recaps). The night-camp block follows
|
||||||
|
// immediately after.
|
||||||
|
|
||||||
|
// renderEndOfDayDigest pulls every log entry for (expID, prevDay) and
|
||||||
|
// returns a markdown rollup. Returns "" when prevDay has no entries —
|
||||||
|
// the caller should fall back to the bare camp block in that case.
|
||||||
|
func renderEndOfDayDigest(expID string, prevDay int) string {
|
||||||
|
entries, err := dayExpeditionLog(expID, prevDay)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("expedition: digest log fetch", "expedition", expID, "day", prevDay, "err", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
walks int
|
||||||
|
harvests int
|
||||||
|
interrupts int
|
||||||
|
threatLines []string
|
||||||
|
milestoneLine []string
|
||||||
|
narrativeBits []string
|
||||||
|
)
|
||||||
|
for _, e := range entries {
|
||||||
|
switch e.Type {
|
||||||
|
case "walk":
|
||||||
|
walks++
|
||||||
|
case "harvest":
|
||||||
|
// Only count successful gathers — failed rolls / errors are noise.
|
||||||
|
if strings.Contains(e.Summary, "success") {
|
||||||
|
harvests++
|
||||||
|
}
|
||||||
|
case "interrupt":
|
||||||
|
interrupts++
|
||||||
|
case "threat":
|
||||||
|
if e.Flavor != "" {
|
||||||
|
threatLines = append(threatLines, e.Flavor)
|
||||||
|
} else if e.Summary != "" {
|
||||||
|
threatLines = append(threatLines, e.Summary)
|
||||||
|
}
|
||||||
|
case "milestone":
|
||||||
|
milestoneLine = append(milestoneLine, e.Summary)
|
||||||
|
case "narrative":
|
||||||
|
// Surface real narrative beats (camp broken / extraction prompts),
|
||||||
|
// skip housekeeping ones.
|
||||||
|
if strings.Contains(e.Summary, "autopilot broke camp") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
narrativeBits = append(narrativeBits, e.Summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(fmt.Sprintf("📜 *Day %d wraps up.*\n\n", prevDay))
|
||||||
|
bulleted := false
|
||||||
|
if walks > 0 {
|
||||||
|
b.WriteString(fmt.Sprintf("• Walked **%d** auto-tick%s through the dark.\n", walks, pluralS(walks)))
|
||||||
|
bulleted = true
|
||||||
|
}
|
||||||
|
if interrupts > 0 {
|
||||||
|
b.WriteString(fmt.Sprintf("• Handled **%d** interrupt%s along the way.\n", interrupts, pluralS(interrupts)))
|
||||||
|
bulleted = true
|
||||||
|
}
|
||||||
|
if harvests > 0 {
|
||||||
|
b.WriteString(fmt.Sprintf("• Came back with **%d** harvest%s.\n", harvests, pluralS(harvests)))
|
||||||
|
bulleted = true
|
||||||
|
}
|
||||||
|
for _, t := range threatLines {
|
||||||
|
b.WriteString("• ")
|
||||||
|
b.WriteString(t)
|
||||||
|
b.WriteString("\n")
|
||||||
|
bulleted = true
|
||||||
|
}
|
||||||
|
for _, m := range milestoneLine {
|
||||||
|
b.WriteString("• ")
|
||||||
|
b.WriteString(m)
|
||||||
|
b.WriteString("\n")
|
||||||
|
bulleted = true
|
||||||
|
}
|
||||||
|
for _, n := range narrativeBits {
|
||||||
|
b.WriteString("• ")
|
||||||
|
b.WriteString(n)
|
||||||
|
b.WriteString("\n")
|
||||||
|
bulleted = true
|
||||||
|
}
|
||||||
|
if !bulleted {
|
||||||
|
// All entries were filtered out — fall back to the bare camp block.
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pluralS(n int) string {
|
||||||
|
if n == 1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "s"
|
||||||
|
}
|
||||||
124
internal/plugin/expedition_autorun_digest_test.go
Normal file
124
internal/plugin/expedition_autorun_digest_test.go
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Long-expedition D4-a — verifies the EoD digest renderer pulls only
|
||||||
|
// the prior day's structured entries and that buildAutoRunDM's surface
|
||||||
|
// rules suppress / surface the right ticks.
|
||||||
|
|
||||||
|
func TestRenderEndOfDayDigest_GroupsByType(t *testing.T) {
|
||||||
|
setupZoneRunTestDB(t)
|
||||||
|
uid := id.UserID("@digest:example")
|
||||||
|
campTestCharacter(t, uid, 1)
|
||||||
|
defer cleanupExpeditions(uid)
|
||||||
|
|
||||||
|
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
|
||||||
|
ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
day := exp.CurrentDay
|
||||||
|
for _, e := range []struct{ kind, summary, flavor string }{
|
||||||
|
{"walk", "auto-walk: 3 room(s)", ""},
|
||||||
|
{"walk", "auto-walk: 2 room(s)", ""},
|
||||||
|
{"harvest", "autopilot harvest oak success", ""},
|
||||||
|
{"harvest", "autopilot harvest iron failure", ""}, // not counted
|
||||||
|
{"interrupt", "noise alarm total=1", ""},
|
||||||
|
{"threat", "drift", "The dungeon stirs awake."},
|
||||||
|
{"milestone", "milestone awarded: first-night", ""},
|
||||||
|
{"narrative", "autopilot broke camp (dwell elapsed)", ""}, // filtered
|
||||||
|
{"narrative", "voluntary extraction prompt", ""},
|
||||||
|
} {
|
||||||
|
if err := appendExpeditionLog(exp.ID, day, e.kind, e.summary, e.flavor); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Entry from a different day must NOT bleed in.
|
||||||
|
if err := appendExpeditionLog(exp.ID, day+1, "walk", "next day", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := renderEndOfDayDigest(exp.ID, day)
|
||||||
|
if got == "" {
|
||||||
|
t.Fatal("expected non-empty digest")
|
||||||
|
}
|
||||||
|
wantSubs := []string{
|
||||||
|
"Walked **2**",
|
||||||
|
"Handled **1** interrupt",
|
||||||
|
"**1** harvest",
|
||||||
|
"dungeon stirs awake",
|
||||||
|
"milestone awarded: first-night",
|
||||||
|
"voluntary extraction prompt",
|
||||||
|
}
|
||||||
|
for _, s := range wantSubs {
|
||||||
|
if !strings.Contains(got, s) {
|
||||||
|
t.Errorf("digest missing %q\n--- got ---\n%s", s, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "autopilot broke camp") {
|
||||||
|
t.Errorf("digest should filter housekeeping narrative\n%s", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "next day") {
|
||||||
|
t.Errorf("digest leaked entry from a later day\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderEndOfDayDigest_EmptyDayReturnsEmpty(t *testing.T) {
|
||||||
|
setupZoneRunTestDB(t)
|
||||||
|
uid := id.UserID("@digest-empty:example")
|
||||||
|
campTestCharacter(t, uid, 1)
|
||||||
|
defer cleanupExpeditions(uid)
|
||||||
|
|
||||||
|
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
|
||||||
|
ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := renderEndOfDayDigest(exp.ID, exp.CurrentDay); got != "" {
|
||||||
|
t.Errorf("expected empty digest for a day with no entries, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAutoRunDM_SuppressesQuietTick(t *testing.T) {
|
||||||
|
r := autopilotWalkResult{rooms: 3, reason: stopOK, stream: []string{"…walked…"}}
|
||||||
|
body, ok := buildAutoRunDM("expid", r, "", autoCampDecision{})
|
||||||
|
if ok || body != "" {
|
||||||
|
t.Errorf("quiet stopOK tick should be silent, got ok=%v body=%q", ok, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAutoRunDM_ForkSurfaces(t *testing.T) {
|
||||||
|
r := autopilotWalkResult{rooms: 1, reason: stopFork, finalMsg: "pick a path"}
|
||||||
|
body, ok := buildAutoRunDM("expid", r, "", autoCampDecision{})
|
||||||
|
if !ok || !strings.Contains(body, "pick a path") {
|
||||||
|
t.Errorf("fork should surface, got ok=%v body=%q", ok, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAutoRunDM_NonNightCampSurfaces(t *testing.T) {
|
||||||
|
r := autopilotWalkResult{rooms: 2, reason: stopPreflight, finalMsg: "low hp"}
|
||||||
|
camp := "\n\n⛺ **Autopilot camp**"
|
||||||
|
body, ok := buildAutoRunDM("expid", r, camp, autoCampDecision{Kind: CampTypeRough})
|
||||||
|
if !ok || !strings.Contains(body, "Autopilot camp") {
|
||||||
|
t.Errorf("mid-day camp pitch should surface camp block, got ok=%v body=%q", ok, body)
|
||||||
|
}
|
||||||
|
// Walk stream/final message dropped — D4-a suppresses the walk narration.
|
||||||
|
if strings.Contains(body, "low hp") {
|
||||||
|
t.Errorf("non-night camp DM should not carry walk finalMsg, got %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAutoRunDM_BossSafetyHasHoldHeader(t *testing.T) {
|
||||||
|
r := autopilotWalkResult{rooms: 0, reason: stopBossSafety}
|
||||||
|
camp := "\n\n⛺ **Autopilot camp — Standard.**"
|
||||||
|
dec := autoCampDecision{Kind: CampTypeStandard, Reason: "boss-safety hold — resting before re-engaging"}
|
||||||
|
body, ok := buildAutoRunDM("expid", r, camp, dec)
|
||||||
|
if !ok || !strings.Contains(body, "Holding before the boss") {
|
||||||
|
t.Errorf("boss-safety camp should prepend hold header, got ok=%v body=%q", ok, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user