22 Commits

Author SHA1 Message Date
prosolis
92b99a0399 Camp: heal on pitch; ambient cooldown 3h → 6h
!camp now applies long-rest effects (HP, spell slots, exhaustion,
threat -5, Underforge heat) immediately instead of waiting for the
06:00 UTC briefing. New CampState.RestApplied flag stops
processOvernightCamp from re-applying at briefing — it just strikes
the camp.

Also dials ambient cooldown 3h → 6h so a workday gives ~1–2 hits
instead of ~3.
2026-05-27 16:57:57 -07:00
prosolis
0f72484653 Idle reaper: use unified activity oracle, hold streak on autopilot
midnightReset gated shame DMs on LastActionDate + a fallback busy check
for active expedition / combat session. Players running on background
autopilot got shamed at midnight: dnd_zone_cmd.go gates markActedToday
on !compact (to keep autopilot out of streak credit), so LastActionDate
stayed stale; and if the autopilot's expedition completed / extracted /
got force-extracted before midnight, the busy check also missed it
(getActiveExpedition only matches status='active').

Switch the reaper to loadAdvDailyActivity — the same oracle the daily
report uses, which unions adventure_activity_log + dnd_zone_run +
dnd_expedition_log. Three explicit branches:

  - engaged (LastActionDate today/yesterday or legacy counters bumped)
    → streak bumps, LastActionDate restamped
  - activity-without-tap (autopilot beats, expedition log, or live
    active expedition/combat as safety net) → hold: no bump, no shame,
    no decay
  - truly idle → shame DM + streak halve

The old busy branch bumped streak; new behavior holds. Aligns autopilot
days with mid-fight-at-midnight days: both engaged earlier but typed
nothing today, neither pumps the streak.

Tests cover all three branches + the death-window early-return.
2026-05-23 10:30:47 -07:00
prosolis
2e6274c1b7 Spiritual Weapon: own channel + narration; rest blocked mid-zone
Spiritual Weapon used to ride the pet-attack channel, so a petless
cleric saw "🐾 Your faithful companion" each round and couldn't tell
the spell was firing. Split it to SpiritWeaponProc/Dmg with its own
 flavor; damage now scales with spell mod + upcast.

Rest also fired mid-dungeon — only the autorun honored RestingUntil,
the !rest commands themselves had no gate. Block both short and long
rest when an expedition or combat session is active.
2026-05-23 10:14:51 -07:00
prosolis
9eed921e4b Camp: auto-break when party moves to a different room
Camp was a stationary "rest here until the next briefing" intent, but
nothing struck it when the autopilot / !zone advance / fork picks walked
the party into a new room. The stale CampState then blocked !camp <type>
with "already camped" even though the player was several rooms away.

autoBreakCampOnMove clears the camp (no rest bonuses — those only land
at briefing time via processOvernightCamp) whenever Camp.RoomIndex no
longer matches run.CurrentRoom. Wired into advanceOnceWithOpts (covers
advance/autopilot, complete/fork/next-room branches all surface a
" Camp struck" banner) and zoneCmdGo (fork commit).
2026-05-23 10:05:19 -07:00
prosolis
2ce56cf76a Combat narration: format magnitude for stat_drain/debuff/ally_buff
These three stateful enemy-effect cases emitted their flavor pool via bare
pickRand, leaking the literal %d placeholder to players ("-%d damage on
every strike from here"). The magnitude is carried in CombatEvent.Damage,
exactly like the already-correct max_hp_drain / spell_fizzle cases; wrap
them in fmt.Sprintf. Add a regression test asserting no placeholder leaks.
2026-05-22 09:26:17 -07:00
prosolis
7dbfa0b56f Pet adoption: stop double-deleting pending, pass interaction to resolvePetName
resolvePendingInteraction deletes the pending entry before dispatch, but
resolvePetName then re-ran LoadAndDelete to recover the carried PetType.
That second lookup always missed, so the if !ok branch returned nil: the
save never ran and no pet was ever persisted (adoption was 100% broken).

Pass the already-loaded interaction in, matching every other handler.
Add a regression test driving the real dispatcher path.
2026-05-22 08:42:47 -07:00
prosolis
b167882e3e expedition-sim: -pet-level flag to model a base housing pet
Synthetic sim chars are vanilla base-class (no pet, no subclass), so the
per-round pet attack/deflect/whiff path was never exercised in the sim.
Add -pet-level N (1-10) which stamps a base Massive Dog (no armor) onto the
AdventureCharacter via the normal save path before the run, so combat's
DerivePlayerStats sees HasPet()==true. 0 (default) stays petless.

Measured lift (n=40, 10 classes x L3/7/12 x 3 zones): overall clear-rate
38.3% petless -> 47.8% at pet L10 (+9.4pp); biggest gains go to the caster
trailers (bard +13.3, warlock +11.9, cleric +11.4), narrowing class spread.
2026-05-22 08:28:51 -07:00
prosolis
95e0995c7f Forex/camp review hardening: bound CoinGecko body, escape URL params, fail-open camp on DB error
- forex_crypto.go: cap the CoinGecko response with io.LimitReader(64KiB);
  build the simple/price query via url.Values instead of bare Sprintf.
- dnd_expedition_camp.go: a combat-session lookup error now fails open
  (allow camp) + logs a warning, instead of blocking standard camp with a
  misleading empty 'not cleared' rejection.
2026-05-22 07:25:08 -07:00
prosolis
56f896b941 Pet arrival: fire after run narration, not before
The run-complete emergence seam rolled the pet-arrival DM synchronously,
then handed the run narration to streamFlow's paced (fire-and-forget)
streamer. The 'animal in your house' prompt landed ahead of the queued
'Run complete' + loot beats. Add streamFlowThen to run the roll after the
final message is delivered; fix the same inverted order in tryAutoRun.
2026-05-22 07:10:51 -07:00
prosolis
ef8fbe5496 Phase-H review fixes: camp, fork streak, pet proc, BG transit, legacy streak
- Camp: campLocationCheck rejects only on live combat; no-encounter and
  post-kill rooms are campable (kills the misleading "clear it first").
- Fork: markActedToday moved after the pending-fork early-return so spamming
  !zone advance at a fork no longer keeps the daily streak alive.
- Pet whiff/deflect: single proc spent on the first multiattack swing only
  (was applied to every swing); also dedups the per-swing event spam.
- Autopilot: background region crossing now runs an HP/SU preflight and
  pauses if low, instead of burning a transit day while the player is idle.
- Legacy streak: acted-branch restamps LastActionDate=today so a purely-legacy
  actor's streak no longer resets to 1 every night.
2026-05-22 01:15:23 -07:00
prosolis
bcd4a873a5 Idle reaper: credit the closing day, not the new day
midnightReset runs at 00:00 UTC closing out yesterday, but gated idle
shame on HasActedToday(), which compares LastActionDate to today (the
new day). DnD-side players credit the day via LastActionDate only, so
anyone who played yesterday and extracted before midnight read as idle
and had their streak halved. Credit activity on the closing day
(LastActionDate==yesterday) directly, matching the streak-bump branch.

(cherry picked from commit f6cb4889e7e6bfacce41ea40853456b08d6857a9)
2026-05-21 23:56:31 -07:00
prosolis
01d2329993 Auto-walk: gate on fork/rest, slow cadence, credit DnD streak
Four fixes for the auto-walk loop that was re-clearing the same room
every 15 min while a fork was pending, ignoring the rest lockout, and
not counting expedition activity toward the daily streak:

- advanceOnceWithOpts / runAutopilotWalk now short-circuit to stopFork
  when NodeChoices has a pending fork. Stops phantom kills + duplicate
  loot drops + fork re-prompts on the same cleared room.
- fireExpeditionAutoRuns honors restingLockoutRemaining so the
  background ticker no longer walks through a long rest.
- autoRunCooldown 15m -> 2h, autoRunTickInterval 5m -> 15m. Auto-walk
  DMs are now a once-in-a-while ping, not a steady drip.
- markActedToday + HasActedToday recognise LastActionDate. Wired into
  !rest short/long, !expedition start/abandon, !extract, foreground
  !zone advance, and !zone go so DnD-side activity credits the streak
  even when the expedition ends before midnight.

(cherry picked from commit 9e27fd8257a4c92150ad584b393bf5a72270b82c)
2026-05-21 23:56:23 -07:00
prosolis
e4518c9c39 Camp: reject (not downgrade) sub-par camps; map: show visited path
Camp rest rules:
  • Standard camp now rejects an uncleared room with a clear message
    instead of silently downgrading to rough — let the player make the
    call rather than spending supplies on a worse rest they didn't pick.
  • campLocationCheck treats a room with a resolved (non-active) combat
    session as cleared, so the natural "just killed, not yet advanced"
    pause still qualifies for a standard camp.
  • Reword fortified-camp gating + babysit help to "zone boss defeated"
    (cache sites aren't a thing yet) so the requirement isn't misleading.

Map: renderVisitedPath adds a 1-indexed breadcrumb of visited rooms to
!expedition map, so players can reference rooms by index (e.g. !revisit 2).

(cherry picked from commit a38fc77eed888e9790c7a7cff24369b98910b43e)
2026-05-21 23:56:18 -07:00
prosolis
20b0d027b2 Pets: per-round attack + wire deflect/whiff into the turn engine
The live turn engine only struck once per fight and never rolled pet
deflect or whiff, so pet armor (deflect-only) bought nothing in real
runs. Roll pet attack each player turn and roll deflect/whiff per enemy
turn, mirroring the auto-resolve engine; retire the one-shot pet-proc
machinery (rollCombatSessionPetProc / PetProcReady).

(cherry picked from commit a0e41c97801e500efad13c7e9a06be4c345e464e)
2026-05-21 23:56:18 -07:00
prosolis
28a90292f0 Expedition-aware continue hints; boss win reads as expedition complete
On an expedition the autopilot drives the walk, so the manual Elite/Boss
fight close-outs and per-room next-room lines pointed players at the wrong
surface (`!zone advance` instead of `!expedition run`). Route every
"keep moving" prompt through continueHint(), which picks the verb by mode.
Special-case the boss victory to read as the expedition win.

(cherry picked from commit 30b51b91445f3bb9680cd252df6c761e3ce61d0a)
2026-05-21 23:56:18 -07:00
prosolis
5d7c76fb20 Sim: cross region boundaries; word mid-zone clears as region clears
Mirror runAutopilotWalk's multi-region auto-advance in the headless sim:
on a mid-zone stopComplete (active multi-region exp with a next region),
advanceToNextRegion and keep simulating instead of scoring a premature
"cleared". A transit error there is now recorded as halted, not cleared.

Also fix misleading run-complete wording: a non-boss region clear of a
multi-region zone now reads "Cleared {region}. The way to {next} opens
ahead." instead of "Cleared {zone}. Run complete." New midZoneRegionClear
helper shares finalizeExpeditionOnZoneClear's gating; the path is shared
with manual !region travel, so both autopilot and manual play get it.

(cherry picked from commit 5d2bba70849a0a3fdeac285cc55ea9b8fadea29c)
2026-05-21 23:56:18 -07:00
prosolis
100a4f1054 Auto-walk: cross region boundaries in multi-region zones
Extract regionCmdTravel's transit core into advanceToNextRegion (shared
by manual !region travel), then have runAutopilotWalk auto-advance into
the next region on a mid-zone stopComplete instead of dead-stopping the
walk. Transit cost (day + supplies + wandering check) is identical on
both paths.

(cherry picked from commit 8ac09cf3b059a086c431859c8aaaf046aa992342)
2026-05-21 23:56:11 -07:00
prosolis
f6a457ae84 Pets: fire 25% morning event from expedition briefing + fix defense-buff leak
Recovers the genuinely-missing half of ade0335 (orphaned on the unmerged
phase-H-harvest-charges branch). 3ed2e1d redid pet *arrival* via the
emergence seam and explicitly deferred the morning event; this closes
that gap.

- deliverBriefing now rolls the 25% morning pet event (petMorningEvent,
  already present but only wired into the overworld DM that underground
  players never see) and prepends it to the briefing body, granting the
  one-day PetMorningDefense buff.
- Add resetAllPetMorningDefense + wire it into midnightReset. The buff was
  leaking permanently even on HEAD's existing overworld path — it was set
  on the 25% roll but never cleared. Resets the pet_flags_json key in place.

Deliberately omits ade0335's ArrivalPending machinery: arrival is now the
emergence seam's job (3ed2e1d + 978dc5e + 513cf32), so queuing arrival
from the briefing would double-roll it.
2026-05-21 23:45:57 -07:00
prosolis
513cf32e42 Restore expedition completion on zone clear (lost in merge)
finalizeExpeditionOnZoneClear and its wiring into the run-complete seam
were introduced in 73b7809 but that commit never made it into the
current history — it's not an ancestor of HEAD, so the function, its
call site in advanceOnceWithOpts, and its tests were all absent.

Symptom: clearing a zone (boss down, no outgoing edges) set the run's
boss_defeated flag but never flipped the wrapping expedition to
'complete' or retired the run. The expedition sat 'active' pointing at a
boss_defeated run, so getActiveZoneRun (filters boss_defeated=0) returned
nil and the next '!expedition run' errored with 'No active zone run'. The
ambient ticker also kept DMing about a finished dungeon.

Re-apply the bridge verbatim against current HEAD (deps verified present:
IsMultiRegionZone / CurrentRegion / MarkRegionBossDefeated /
completeExpedition / retireAllRegionRuns / AwardCompletionMilestones) and
restore the test.
2026-05-21 23:40:54 -07:00
prosolis
978dc5e25f Pets: roll arrival on run-complete emergence too
The emergence-seam roll added in 3ed2e1d covered extract, abandon,
forced extraction, and death-respawn — but not a natural run-complete
(boss down / dead-end node), which is the most common successful
emergence. Players who cleared a run cleanly never got the arrival roll
despite meeting every condition.

Wire maybeRollPetArrivalOnEmerge into the two real run-complete callers
(expeditionCmdRun foreground + the autorun background ticker), gated on
reason == stopComplete. Kept out of runAutopilotWalk itself so the sim
path (which calls the walk directly) never fires arrival DMs.
2026-05-21 23:27:53 -07:00
prosolis
2ea3e42612 Forex: reject crypto/unknown currencies on the fiat-only rate/report path
The analysis path silently normalized junk tokens (BTC/USD, XYZ) to the
default EUR/JPY/CAD set, giving no signal the input was rejected. Validate
tokens up front: crypto symbols get nudged toward the conversion path,
anything else returns an unknown-currency error.
2026-05-21 22:58:31 -07:00
prosolis
3ed2e1d8e0 Pets: roll arrival on expedition emergence, not the 08:00 morning DM
The pet-arrival roll only ran in sendMorningDMs (the 08:00 overworld
morning DM), which is skipped for anyone underground. Expedition players
are almost never in the overworld at 08:00, so the roll never reached
them — the encounter never fired despite the conditions being met.

Move the roll onto the emergence seam via a shared helper
maybeRollPetArrivalOnEmerge, called when a player surfaces alive
(voluntary extract, abandon, survived forced extraction) and on respawn
for underground deaths. Remove the now-dead morning-DM arrival roll.
Story-wise: while the player was out, an animal wandered into the empty
house looking for food.

The 25% morning pet event still rolls only in the overworld DM and has
the same reachability gap; left for a follow-up.
2026-05-21 22:51:00 -07:00
35 changed files with 1313 additions and 265 deletions

View File

@@ -43,10 +43,17 @@ func main() {
runs = flag.Int("runs", 1, "replicates per (class,level,zone) cell (matrix mode)")
trace = flag.Bool("trace", false, "include raw per-round CombatEvent stream on the LAST combat of each expedition (boss room) — for J2 diagnostic sweeps")
petLevel = flag.Int("pet-level", 0, "attach a base housing pet at this level (1-10) to every sim character; 0 = no pet (default, matches prod char-creation)")
)
flag.Parse()
if *petLevel < 0 || *petLevel > 10 {
fail("pet-level must be 0-10, got", *petLevel)
}
plugin.SetSimIncludeTrace(*trace)
plugin.SetSimPetLevel(*petLevel)
if *matrix {
// Matrix default: drop log to keep stdout manageable; explicit

View File

@@ -853,7 +853,7 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact
case "pet_type":
return p.resolvePetType(ctx)
case "pet_name":
return p.resolvePetName(ctx)
return p.resolvePetName(ctx, interaction)
}
return nil
}

View File

@@ -98,7 +98,7 @@ func (p *AdventurePlugin) handleBabysitCmd(ctx MessageContext, args string) erro
return p.SendDM(ctx.Sender, "🍼 **Adventurer Babysitting Service**\n\n"+
"Hire a babysitter to look after your camp and tend the pet while you sleep:\n"+
" • Daily pet XP trickle (your pet still grows while you focus elsewhere)\n"+
" • Standard camps act like fortified ones — rest deeply, no need for boss-cleared rooms\n"+
" • Standard camps act like fortified ones — rest deeply, no need to have downed the zone boss\n"+
" • Rival duels declined on your behalf\n\n"+
"`!adventure babysit week` — 7 days of service\n"+
"`!adventure babysit month` — 30 days of service\n"+

View File

@@ -311,7 +311,30 @@ func (c *AdventureCharacter) CanDoHarvest(isHoliday bool) bool {
}
func (c *AdventureCharacter) HasActedToday() bool {
return c.CombatActionsUsed > 0 || c.HarvestActionsUsed > 0
if c.CombatActionsUsed > 0 || c.HarvestActionsUsed > 0 {
return true
}
// DnD-side flows (expedition / zone / rest / autopilot) don't touch
// the legacy action counters; they credit the day via LastActionDate
// instead. Honor that so a player who ran an expedition all day and
// extracted before midnight still counts as having acted.
return c.LastActionDate == time.Now().UTC().Format("2006-01-02")
}
// markActedToday stamps the player's LastActionDate to today so the
// midnight reset credits the day. Safe to call on every DnD-side action;
// no-ops if the date is already today.
func markActedToday(userID id.UserID) {
today := time.Now().UTC().Format("2006-01-02")
c, err := loadAdvCharacter(userID)
if err != nil || c == nil {
return
}
if c.LastActionDate == today {
return
}
c.LastActionDate = today
_ = saveAdvCharacter(c)
}
func (c *AdventureCharacter) AllActionsUsed(isHoliday bool) bool {

View File

@@ -206,6 +206,25 @@ func petShouldArrive(pet PetState, house HouseState) bool {
return rand.Float64() < 0.30
}
// maybeRollPetArrivalOnEmerge rolls the pet-arrival check when a player
// surfaces from an expedition (voluntary extract, abandon, or a survived
// forced extraction) or revives after death. The arrival roll lives on the
// emergence seam — not the legacy 08:00 overworld morning DM — because
// expedition players are almost never in the overworld at the scheduled hour,
// so the morning roll never reached them. Story beat: while the player was
// underground, an animal wandered into the empty house looking for food.
//
// Safe to call unconditionally on any emergence: petShouldArrive gates on
// house tier / not-yet-arrived, and petArrivalDM won't clobber an existing
// pending interaction.
func (p *AdventurePlugin) maybeRollPetArrivalOnEmerge(userID id.UserID) {
pet, _ := loadPetState(userID)
house, _ := loadHouseState(userID)
if petShouldArrive(pet, house) {
p.petArrivalDM(userID)
}
}
// petArrivalDM sends the initial "there's an animal in your house" DM.
func (p *AdventurePlugin) petArrivalDM(userID id.UserID) {
// Don't overwrite an existing pending interaction
@@ -310,8 +329,11 @@ func (p *AdventurePlugin) resolvePetType(ctx MessageContext) error {
article, titleCase(petType)))
}
// resolvePetName handles naming the pet.
func (p *AdventurePlugin) resolvePetName(ctx MessageContext) error {
// resolvePetName handles naming the pet. The dispatcher (handlePendingReply)
// has already deleted the pending interaction and passes it in, so this must
// NOT re-load from p.pending — doing so previously made every adoption fail
// silently (the carried PetType was lost and the save never ran).
func (p *AdventurePlugin) resolvePetName(ctx MessageContext, interaction *advPendingInteraction) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
@@ -321,20 +343,15 @@ func (p *AdventurePlugin) resolvePetName(ctx MessageContext) error {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
val, ok := p.pending.LoadAndDelete(string(ctx.Sender))
if !ok {
return nil
}
pi := val.(*advPendingInteraction)
data := pi.Data.(*advPendingPetName)
data := interaction.Data.(*advPendingPetName)
name := strings.TrimSpace(ctx.Body)
if len(name) == 0 || len(name) > 30 {
p.pending.Store(string(ctx.Sender), pi)
p.pending.Store(string(ctx.Sender), interaction)
return p.SendDM(ctx.Sender, "Name must be 1-30 characters. Try again.")
}
if !petNameValid.MatchString(name) {
p.pending.Store(string(ctx.Sender), pi)
p.pending.Store(string(ctx.Sender), interaction)
return p.SendDM(ctx.Sender, "Name can only contain letters, numbers, spaces, hyphens, and apostrophes. Try again.")
}

View File

@@ -0,0 +1,51 @@
package plugin
import (
"testing"
"time"
"maunium.net/go/mautrix/id"
)
// TestResolvePetName_ThroughDispatcher is a regression test for the bug where
// resolvePendingInteraction deleted the pending entry before dispatch, while
// resolvePetName then tried to LoadAndDelete it again — always missing, so the
// adoption silently no-op'd and no pet was ever persisted. The fix passes the
// already-loaded interaction into resolvePetName. This test drives the real
// dispatcher path (resolvePendingInteraction) to lock that in.
func TestResolvePetName_ThroughDispatcher(t *testing.T) {
setupAuditTestDB(t)
uid := id.UserID("@pet-name-dispatch:example")
if err := createAdvCharacter(uid, "petnamer"); err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
interaction := &advPendingInteraction{
Type: "pet_name",
Data: &advPendingPetName{PetType: "dog"},
ExpiresAt: time.Now().Add(time.Hour),
}
p.pending.Store(string(uid), interaction)
if err := p.resolvePendingInteraction(MessageContext{Sender: uid, Body: "Pepper"}, interaction); err != nil {
t.Fatal(err)
}
pet, err := loadPetState(uid)
if err != nil {
t.Fatal(err)
}
if !pet.HasPet() {
t.Fatalf("expected pet to be adopted; got %+v", pet)
}
if pet.Name != "Pepper" {
t.Errorf("pet name = %q, want Pepper", pet.Name)
}
if pet.Type != "dog" {
t.Errorf("pet type = %q, want dog", pet.Type)
}
if !pet.Arrived || pet.Level != 1 {
t.Errorf("pet arrived=%v level=%d, want true/1", pet.Arrived, pet.Level)
}
}

View File

@@ -82,6 +82,12 @@ func (p *AdventurePlugin) sendMorningDMs() {
if err := p.SendDM(char.UserID, text); err != nil {
slog.Error("adventure: failed to send respawn DM", "user", char.UserID, "err", err)
}
// Emergence seam (death case): a player who died underground
// "comes home" on respawn. This is the deferred half of the
// emergence roll — survived extractions roll at their exit
// site; deaths roll here. See maybeRollPetArrivalOnEmerge.
p.maybeRollPetArrivalOnEmerge(char.UserID)
}
// Babysitting: pet-care trickle (no harvest actions; safe-rest perk
@@ -133,13 +139,12 @@ func (p *AdventurePlugin) sendMorningDMs() {
continue
}
// Pet arrival check (fires before normal morning DM)
house, _ := loadHouseState(char.UserID)
// Pet arrival no longer rolls here. The 08:00 overworld morning DM
// is skipped for anyone underground (expedition gate above), so it
// never reached expedition players. Arrival now fires on the
// emergence seam — see maybeRollPetArrivalOnEmerge, called from the
// extract/abandon/forced-extract and respawn paths.
pet, _ := loadPetState(char.UserID)
if petShouldArrive(pet, house) {
p.petArrivalDM(char.UserID)
continue
}
// Morning pet event
petEvent := petMorningEvent(pet)
@@ -391,7 +396,6 @@ func (p *AdventurePlugin) midnightTicker() {
}
func (p *AdventurePlugin) midnightReset() error {
// Send idle shame DMs to players who didn't act
chars, err := loadAllAdvCharacters()
if err != nil {
return fmt.Errorf("load chars: %w", err)
@@ -400,79 +404,89 @@ func (p *AdventurePlugin) midnightReset() error {
today := time.Now().UTC().Format("2006-01-02")
yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
// Unified activity oracle — same source the daily report uses. Unions
// adventure_activity_log + dnd_zone_run + dnd_expedition_log, so
// background-autopilot walks, expedition extracts/completions, and any
// subsystem that logged a beat all count as "something happened on this
// player's behalf." LastActionDate alone misses the autopilot path
// (dnd_zone_cmd.go gates markActedToday on !compact to keep autopilot
// out of streak credit) — without this oracle, a player who let the
// autopilot run a full expedition gets shamed at midnight.
todayActs, _ := loadAdvDailyActivity(today)
yesterdayActs, _ := loadAdvDailyActivity(yesterday)
dmsSent := 0
for _, char := range chars {
if !char.HasActedToday() {
// If the player died today or yesterday, they couldn't act — no shame,
// no streak reset. This covers both currently-dead players and players
// who were just revived at midnight (Alive already flipped to true by
// the reminder loop before midnightReset runs).
if char.LastDeathDate == today || char.LastDeathDate == yesterday {
continue
}
// Died inside the window — no shame, no streak change. Covers both
// currently-dead players and players revived at midnight (Alive
// already flipped to true by the reminder loop before this runs).
if char.LastDeathDate == today || char.LastDeathDate == yesterday {
continue
}
// An active expedition — or a turn-based fight locked open across
// midnight — counts as activity. Both track their own action flow
// (zone/harvest/combat/transit/extract) and never touch the legacy
// CombatActionsUsed/HarvestActionsUsed counters, so HasActedToday()
// 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 {
slog.Warn("adventure: failed to check active expedition for idle reaper", "user", char.UserID, "err", err)
} else if exp != nil {
busy = true
}
if !busy && hasActiveCombatSession(char.UserID) {
busy = true
}
if busy {
if char.LastActionDate == yesterday || char.LastActionDate == today {
char.CurrentStreak++
} else {
char.CurrentStreak = 1
}
if char.CurrentStreak > char.BestStreak {
char.BestStreak = char.CurrentStreak
}
char.LastActionDate = today
_ = saveAdvCharacter(&char)
continue
}
// Player-initiated engagement — only this credits the streak.
// LastActionDate is stamped at action time by markActedToday + !rest;
// the legacy counters get bumped by the legacy CanDo... paths.
engaged := char.LastActionDate == today || char.LastActionDate == yesterday ||
char.CombatActionsUsed > 0 || char.HarvestActionsUsed > 0
// Jitter between DMs to avoid Matrix rate limits
if dmsSent > 0 {
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond)
}
dmsSent++
// Idle shame DM
text := renderAdvIdleShameDM(char.UserID)
if char.CurrentStreak > 0 {
oldStreak := char.CurrentStreak
char.CurrentStreak /= 2
char.StreakDecayed = true
text += fmt.Sprintf("\n\n🔥 Streak: %d → %d days", oldStreak, char.CurrentStreak)
if char.CurrentStreak > 0 {
text += " — not all is lost."
}
_ = saveAdvCharacter(&char)
}
if err := p.SendDM(char.UserID, text); err != nil {
slog.Error("adventure: failed to send idle shame DM", "user", char.UserID, "err", err)
}
} else {
// Update streak — LastActionDate was set at action time
if engaged {
if char.LastActionDate == yesterday || char.LastActionDate == today {
char.CurrentStreak++
} else {
// Gap in activity — start fresh
// Legacy-only path: counters bumped but LastActionDate stale.
// Without this fall-through the streak would reset to 1 every
// night even with continuous play.
char.CurrentStreak = 1
}
if char.CurrentStreak > char.BestStreak {
char.BestStreak = char.CurrentStreak
}
char.LastActionDate = today
_ = saveAdvCharacter(&char)
continue
}
// Activity happened on the player's behalf without an explicit tap —
// autopilot walked rooms, expedition log gained beats, a fight session
// is still locked open. Hold the streak: no bump, no shame, no decay.
// The player engaged earlier to kick this off; autopilot is a feature,
// not a way to game streaks, and absence of taps shouldn't strip
// progress they earned through real play.
if len(todayActs[char.UserID]) > 0 || len(yesterdayActs[char.UserID]) > 0 {
continue
}
// Safety net for live state the activity logs don't reflect yet
// (e.g. an active expedition that's been quiet today, or a combat
// session locked open across midnight without a log append).
if exp, err := getActiveExpedition(char.UserID); err != nil {
slog.Warn("adventure: failed to check active expedition for idle reaper", "user", char.UserID, "err", err)
} else if exp != nil {
continue
}
if hasActiveCombatSession(char.UserID) {
continue
}
// Truly idle — shame DM + streak halve.
if dmsSent > 0 {
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond)
}
dmsSent++
text := renderAdvIdleShameDM(char.UserID)
if char.CurrentStreak > 0 {
oldStreak := char.CurrentStreak
char.CurrentStreak /= 2
char.StreakDecayed = true
text += fmt.Sprintf("\n\n🔥 Streak: %d → %d days", oldStreak, char.CurrentStreak)
if char.CurrentStreak > 0 {
text += " — not all is lost."
}
_ = saveAdvCharacter(&char)
}
if err := p.SendDM(char.UserID, text); err != nil {
slog.Error("adventure: failed to send idle shame DM", "user", char.UserID, "err", err)
}
}
@@ -490,6 +504,12 @@ func (p *AdventurePlugin) midnightReset() error {
return fmt.Errorf("reset daily actions after 3 attempts: %w", resetErr)
}
// Clear the one-day pet morning-defense buff so it re-rolls fresh each
// morning (briefing or overworld DM) instead of leaking permanently.
if err := resetAllPetMorningDefense(); err != nil {
slog.Error("adventure: failed to reset pet morning defense", "err", err)
}
// Prune expired buffs
if err := pruneAdvExpiredBuffs(); err != nil {
slog.Error("adventure: failed to prune expired buffs", "err", err)

View File

@@ -0,0 +1,145 @@
package plugin
import (
"testing"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// TestMidnightReset_Branching exercises the three idle-reaper branches:
// - engaged: LastActionDate stamped today/yesterday → streak bumps
// - activity-without-tap: autopilot/background activity logged today but
// no LastActionDate stamp → streak holds, no shame DM, no decay
// - truly idle: nothing anywhere → streak halves, StreakDecayed=true
//
// Regression guard for the autopilot bug: a player who let the background
// auto-run walk an expedition all day used to get shamed at midnight because
// markActedToday is gated on !compact at dnd_zone_cmd.go.
func TestMidnightReset_Branching(t *testing.T) {
dir := t.TempDir()
db.Close()
if err := db.Init(dir); err != nil {
t.Fatalf("db.Init: %v", err)
}
t.Cleanup(db.Close)
today := time.Now().UTC().Format("2006-01-02")
yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
mk := func(uid, lastAction string, streak int) id.UserID {
u := id.UserID(uid)
if err := createAdvCharacter(u, uid); err != nil {
t.Fatalf("createAdvCharacter %s: %v", uid, err)
}
c, err := loadAdvCharacter(u)
if err != nil {
t.Fatalf("load %s: %v", uid, err)
}
c.LastActionDate = lastAction
c.CurrentStreak = streak
c.BestStreak = streak
if err := saveAdvCharacter(c); err != nil {
t.Fatalf("save %s: %v", uid, err)
}
return u
}
engaged := mk("@engaged:example", yesterday, 5)
autopilot := mk("@autopilot:example", "2020-01-01", 7)
idle := mk("@idle:example", "2020-01-01", 8)
// Simulate autopilot activity: a legacy activity-log row dated today.
// loadAdvDailyActivity unions this in, so the reaper sees the player as
// "had activity" without their LastActionDate being current.
if _, err := db.Get().Exec(
`INSERT INTO adventure_activity_log
(user_id, activity_type, location, outcome, loot_value, xp_gained, flavor_key, logged_at)
VALUES (?, 'zone', 'Test Zone', 'in_progress', 0, 0, '', CURRENT_TIMESTAMP)`,
string(autopilot),
); err != nil {
t.Fatalf("insert activity row: %v", err)
}
p := &AdventurePlugin{}
if err := p.midnightReset(); err != nil {
t.Fatalf("midnightReset: %v", err)
}
// Engaged → streak bumped, LastActionDate restamped to today, no decay.
if c, _ := loadAdvCharacter(engaged); c != nil {
if c.CurrentStreak != 6 {
t.Errorf("engaged: streak = %d, want 6", c.CurrentStreak)
}
if c.LastActionDate != today {
t.Errorf("engaged: LastActionDate = %q, want %q", c.LastActionDate, today)
}
if c.StreakDecayed {
t.Error("engaged: StreakDecayed = true, want false")
}
}
// Autopilot → hold. Streak unchanged, LastActionDate stays stale, no decay.
if c, _ := loadAdvCharacter(autopilot); c != nil {
if c.CurrentStreak != 7 {
t.Errorf("autopilot: streak = %d, want 7 (held)", c.CurrentStreak)
}
if c.StreakDecayed {
t.Error("autopilot: StreakDecayed = true, want false (shamed by mistake)")
}
if c.LastActionDate == today {
t.Errorf("autopilot: LastActionDate restamped to today — autopilot must not credit streak via LastActionDate")
}
}
// Idle → shame DM (no-op without Matrix client) + streak halved + decay flagged.
if c, _ := loadAdvCharacter(idle); c != nil {
if c.CurrentStreak != 4 {
t.Errorf("idle: streak = %d, want 4 (halved from 8)", c.CurrentStreak)
}
if !c.StreakDecayed {
t.Error("idle: StreakDecayed = false, want true")
}
}
}
// TestMidnightReset_DeathWindowSkips: a player who died today/yesterday is
// left untouched — no shame, no streak change, no decay flag.
func TestMidnightReset_DeathWindowSkips(t *testing.T) {
dir := t.TempDir()
db.Close()
if err := db.Init(dir); err != nil {
t.Fatalf("db.Init: %v", err)
}
t.Cleanup(db.Close)
today := time.Now().UTC().Format("2006-01-02")
u := id.UserID("@dead:example")
if err := createAdvCharacter(u, "dead"); err != nil {
t.Fatalf("createAdvCharacter: %v", err)
}
c, _ := loadAdvCharacter(u)
c.LastActionDate = "2020-01-01"
c.CurrentStreak = 10
c.BestStreak = 10
c.LastDeathDate = today
if err := saveAdvCharacter(c); err != nil {
t.Fatalf("save: %v", err)
}
p := &AdventurePlugin{}
if err := p.midnightReset(); err != nil {
t.Fatalf("midnightReset: %v", err)
}
got, _ := loadAdvCharacter(u)
if got.CurrentStreak != 10 {
t.Errorf("dead: streak = %d, want 10 (untouched)", got.CurrentStreak)
}
if got.StreakDecayed {
t.Error("dead: StreakDecayed = true, want false")
}
}

View File

@@ -52,7 +52,7 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
case CombatStatusActive:
return p.SendDM(ctx.Sender, "You're already in this fight — `!attack` or `!flee`.")
case CombatStatusWon:
return p.SendDM(ctx.Sender, "You've already cleared this room. `!zone advance` to move on.")
return p.SendDM(ctx.Sender, "You've already cleared this room. "+continueHint(ctx.Sender))
default:
return p.SendDM(ctx.Sender, "This fight is already over. `!zone status` for where you stand.")
}
@@ -91,11 +91,9 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
}
// Carry fight-start one-shot resources (Abjuration Arcane Ward, etc.) onto
// the session so they survive the turn engine's resume/commit cycle, and
// make the one-and-only per-fight pet-attack roll.
seeded := seedCombatSessionOneShots(sess, player.Mods)
pet := rollCombatSessionPetProc(sess, player.Mods)
if seeded || pet {
// the session so they survive the turn engine's resume/commit cycle. The
// pet now rolls per-turn inside the engine, so there's no fight-start roll.
if seedCombatSessionOneShots(sess, player.Mods) {
if err := saveCombatSession(sess); err != nil {
slog.Error("combat: seed session one-shots", "user", ctx.Sender, "err", err)
}
@@ -204,6 +202,18 @@ func combatTurnPrompt(sess *CombatSession) string {
// ── close-out ───────────────────────────────────────────────────────────────
// continueHint returns the verb the player uses to keep moving after a
// manual Elite/Boss fight, phrased for their current mode. On an
// expedition the autopilot drives the walk, so `!zone advance` is the
// wrong surface — point them at `!expedition run` instead. Standalone
// zone runs still advance with `!zone advance`.
func continueHint(userID id.UserID) string {
if exp, err := getActiveExpedition(userID); err == nil && exp != nil {
return "`!expedition run` to keep going."
}
return "`!zone advance` to move on."
}
// finishCombatSession runs the post-fight side effects once a CombatSession
// has reached a terminal status, and returns the player-facing outcome block.
// The graph is NOT advanced here: the terminal session row is the record that
@@ -241,11 +251,13 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
slog.Error("combat: grantDnDXP turn-based", "user", userID, "err", err)
}
}
bossOnExpedition := false
if !elite {
// §8.1 — zone boss defeat drops expedition threat. Silent no-op
// for standalone zone runs (no active expedition).
if exp, eerr := getActiveExpedition(userID); eerr == nil && exp != nil {
_ = applyBossDefeatThreat(exp.ID)
bossOnExpedition = true
}
}
if line := twinBeeLine(zone.ID, DMCombatEnd, sess.RunID, cadence); line != "" {
@@ -260,7 +272,15 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
if drop := p.dropZoneLoot(userID, zone.ID, monster, !elite); drop != "" {
b.WriteString(drop + "\n")
}
b.WriteString("`!zone advance` to move on.")
if bossOnExpedition {
// The boss is the expedition's climax. Frame the close-out as
// the win rather than a "keep walking" nudge. One more
// `!expedition run` walks out the cleared room and triggers
// finalizeExpeditionOnZoneClear (rewards + status flip).
b.WriteString("🎉 **Zone cleared — the expedition is won.** `!expedition run` to march out and claim your spoils.")
} else {
b.WriteString(continueHint(userID))
}
case CombatStatusLost:
if run != nil {

View File

@@ -53,6 +53,11 @@ type CombatModifiers struct {
PetAttackDmg int
PetDeflectProc float64
PetWhiffProc float64 // pet distracts enemy → guaranteed miss
// Spiritual Weapon — separate channel from the pet so the spectral mace
// gets its own narration when a cleric without a companion casts it.
// Damage formula mirrors PetAttack (Dmg + d5), proc rolls per round.
SpiritWeaponProc float64
SpiritWeaponDmg int
SniperKillProc float64 // Arina instant-kill
MistyHealProc float64
MistyHealAmt int
@@ -319,10 +324,6 @@ type combatState struct {
// the enemy would otherwise attack).
enemySkipFirst bool
// Phase 13 turn-based — pet attack decided once at fight start; the pet
// strikes once on the player's first acting turn, which clears this.
petProcReady bool
// Phase 10 SUB2a-ii first-attack one-shots.
firstAttackBonusUsed bool
assassinateRerollUsed bool
@@ -649,6 +650,19 @@ func simulateRound(st *combatState, player, enemy *Combatant, phase *CombatPhase
}
}
// Spiritual Weapon strike
if player.Mods.SpiritWeaponProc > 0 && st.randFloat() < player.Mods.SpiritWeaponProc {
swDmg := player.Mods.SpiritWeaponDmg + st.roll(5)
st.enemyHP = max(0, st.enemyHP-swDmg)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "spirit_weapon", Action: "spirit_weapon_strike",
Damage: swDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
if enemyDown(st, phaseName) {
return true
}
}
// Misty heal
if player.Mods.MistyHealProc > 0 && st.randFloat() < player.Mods.MistyHealProc {
healAmt := player.Mods.MistyHealAmt

View File

@@ -236,6 +236,9 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul
case "pet_attack":
return fmt.Sprintf(pickRand(narrativePetAttack), e.Damage)
case "spirit_weapon_strike":
return fmt.Sprintf(pickRand(narrativeSpiritWeapon), e.Damage)
case "pet_deflect":
return pickRand(narrativePetDeflect)
@@ -326,9 +329,9 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul
case "survive_at_1":
return pickRand(narrativeSurvive)
case "stat_drain":
return pickRand(narrativeStatDrain)
return fmt.Sprintf(pickRand(narrativeStatDrain), e.Damage)
case "debuff":
return pickRand(narrativeDebuff)
return fmt.Sprintf(pickRand(narrativeDebuff), e.Damage)
case "max_hp_drain":
return fmt.Sprintf(pickRand(narrativeMaxHPDrain), e.Damage)
@@ -346,7 +349,7 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul
case "fear_resist":
return pickRand(narrativeFearResist)
case "ally_buff":
return pickRand(narrativeAllyBuff)
return fmt.Sprintf(pickRand(narrativeAllyBuff), e.Damage)
case "timeout":
return pickRand(narrativeTimeout)
@@ -526,6 +529,13 @@ var narrativePetAttack = []string{
"🐾 Your faithful companion lands a hit for %d damage. More faithful than accurate, but today both applied.",
}
var narrativeSpiritWeapon = []string{
"✨ The spectral mace swings on its own and lands for %d damage. Floating menace, well-balanced.",
"✨ Your spiritual weapon hovers, picks an angle, strikes — %d damage. No grip, all conviction.",
"✨ A glowing weapon arcs in from beside you. %d damage. The enemy keeps trying to track it. Cannot.",
"✨ The spectral blade flickers, then bites. %d damage. It does not tire. It does not blink.",
}
var narrativePetDeflect = []string{
"🐾 Your pet intercepts the blow. Damage halved. Your pet is now your best piece of equipment.",
"🐾 Your pet pushes you aside at the last second. Impact reduced. You did not ask to be pushed. Results speak for themselves.",

View File

@@ -0,0 +1,24 @@
package plugin
import (
"strings"
"testing"
)
// TestRenderEvent_StatefulDrainsFormatMagnitude is a regression test for the
// leaked "%d" in stateful enemy-effect narration. stat_drain, debuff and
// ally_buff carry their magnitude in CombatEvent.Damage and their flavor
// pools contain a %d placeholder; renderEvent must fmt.Sprintf them rather
// than emit the template raw (which surfaced "-%d damage" to players).
func TestRenderEvent_StatefulDrainsFormatMagnitude(t *testing.T) {
for _, action := range []string{"stat_drain", "debuff", "ally_buff"} {
e := CombatEvent{Actor: "enemy", Action: action, Damage: 4}
out := renderEvent(e, "Rurina", "Shadow", CombatResult{}, newActionPicker())
if strings.Contains(out, "%") {
t.Errorf("%s: leaked format placeholder in output: %q", action, out)
}
if !strings.Contains(out, "4") {
t.Errorf("%s: expected magnitude 4 in output: %q", action, out)
}
}
}

View File

@@ -65,14 +65,6 @@ type CombatStatuses struct {
// combatState, so the flag must survive the commit between them.
EnemySkipNext bool `json:"enemy_skip_next,omitempty"`
// PetProcReady is the per-fight pet-attack outcome. Auto-resolve rolls the
// pet proc every round; a manual fight can run many rounds, so the roll is
// decided once at fight start (rollCombatSessionPetProc) and parked here.
// The pet then lands a single hit on the player's first acting turn, which
// clears the flag — persisted so a suspend/resume or reaper auto-play sees
// the same outcome.
PetProcReady bool `json:"pet_proc_ready,omitempty"`
// Fight-scoped depleting resources — mirror the combatState charges that
// genuinely carry round-to-round. Seeded at fight start (Arcane Ward) or by
// a mid-fight !cast / !consume, restored into combatState on every resume,
@@ -129,6 +121,8 @@ type CombatStatuses struct {
BuffDamageBonus float64 `json:"buff_damage_bonus,omitempty"`
BuffPetProc float64 `json:"buff_pet_proc,omitempty"`
BuffDamageReductMul float64 `json:"buff_damage_reduct_mul,omitempty"`
BuffSpiritProc float64 `json:"buff_spirit_proc,omitempty"`
BuffSpiritDmg int `json:"buff_spirit_dmg,omitempty"`
}
// applyBuffDelta folds one resolved buff (the result of a !cast / !consume
@@ -142,6 +136,8 @@ func (s *CombatStatuses) applyBuffDelta(d turnBuffDelta) {
s.BuffCritRate += d.dCrit
s.BuffDamageBonus += d.dDmgBonus
s.BuffPetProc += d.dPetProc
s.BuffSpiritProc += d.dSpiritProc
s.BuffSpiritDmg += d.dSpiritDmg
if d.dReductMul > 0 && d.dReductMul != 1 {
if s.BuffDamageReductMul == 0 {
s.BuffDamageReductMul = d.dReductMul

View File

@@ -149,6 +149,8 @@ func applySessionBuffs(player *Combatant, s CombatStatuses) {
player.Mods.DamageBonus += s.BuffDamageBonus
player.Mods.PetAttackProc += s.BuffPetProc
player.Mods.PetAttackDmg += s.BuffPetDmg
player.Mods.SpiritWeaponProc += s.BuffSpiritProc
player.Mods.SpiritWeaponDmg += s.BuffSpiritDmg
if s.BuffDamageReductMul > 0 {
player.Mods.DamageReduct *= s.BuffDamageReductMul
}

View File

@@ -425,72 +425,120 @@ func TestTurnEngine_OneShotsRoundTrip(t *testing.T) {
}
}
// ── Pet proc (per-fight) ───────────────────────────────────────────────────
// ── Pet proc (per-round) ───────────────────────────────────────────────────
func TestRollCombatSessionPetProc(t *testing.T) {
// No pet proc on the player → never rolls true, never touches statuses.
none := &CombatSession{SessionID: "no-pet"}
if rollCombatSessionPetProc(none, CombatModifiers{}) || none.Statuses.PetProcReady {
t.Error("zero PetAttackProc should not arm a pet strike")
}
// A guaranteed proc arms the flag; the draw is deterministic per session id.
sure := &CombatSession{SessionID: "pet-fight"}
if !rollCombatSessionPetProc(sure, CombatModifiers{PetAttackProc: 1.0}) || !sure.Statuses.PetProcReady {
t.Error("PetAttackProc 1.0 should always arm a pet strike")
}
again := &CombatSession{SessionID: "pet-fight"}
rollCombatSessionPetProc(again, CombatModifiers{PetAttackProc: 1.0})
if again.Statuses.PetProcReady != sure.Statuses.PetProcReady {
t.Error("same session id should roll the pet proc deterministically")
}
}
// TestTurnEngine_PetStrike confirms an armed pet lands exactly one hit on the
// player's first acting turn, then never again — the per-fight roll model.
// TestTurnEngine_PetStrike confirms a pet with a guaranteed proc strikes on
// every player-acting turn — the per-round roll model, matching auto-resolve.
func TestTurnEngine_PetStrike(t *testing.T) {
// Pools huge so no phase can end the fight; isolate the pet behaviour.
sess := turnSession(CombatPhasePlayerTurn, 100000, 100000)
sess.Statuses.PetProcReady = true
player, enemy := basePlayer(), baseEnemy()
player.Mods.PetAttackProc = 1.0
player.Mods.PetAttackDmg = 8
events, err := stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionAttack})
if err != nil {
t.Fatal(err)
}
petHits := 0
for _, e := range events {
if e.Action == "pet_attack" {
petHits++
if e.Damage <= 0 {
t.Errorf("pet_attack damage = %d, want > 0", e.Damage)
petHitsOnPlayerTurn := func() int {
events, err := stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionAttack})
if err != nil {
t.Fatal(err)
}
hits := 0
for _, e := range events {
if e.Action == "pet_attack" {
hits++
if e.Damage <= 0 {
t.Errorf("pet_attack damage = %d, want > 0", e.Damage)
}
}
}
}
if petHits != 1 {
t.Fatalf("pet_attack events = %d on first player turn, want 1", petHits)
}
if sess.Statuses.PetProcReady {
t.Error("PetProcReady should clear after the pet strikes")
return hits
}
// Cycle back to the next player turn — the pet must not strike again.
if got := petHitsOnPlayerTurn(); got != 1 {
t.Fatalf("pet_attack events = %d on first player turn, want 1", got)
}
// Cycle back to the next player turn — the pet must strike again.
for sess.Phase != CombatPhasePlayerTurn {
if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil {
t.Fatal(err)
}
}
events, err = stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionAttack})
if got := petHitsOnPlayerTurn(); got != 1 {
t.Errorf("pet_attack events = %d on second player turn, want 1 (per-round)", got)
}
}
// TestTurnEngine_PetNoProc confirms a player without the pet proc never sees a
// pet strike.
func TestTurnEngine_PetNoProc(t *testing.T) {
sess := turnSession(CombatPhasePlayerTurn, 100000, 100000)
player, enemy := basePlayer(), baseEnemy()
player.Mods.PetAttackProc = 0
events, err := stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionAttack})
if err != nil {
t.Fatal(err)
}
for _, e := range events {
if e.Action == "pet_attack" {
t.Error("pet struck twice — the roll is per fight, not per round")
t.Error("pet struck with zero PetAttackProc")
}
}
}
// TestTurnEngine_PetWhiff confirms a guaranteed whiff makes the enemy's turn a
// total miss — a pet_whiff event fires and the player takes no damage.
func TestTurnEngine_PetWhiff(t *testing.T) {
sess := turnSession(CombatPhaseEnemyTurn, 100000, 100000)
player, enemy := basePlayer(), baseEnemy()
player.Mods.PetWhiffProc = 1.0
enemy.Stats.AttackBonus = 50 // would connect easily if not whiffed
startHP := sess.PlayerHP
events, err := stepEngine(sess, &player, &enemy, PlayerAction{})
if err != nil {
t.Fatal(err)
}
whiffs := 0
for _, e := range events {
if e.Action == "pet_whiff" {
whiffs++
}
if e.Actor == "enemy" && e.Action == "hit" {
t.Error("enemy landed a hit despite a guaranteed pet whiff")
}
}
if whiffs == 0 {
t.Error("expected a pet_whiff event with PetWhiffProc 1.0")
}
if sess.PlayerHP != startHP {
t.Errorf("player took %d damage on a whiffed turn, want 0", startHP-sess.PlayerHP)
}
}
// TestTurnEngine_PetDeflect confirms a guaranteed deflect emits a pet_deflect
// event on a connecting enemy attack.
func TestTurnEngine_PetDeflect(t *testing.T) {
sess := turnSession(CombatPhaseEnemyTurn, 100000, 100000)
player, enemy := basePlayer(), baseEnemy()
player.Mods.PetDeflectProc = 1.0
enemy.Stats.AttackBonus = 50 // guarantee the swing connects
events, err := stepEngine(sess, &player, &enemy, PlayerAction{})
if err != nil {
t.Fatal(err)
}
deflects := 0
for _, e := range events {
if e.Action == "pet_deflect" {
deflects++
}
}
if deflects == 0 {
t.Error("expected a pet_deflect event with PetDeflectProc 1.0 on a connecting hit")
}
}
func TestCombatSessionRNG_DeterministicPerPhase(t *testing.T) {
sess := &CombatSession{SessionID: "abc123", Round: 2, Phase: CombatPhaseEnemyTurn}
a := combatSessionRNG(sess)

View File

@@ -116,7 +116,6 @@ func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.R
armorBroken: sess.Statuses.ArmorBroken,
armorBreakAmt: sess.Statuses.ArmorBreakAmt,
enemySkipFirst: sess.Statuses.EnemySkipNext,
petProcReady: sess.Statuses.PetProcReady,
// Fight-scoped depleting resources + once-per-fight one-shots: restored
// from the persisted statuses so a charge or "already used" flag can't
// reset across a suspend/resume. commit writes the updated values back.
@@ -220,22 +219,26 @@ func (te *turnEngine) stepPlayerTurn(action PlayerAction) {
te.finish(CombatStatusWon)
return
}
if te.spiritWeaponStrike() {
te.finish(CombatStatusWon)
return
}
te.sess.Phase = CombatPhaseEnemyTurn
}
// petStrike resolves the player's pet attack for a turn-based fight. Whether
// the pet lands a hit was decided once at fight start (rollCombatSessionPetProc)
// and parked on the session; the pet then strikes a single time on the player's
// first acting turn — this clears the flag so it never repeats. Damage reuses
// the auto-resolve formula (PetAttackDmg + d5), and PetAttackDmg already carries
// any mid-fight buff delta via applySessionBuffs. Returns true if the strike
// dropped the enemy.
// petStrike resolves the player's pet attack for a turn-based fight. The pet
// rolls fresh on every player-acting turn (PetAttackProc), mirroring the
// auto-resolve engine's per-round chance rather than a once-per-fight strike.
// The roll rides the per-(round,phase) step RNG, so a suspend/resume or reaper
// auto-play of the same turn reproduces the same outcome. Damage reuses the
// auto-resolve formula (PetAttackDmg + d5), and PetAttackDmg already carries any
// mid-fight buff delta via applySessionBuffs. Returns true if the strike dropped
// the enemy.
func (te *turnEngine) petStrike() bool {
st := te.st
if !st.petProcReady {
if te.player.Mods.PetAttackProc <= 0 || st.randFloat() >= te.player.Mods.PetAttackProc {
return false
}
st.petProcReady = false
petDmg := te.player.Mods.PetAttackDmg + st.roll(5)
st.enemyHP = max(0, st.enemyHP-petDmg)
st.events = append(st.events, CombatEvent{
@@ -245,30 +248,22 @@ func (te *turnEngine) petStrike() bool {
return enemyDown(st, turnCombatPhase.Name)
}
// rollCombatSessionPetProc makes the one-and-only per-fight pet-attack roll and
// parks the result on the session. Called once at fight start. The draw is
// deterministic — seeded off the session id on a stream distinct from the
// per-(round,phase) combat streams — so a reaper auto-play of an abandoned
// fight reproduces the same outcome. Returns true if the pet will attack (so
// the caller can decide whether the session needs persisting).
//
// Note: only the base PetAttackProc (class/race/subclass passives) is rolled
// here — a pet-proc buff cast mid-fight gets no fresh roll, consistent with the
// per-fight rule. Such a buff still raises PetAttackDmg if the pet does strike.
func rollCombatSessionPetProc(sess *CombatSession, playerMods CombatModifiers) bool {
if playerMods.PetAttackProc <= 0 {
// spiritWeaponStrike resolves the spell's bonus-action attack each round when
// the spiritual_weapon buff is active. Same per-turn cadence as petStrike, but
// rolls and narrates on its own channel so the spectral mace doesn't borrow
// pet flavor on a petless caster. Returns true if the strike dropped the enemy.
func (te *turnEngine) spiritWeaponStrike() bool {
st := te.st
if te.player.Mods.SpiritWeaponProc <= 0 || st.randFloat() >= te.player.Mods.SpiritWeaponProc {
return false
}
var seed uint64 = 1469598103934665603
for _, c := range sess.SessionID {
seed = (seed ^ uint64(c)) * 1099511628211
}
rng := rand.New(rand.NewPCG(seed, 0x9E3779B97F4A7C15))
if rngFloat(rng) < playerMods.PetAttackProc {
sess.Statuses.PetProcReady = true
return true
}
return false
dmg := te.player.Mods.SpiritWeaponDmg + st.roll(5)
st.enemyHP = max(0, st.enemyHP-dmg)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: turnCombatPhase.Name, Actor: "spirit_weapon", Action: "spirit_weapon_strike",
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
return enemyDown(st, turnCombatPhase.Name)
}
// stepPlayerActionEffect resolves a !cast / !consume turn: the command handler
@@ -322,6 +317,10 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
te.finish(CombatStatusWon)
return
}
if te.spiritWeaponStrike() {
te.finish(CombatStatusWon)
return
}
if eff.EnemySkip {
// fear_immune enemies shrug off control spells — the skip never arms.
if enemyImmuneToControl(te.enemy, st) {
@@ -375,17 +374,32 @@ func (te *turnEngine) stepEnemyTurn() {
}
if !abilityDealtDamage {
// Pet defensive procs are a single proc per enemy turn: roll once, then
// spend it on the first swing only. Whiff makes that one swing a
// guaranteed miss; deflect halves its damage. Against a multiattack the
// remaining swings resolve normally — a single proc shouldn't nullify a
// boss's whole multiattack round. (This deliberately diverges from the
// auto-resolve engine's apply-to-all model.)
petWhiff := te.player.Mods.PetWhiffProc > 0 && te.st.randFloat() < te.player.Mods.PetWhiffProc
petDeflect := te.player.Mods.PetDeflectProc > 0 && te.st.randFloat() < te.player.Mods.PetDeflectProc
if petDeflect {
te.result.PetDeflected = true
}
// SRD multiattack: each profile entry is one attack roll resolved
// through the shared primitive. A registered elite/boss swings its full
// profile; everyone else gets a single attack from the template stats.
// resolveEnemyAttack returns true when the fight is decided — either the
// player went down without a death save, or a reflect consumable killed
// the enemy. Disambiguate by inspecting HP.
for _, atk := range enemyAttackProfile(te.sess.EnemyID, te.enemy.Stats) {
for i, atk := range enemyAttackProfile(te.sess.EnemyID, te.enemy.Stats) {
swing := *te.enemy
swing.Stats.Attack = atk.Damage
swing.Stats.AttackBonus = atk.AttackBonus
decided := resolveEnemyAttack(te.st, te.player, &swing, &turnCombatPhase, te.result, false, false, false)
// Spend the proc on the first swing only; later swings see false.
swingWhiff := petWhiff && i == 0
swingDeflect := petDeflect && i == 0
decided := resolveEnemyAttack(te.st, te.player, &swing, &turnCombatPhase, te.result, swingWhiff, swingDeflect, false)
if te.st.playerHP <= 0 {
te.finish(CombatStatusLost)
return
@@ -479,7 +493,6 @@ func (te *turnEngine) commit() {
s.ArmorBroken = st.armorBroken
s.ArmorBreakAmt = st.armorBreakAmt
s.EnemySkipNext = st.enemySkipFirst
s.PetProcReady = st.petProcReady
s.WardCharges = st.wardCharges
s.SporeRounds = st.sporeRounds
s.ReflectFrac = st.reflectFrac

View File

@@ -53,6 +53,10 @@ type CampState struct {
RoomIndex int `json:"room_index"`
EstablishedAt time.Time `json:"established_at"`
NightEvents []string `json:"night_events"`
// RestApplied is set when the long-rest effects (HP refill, spell slots,
// threat -5 etc.) have already been applied at pitch time. processOvernightCamp
// uses it to skip re-applying so the night cycle just breaks the camp.
RestApplied bool `json:"rest_applied,omitempty"`
}
// ThreatEvent — §8.4.

View File

@@ -2,6 +2,7 @@ package plugin
import (
"fmt"
"log/slog"
"math/rand/v2"
"strings"
"time"
@@ -79,12 +80,9 @@ func (p *AdventurePlugin) handleCampCmd(ctx MessageContext, args string) error {
case "rough", "standard":
// allowed in E1e
case "fortified":
// E2d: §5.1 — boss-cleared room or cache site. Cache sites
// are zone-specific waypoints (E3+), so for now we require the
// expedition's boss to have been defeated.
if !exp.BossDefeated {
return p.SendDM(ctx.Sender,
"Fortified camps require a boss-cleared room or cache site. Defeat the zone boss (or find a cache) first.")
"Fortified camps require a defeated zone boss. Clear the zone first.")
}
case "base":
// E4d: §11.1 — base camps unlock per region after the region
@@ -122,7 +120,7 @@ func campHelpText(exp *Expedition) string {
b.WriteString("**!camp <type>** — establish camp during an expedition.\n\n")
b.WriteString("`!camp rough` — partial rest (any location, +0.5 SU, high night risk)\n")
b.WriteString("`!camp standard` — full long rest (cleared room, +1 SU, medium risk)\n")
b.WriteString("`!camp fortified` — long rest + bonus (boss-cleared room, +2 SU, low risk)\n")
b.WriteString("`!camp fortified` — long rest + bonus (zone boss defeated, +2 SU, low risk)\n")
b.WriteString("`!camp base` — persistent waypoint (region-boss-cleared base-camp site, +3 SU, very low risk)\n")
b.WriteString("`!camp break` — break camp\n\n")
if exp.Camp != nil && exp.Camp.Active {
@@ -146,10 +144,10 @@ func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind st
return p.SendDM(ctx.Sender, problem)
}
if !cleared && kind == CampTypeStandard {
// §5.2: non-cleared room forces rough.
kind = CampTypeRough
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative",
"intended standard camp; downgraded to rough (room not cleared)", "")
// §5.2: standard camp requires a cleared room. Reject explicitly
// rather than silently downgrading — the player should make the call.
return p.SendDM(ctx.Sender,
"Standard camp needs a cleared room. This room isn't cleared yet — clear it first, or `!camp rough` for a partial rest here.")
}
cost, ok := campSupplyCost[kind]
@@ -176,6 +174,16 @@ func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind st
if err := updateCamp(exp.ID, camp); err != nil {
return p.SendDM(ctx.Sender, "Couldn't pitch camp: "+err.Error())
}
exp.Camp = camp
// Apply the long-rest effects immediately so the player isn't waiting
// until the next 06:00 UTC briefing for HP and spell slots to come
// back. The flag tells processOvernightCamp not to re-apply at briefing.
restSummary := applyCampRest(exp, kind)
camp.RestApplied = true
if err := updateCamp(exp.ID, camp); err != nil {
slog.Warn("camp: mark rest applied", "expedition", exp.ID, "err", err)
}
// E4d: pick the BaseCampEstablished pool for base camps; otherwise
// the generic camp pool. Both already handle [N] day interpolation
@@ -206,40 +214,24 @@ func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind st
if line != "" {
b.WriteString("\n" + line + "\n")
}
if restSummary != "" {
b.WriteString("\n" + restSummary + "\n")
}
switch kind {
case CampTypeRough:
b.WriteString("\n_Rough camp — partial rest. HP recovers to 50%, no spell slots restored._")
case CampTypeFortified:
b.WriteString("\n_Fortified camp — long rest + 1d6 HP bonus on wake; threat clock 5; wandering rolls 4._")
case CampTypeBase:
b.WriteString("\n_Base camp — long rest + 1d6 HP bonus; threat clock 5; wandering rolls 6. **Waypoint persisted** — camp here again at no eligibility cost on later returns._")
default:
b.WriteString("\n_Standard camp — full long rest at the next morning briefing._")
b.WriteString("\n_Base camp — **waypoint persisted**. Camp here again at no eligibility cost on later returns._")
}
return p.SendDM(ctx.Sender, b.String())
}
// processOvernightCamp applies the overnight long-rest effects of an
// active camp at briefing time (§3, §5.1, §8.1). Auto-breaks the camp
// after the rest. Returns a one-line summary for the briefing body.
//
// Effects:
// - rough: HP recovered to at least 50% of max.
// - standard: HP fully restored, spell slots refreshed, exhaustion -1.
// - fortified: standard + 1d6 HP bonus on top, threat -5.
// - base: same as fortified for the rest itself; persistent waypoint
// mechanics land in E4.
//
// Returns "" if the expedition wasn't camped overnight.
func processOvernightCamp(e *Expedition) string {
if e.Camp == nil || !e.Camp.Active {
return ""
}
// applyCampRest runs the long-rest effects (HP/spells/threat/heat) for the
// given camp kind and returns a player-facing summary. Shared by camp pitch
// (immediate rest) and overnight rollover (legacy deferred path). Does NOT
// break the camp — callers decide that.
func applyCampRest(e *Expedition, kind string) string {
uid := id.UserID(e.UserID)
c, _ := LoadDnDCharacter(uid)
if c == nil {
// No character to apply HP/spells to; just break the camp.
_ = updateCamp(e.ID, nil)
return ""
}
// Babysit safe-rest: an active subscription promotes a Standard camp
@@ -247,7 +239,6 @@ func processOvernightCamp(e *Expedition) string {
// Rough/Base are unchanged — Rough still implies no shelter, and Base
// already exceeds Fortified.
babysitUpgraded := false
kind := e.Camp.Type
if kind == CampTypeStandard && BabysitSafeRest(uid) {
kind = CampTypeFortified
babysitUpgraded = true
@@ -299,11 +290,6 @@ func processOvernightCamp(e *Expedition) string {
heatReduced = before - after
}
// Auto-break the camp now that the rest has been applied.
_ = updateCamp(e.ID, nil)
e.Camp = nil
// Pretty summary for the briefing body.
switch kind {
case CampTypeRough:
if c.HPCurrent > prevHP {
@@ -327,6 +313,24 @@ func processOvernightCamp(e *Expedition) string {
return ""
}
// processOvernightCamp handles the briefing-time half of the camp lifecycle:
// auto-breaks the camp, and applies the long rest only if it wasn't already
// applied at pitch time. Returns a one-line summary for the briefing body, or
// "" if there was nothing to do.
func processOvernightCamp(e *Expedition) string {
if e.Camp == nil || !e.Camp.Active {
return ""
}
kind := e.Camp.Type
alreadyApplied := e.Camp.RestApplied
_ = updateCamp(e.ID, nil)
e.Camp = nil
if alreadyApplied {
return ""
}
return applyCampRest(e, kind)
}
func (p *AdventurePlugin) campBreak(ctx MessageContext, exp *Expedition) error {
if exp.Camp == nil || !exp.Camp.Active {
return p.SendDM(ctx.Sender, "No camp to break.")
@@ -359,15 +363,66 @@ func campLocationCheck(exp *Expedition) (cleared bool, problem string) {
case RoomTrap:
return false, "You can't camp in a trap room — even a disarmed one."
}
// Active-enemy detection requires combat-state lookup; defer to E2.
cleared = false
for _, idx := range run.RoomsCleared {
if idx == run.CurrentRoom {
cleared = true
break
return true, ""
}
}
return cleared, ""
// Not yet advanced-past, but the only thing that bars a rest is a live
// fight. Forward-only navigation means players pause right after a kill
// before advancing, and peaceful/exploration/loot rooms never spawn an
// encounter at all — both are safe to rest in. The "cleared" flag would
// otherwise reject standard camp here with a misleading "clear it first".
encID := encounterIDForRoom(run.CurrentRoom)
sess, err := getCombatSessionForEncounter(run.RunID, encID)
if err != nil {
// Can't read the encounter's combat state. Fail open like the
// run-lookup path above rather than blocking standard camp with an
// empty (misleading "not cleared") rejection — a DB hiccup shouldn't
// strand a player who only wants to rest.
slog.Warn("camp: combat session lookup failed; allowing camp",
"run", run.RunID, "encounter", encID, "err", err)
return true, ""
}
if sess != nil && sess.Status == CombatStatusActive {
return false, "You can't camp mid-fight — finish the encounter first."
}
return true, ""
}
// autoBreakCampOnMove strikes an active camp when the player has moved
// to a different room than the one camp was pitched in. Camp is a
// stationary intent (long-rest at this spot until the next briefing);
// once the party walks on — whether by autopilot, manual !advance, or
// fork pick — the tent stops mattering. Overnight rest effects are not
// applied here (those only land at briefing time via
// processOvernightCamp). Returns the camp type that was struck, or ""
// if no break happened. Safe to call when there's no expedition.
func autoBreakCampOnMove(userID id.UserID) string {
exp, err := getActiveExpedition(userID)
if err != nil || exp == nil {
return ""
}
if exp.Camp == nil || !exp.Camp.Active {
return ""
}
if exp.RunID == "" {
return ""
}
run, err := getZoneRun(exp.RunID)
if err != nil || run == nil {
return ""
}
if run.CurrentRoom == exp.Camp.RoomIndex {
return ""
}
kind := exp.Camp.Type
if err := updateCamp(exp.ID, nil); err != nil {
slog.Warn("camp: auto-break on move failed", "expedition", exp.ID, "err", err)
return ""
}
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "camp struck (party moved on)", "")
return kind
}
// campCurrentRoomIndex returns 0 (entry) when no room context exists.

View File

@@ -261,6 +261,7 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
// Log the start with prewritten flavor.
startLine := flavor.Pick(flavor.ExpeditionStart)
_ = appendExpeditionLog(exp.ID, 1, "narrative", "expedition started", startLine)
markActedToday(ctx.Sender)
var b strings.Builder
b.WriteString(fmt.Sprintf("🗺 **Expedition begins — %s** _(T%d)_\n\n", zone.Display, int(zone.Tier)))
@@ -484,11 +485,17 @@ func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error {
if err := abandonExpedition(ctx.Sender); err != nil {
return p.SendDM(ctx.Sender, "Couldn't abandon: "+err.Error())
}
markActedToday(ctx.Sender)
_ = retireAllRegionRuns(exp)
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "expedition abandoned", "")
return p.SendDM(ctx.Sender, fmt.Sprintf(
if err := p.SendDM(ctx.Sender, fmt.Sprintf(
"Expedition in **%s** abandoned on Day %d. Supplies are forfeit. The dungeon remembers.",
zone.Display, exp.CurrentDay))
zone.Display, exp.CurrentDay)); err != nil {
return err
}
// Emergence seam: see maybeRollPetArrivalOnEmerge.
p.maybeRollPetArrivalOnEmerge(ctx.Sender)
return nil
}
// helper: ensure we don't shadow id.UserID import in test harness.
@@ -549,7 +556,19 @@ func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
if r.initErr != "" {
return p.SendDM(ctx.Sender, r.initErr)
}
return p.streamFlow(ctx.Sender, r.stream, r.finalMsg)
// Emergence seam: a natural run-complete (boss down / dead-end node)
// surfaces the player alive just like an extract or abandon — roll pet
// arrival here too. The roll lives in the real callers, not in
// runAutopilotWalk, so the sim path (which calls the walk directly)
// never fires arrival DMs. See maybeRollPetArrivalOnEmerge. Defer it
// behind the paced stream so the "animal in your house" DM lands after
// the "Run complete" beat, not before it.
var after func()
if r.reason == stopComplete {
uid := ctx.Sender
after = func() { p.maybeRollPetArrivalOnEmerge(uid) }
}
return p.streamFlowThen(ctx.Sender, r.stream, r.finalMsg, after)
}
// runAutopilotWalk runs the autopilot loop up to maxRooms times and
@@ -570,6 +589,21 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com
return autopilotWalkResult{initErr: "No active region run. Try `!region` to refresh."}
}
// Already standing at a pending fork: autopilot can't pick for the
// player. Re-emit the prompt with rooms=0 so the background DM
// suppression keeps quiet and we don't tick the rooms counter on a
// no-op walk.
if run, rerr := getActiveZoneRun(ctx.Sender); rerr == nil && run != nil {
if pf, derr := decodePendingFork(run.NodeChoices); derr == nil && pf != nil {
zone := zoneOrFallback(run.ZoneID)
return autopilotWalkResult{
finalMsg: renderForkPrompt(zone, *pf),
rooms: 0,
reason: stopFork,
}
}
}
var stream []string
var finalMsg string
rooms := 0
@@ -612,6 +646,46 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com
rooms++
}
// Multi-region auto-advance: a mid-zone region clear completes the
// region's run (stopComplete) but leaves the wrapping expedition
// active. Rather than dead-stopping the walk at every region
// boundary, cross into the next region — burning the transit day +
// supplies exactly like manual `!region travel` — and keep walking
// within the remaining room budget. A full zone clear instead flips
// the expedition to 'complete' (getActiveExpedition → nil) and falls
// through to the normal stop below.
if res.reason == stopComplete {
if fresh, ferr := getActiveExpedition(ctx.Sender); ferr == nil && fresh != nil &&
IsMultiRegionZone(fresh.ZoneID) {
if cur, ok := CurrentRegion(fresh); ok {
if next, ok := nextRegion(fresh.ZoneID, cur.ID); ok {
// A region crossing burns a transit day + supplies and
// draws unprotected wandering damage. On the background
// walk, don't cross while the player is weak — preflight
// HP/SU and hand the crossing back to a foreground
// `!region travel` / `!expedition run` if either is low.
if compact {
if msg, stop := autopilotPreflight(ctx.Sender, fresh); stop {
finalMsg = res.final + "\n\n" + msg
reason = stopPreflight
break
}
}
stream = append(stream, res.final)
transit, terr := p.advanceToNextRegion(ctx.Sender, fresh, cur, next)
if terr != nil {
finalMsg = res.final + "\n\n⏸ **Autopilot paused — region transit failed.** `!region travel` to cross over manually."
reason = stopComplete
break
}
stream = append(stream, transit)
exp = fresh
continue
}
}
}
}
if res.reason != stopOK {
footer := autopilotFooter(res.reason, rooms)
if footer != "" {

View File

@@ -300,9 +300,33 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
}
if uid := id.UserID(e.UserID); uid != "" {
// The legacy overworld morning DM is skipped while underground, so
// its 25% morning pet event fires here instead, granting the one-day
// defense buff (reset at midnight via resetAllPetMorningDefense).
// Pet *arrival* is handled separately on the emergence seam below —
// not queued here — so we only roll the morning event.
if pet, perr := loadPetState(uid); perr == nil {
if petEvent := petMorningEvent(pet); petEvent != "" {
if char, cerr := loadAdvCharacter(uid); cerr == nil {
char.PetMorningDefense = true
if serr := saveAdvCharacter(char); serr != nil {
slog.Warn("expedition: save pet morning defense", "user", uid, "err", serr)
}
}
body = fmt.Sprintf("🐾 *%s*\n\n%s", petEvent, body)
}
}
if err := p.SendDM(uid, body); err != nil {
slog.Warn("expedition: send briefing DM", "user", uid, "err", err)
}
// Emergence seam: a briefing-time forced extraction (starvation /
// abyss collapse) surfaces the player alive — roll pet arrival.
// Combat/patrol deaths never reach deliverBriefing (the row is
// already abandoned), so an abandoned status here means a survived
// emergence; those death paths roll on respawn instead.
if e.Status == ExpeditionStatusAbandoned {
p.maybeRollPetArrivalOnEmerge(uid)
}
}
if err := appendExpeditionLog(e.ID, e.CurrentDay, "briefing",
fmt.Sprintf("morning briefing — %.1f SU consumed overnight", burn), line); err != nil {

View File

@@ -110,6 +110,89 @@ func forceExtractExpeditionForRunLoss(userID id.UserID, reason string) {
}
}
// finalizeExpeditionOnZoneClear bridges the run-complete seam (boss down,
// no outgoing edges) into expedition completion — the success-path twin of
// forceExtractExpeditionForRunLoss. When the cleared run is the active
// expedition's current run AND the clear finishes the whole zone (a
// single-region zone, or the zone-boss region of a multi-region zone), it
// flips the expedition to 'complete', records boss_defeated, and awards
// completion milestones. Returns the rendered milestone lines for the caller
// to append to the run-complete message.
//
// No-op (nil) for standalone runs and for mid-zone region clears, which
// leave the expedition active so inter-region travel can continue. Without
// this, a cleared zone leaves the expedition 'active' forever and the
// ambient ticker keeps DMing about a dungeon the player already finished.
func (p *AdventurePlugin) finalizeExpeditionOnZoneClear(userID id.UserID, runID string) []string {
exp, err := getActiveExpedition(userID)
if err != nil || exp == nil {
return nil
}
if exp.RunID != runID {
return nil // the completed run isn't this expedition's current run
}
if IsMultiRegionZone(exp.ZoneID) {
region, ok := CurrentRegion(exp)
if !ok {
return nil
}
if _, err := MarkRegionBossDefeated(exp, region.ID); err != nil {
slog.Warn("expedition: mark region boss defeated",
"user", userID, "expedition", exp.ID, "region", region.ID, "err", err)
}
if !region.IsZoneBoss {
return nil // region cleared; expedition continues to the next region
}
} else {
// Single-region zone: there's no region registry to flip through
// MarkRegionBossDefeated, so set the zone-level flag directly.
exp.BossDefeated = true
if _, err := db.Get().Exec(`
UPDATE dnd_expedition
SET boss_defeated = 1,
last_activity = CURRENT_TIMESTAMP
WHERE expedition_id = ?`, exp.ID); err != nil {
slog.Warn("expedition: set boss defeated on zone clear",
"user", userID, "expedition", exp.ID, "err", err)
}
}
// completeExpedition must run before AwardCompletionMilestones — the
// latter gates on status == 'complete'.
if err := completeExpedition(exp.ID, ExpeditionStatusComplete); err != nil {
slog.Warn("expedition: complete on zone clear",
"user", userID, "expedition", exp.ID, "err", err)
return nil
}
exp.Status = ExpeditionStatusComplete
_ = retireAllRegionRuns(exp)
return p.AwardCompletionMilestones(exp, false)
}
// midZoneRegionClear reports whether the just-completed run (runID) is the
// active expedition's current run AND finishes a non-boss region of a
// multi-region zone — i.e. a region clear that leaves the expedition active
// with a next region to cross into. Returns the cleared region, the next
// region, and true in that case; zero-values + false for a full zone clear,
// a standalone run, or any read error. Used to word the run-complete message
// (region-cleared vs zone-cleared) without re-deriving region state inline.
func midZoneRegionClear(userID id.UserID, runID string) (cur, next ExpeditionRegion, ok bool) {
exp, err := getActiveExpedition(userID)
if err != nil || exp == nil || exp.RunID != runID || !IsMultiRegionZone(exp.ZoneID) {
return ExpeditionRegion{}, ExpeditionRegion{}, false
}
region, found := CurrentRegion(exp)
if !found || region.IsZoneBoss {
return ExpeditionRegion{}, ExpeditionRegion{}, false
}
nxt, found := nextRegion(exp.ZoneID, region.ID)
if !found {
return ExpeditionRegion{}, ExpeditionRegion{}, false
}
return region, nxt, true
}
// getResumableExpedition returns the most recent 'extracting' row for the
// user, regardless of age (caller checks the 7-day window).
func getResumableExpedition(userID id.UserID) (*Expedition, error) {
@@ -169,6 +252,7 @@ func (p *AdventurePlugin) handleExtractCmd(ctx MessageContext, _ string) error {
line := flavor.Pick(flavor.ExtractionVoluntary)
_ = appendExpeditionLog(updated.ID, updated.CurrentDay, "narrative",
"voluntary extraction", line)
markActedToday(ctx.Sender)
var b strings.Builder
b.WriteString(fmt.Sprintf("🚪 **Extraction — %s, Day %d**\n\n",
@@ -179,7 +263,13 @@ func (p *AdventurePlugin) handleExtractCmd(ctx MessageContext, _ string) error {
}
b.WriteString(fmt.Sprintf("Loot, XP, and coins are kept. The dungeon stays where you left it — `!resume` within 7 days to come back. After %s the expedition expires.",
(time.Now().UTC().Add(extractResumeWindow)).Format("2006-01-02 15:04 MST")))
return p.SendDM(ctx.Sender, b.String())
if err := p.SendDM(ctx.Sender, b.String()); err != nil {
return err
}
// Emergence seam: surfacing from a run is when an animal may have moved
// into the empty house.
p.maybeRollPetArrivalOnEmerge(ctx.Sender)
return nil
}
// ── !resume command ─────────────────────────────────────────────────────────

View File

@@ -119,6 +119,9 @@ func (p *AdventurePlugin) handleExpeditionMapCmd(ctx MessageContext, _ string) e
b.WriteString(renderZoneGraphMap(g, run))
b.WriteString("\n```\n")
b.WriteString("_E=Entry ?=Exploration T=Trap ★=Elite ☠=Boss ⚿=Secret · ✓ cleared ▶ here · pending locked_")
if path := renderVisitedPath(g, run); path != "" {
b.WriteString("\n**Path:** " + path)
}
if chain != "" {
b.WriteString("\n_Regions: ")
b.WriteString(renderRegionLegend(exp))

View File

@@ -6,6 +6,8 @@ import (
"strings"
"gogobee/internal/flavor"
"maunium.net/go/mautrix/id"
)
// Phase 12 E4c — !region command surface and inter-region travel.
@@ -120,24 +122,41 @@ func (p *AdventurePlugin) regionCmdTravel(ctx MessageContext, exp *Expedition) e
"You're already in **%s** — the final region of this zone. Defeat the zone boss to complete the expedition.",
cur.Name))
}
narrative, err := p.advanceToNextRegion(ctx.Sender, exp, cur, next)
if err != nil {
return p.SendDM(ctx.Sender, "Region transit failed: "+err.Error())
}
return p.SendDM(ctx.Sender, narrative)
}
// advanceToNextRegion performs an inter-region transition: burns the transit
// day + supplies, fires the transit wandering check, retires the outgoing
// region's DungeonRun, bumps CurrentRegion + the visited list, and spawns the
// incoming region's run (pinning exp.RunID). Returns the rendered transit
// narrative block. Shared by the manual `!region travel` command and the
// autopilot walk's mid-zone auto-advance — keeping the transit cost identical
// on both paths.
func (p *AdventurePlugin) advanceToNextRegion(userID id.UserID, exp *Expedition, cur, next ExpeditionRegion) (string, error) {
// Burn one day of supplies (transit day).
siege := exp.SiegeMode
harsh := exp.ThreatLevel > 60 || zoneTemporalHarsh(exp)
newSupplies, burned := applyDailyBurn(exp.Supplies, harsh, siege)
exp.Supplies = newSupplies
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
return p.SendDM(ctx.Sender, "Couldn't apply transit supply burn: "+err.Error())
return "", fmt.Errorf("apply transit supply burn: %w", err)
}
if err := advanceExpeditionDay(exp.ID); err != nil {
return p.SendDM(ctx.Sender, "Couldn't advance the expedition day: "+err.Error())
return "", fmt.Errorf("advance expedition day: %w", err)
}
exp.CurrentDay++
// Transit wandering check (no camp protection — campMod = 0).
c, _ := LoadDnDCharacter(ctx.Sender)
c, _ := LoadDnDCharacter(userID)
var charClass DnDClass
charLevel := 1
if c != nil {
charClass = c.Class
charLevel = c.Level
}
tc := resolveTransitWanderingCheck(exp, charClass, nil)
_ = processTransitWanderingCheck(exp, tc)
@@ -145,24 +164,20 @@ func (p *AdventurePlugin) regionCmdTravel(ctx MessageContext, exp *Expedition) e
// R2 — retire the outgoing region's DungeonRun before mutating
// CurrentRegion so retireRegionRun keys the right region.
if err := retireRegionRun(exp, cur.ID); err != nil {
return p.SendDM(ctx.Sender, "Couldn't retire previous region run: "+err.Error())
return "", fmt.Errorf("retire previous region run: %w", err)
}
// Update CurrentRegion + visited list.
if err := setCurrentRegion(exp, next.ID); err != nil {
return p.SendDM(ctx.Sender, "Couldn't update current region: "+err.Error())
return "", fmt.Errorf("update current region: %w", err)
}
if _, err := MarkRegionVisited(exp, next.ID); err != nil {
return p.SendDM(ctx.Sender, "Couldn't mark region visited: "+err.Error())
return "", fmt.Errorf("mark region visited: %w", err)
}
// Spawn the new region's DungeonRun and pin exp.RunID.
charLevel := 1
if c != nil {
charLevel = c.Level
}
if _, err := ensureRegionRun(exp, charLevel); err != nil {
return p.SendDM(ctx.Sender, "Couldn't outfit the new region: "+err.Error())
return "", fmt.Errorf("outfit the new region: %w", err)
}
// Log + flavor.
@@ -189,7 +204,7 @@ func (p *AdventurePlugin) regionCmdTravel(ctx MessageContext, exp *Expedition) e
if next.IsZoneBoss {
b.WriteString("\n_★ Final region — the zone boss is here._")
}
return p.SendDM(ctx.Sender, b.String())
return b.String(), nil
}
// resolveTransitWanderingCheck mirrors resolveWanderingCheck (§6.1) but

View File

@@ -0,0 +1,116 @@
package plugin
import (
"testing"
"maunium.net/go/mautrix/id"
)
// Single-region zone: clearing the run completes the wrapping expedition
// and flips BossDefeated.
func TestFinalizeExpeditionOnZoneClear_SingleRegionCompletes(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@zclear-single:example.org")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneSunkenTemple, "run-1",
ExpeditionSupplies{Max: 10, Current: 10, DailyBurn: 1})
if err != nil {
t.Fatal(err)
}
if exp.RunID != "run-1" {
t.Fatalf("setup: RunID = %q", exp.RunID)
}
p := &AdventurePlugin{}
p.finalizeExpeditionOnZoneClear(uid, "run-1")
if act, _ := getActiveExpedition(uid); act != nil {
t.Fatalf("expedition still active after zone clear: status=%s", act.Status)
}
loaded, _ := getExpedition(exp.ID)
if loaded.Status != ExpeditionStatusComplete {
t.Errorf("status = %q, want %q", loaded.Status, ExpeditionStatusComplete)
}
if !loaded.BossDefeated {
t.Error("BossDefeated not set after single-region zone clear")
}
}
// A run that isn't the expedition's current run must not complete it.
func TestFinalizeExpeditionOnZoneClear_RunMismatchNoop(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@zclear-mismatch:example.org")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneSunkenTemple, "run-1",
ExpeditionSupplies{Max: 10, Current: 10, DailyBurn: 1})
if err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
p.finalizeExpeditionOnZoneClear(uid, "some-other-run")
loaded, _ := getExpedition(exp.ID)
if loaded.Status != ExpeditionStatusActive {
t.Errorf("status = %q, want active (mismatched run should be no-op)", loaded.Status)
}
}
// Multi-region zone: clearing a non-boss region marks it cleared but leaves
// the expedition active so inter-region travel can continue.
func TestFinalizeExpeditionOnZoneClear_MidZoneRegionStaysActive(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@zclear-midzone:example.org")
defer cleanupExpeditions(uid)
// CurrentRegion defaults to the first region (underdark_surface_tunnels,
// not the zone boss).
exp, err := startExpedition(uid, ZoneUnderdark, "run-1",
ExpeditionSupplies{Max: 10, Current: 10, DailyBurn: 1})
if err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
p.finalizeExpeditionOnZoneClear(uid, "run-1")
loaded, _ := getExpedition(exp.ID)
if loaded.Status != ExpeditionStatusActive {
t.Errorf("status = %q, want active after non-boss region clear", loaded.Status)
}
if !IsRegionCleared(loaded, "underdark_surface_tunnels") {
t.Error("first region not marked cleared")
}
if loaded.BossDefeated {
t.Error("BossDefeated set on a non-boss region clear")
}
}
// Multi-region zone: clearing the zone-boss region completes the expedition.
func TestFinalizeExpeditionOnZoneClear_ZoneBossRegionCompletes(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@zclear-zoneboss:example.org")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneUnderdark, "run-1",
ExpeditionSupplies{Max: 10, Current: 10, DailyBurn: 1})
if err != nil {
t.Fatal(err)
}
if err := setCurrentRegion(exp, "underdark_deep_throne"); err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
p.finalizeExpeditionOnZoneClear(uid, "run-1")
loaded, _ := getExpedition(exp.ID)
if loaded.Status != ExpeditionStatusComplete {
t.Errorf("status = %q, want complete after zone-boss region clear", loaded.Status)
}
if !loaded.BossDefeated {
t.Error("BossDefeated not set after zone-boss region clear")
}
}

View File

@@ -6,6 +6,8 @@ import (
"sort"
"strings"
"time"
"maunium.net/go/mautrix/id"
)
// !rest short / !rest long.
@@ -45,6 +47,20 @@ func restingLockoutRemaining(c *DnDCharacter) time.Duration {
return remaining
}
// restBlockedReason returns a player-facing message when the character
// cannot rest right now because they're mid-fight or mid-expedition. An
// empty string means rest is allowed. Both !rest short and !rest long
// honor this — the dungeon shouldn't be a campfire.
func restBlockedReason(uid id.UserID) string {
if hasActiveCombatSession(uid) {
return "You're mid-fight. Finish it (or `!flee`) before resting."
}
if exp, _ := getActiveExpedition(uid); exp != nil {
return "You can't rest while on an expedition. Use `!expedition extract` first."
}
return ""
}
func (p *AdventurePlugin) handleDnDRestCmd(ctx MessageContext, args string) error {
args = strings.TrimSpace(strings.ToLower(args))
switch args {
@@ -77,6 +93,9 @@ func (p *AdventurePlugin) handleDnDShortRest(ctx MessageContext) error {
return p.SendDM(ctx.Sender, "Couldn't load your character.")
}
if msg := restBlockedReason(ctx.Sender); msg != "" {
return p.SendDM(ctx.Sender, msg)
}
if c.ShortRestCharges <= 0 {
return p.SendDM(ctx.Sender,
"You're out of short rest charges. Take a `!rest long` to restore them.")
@@ -114,6 +133,7 @@ func (p *AdventurePlugin) handleDnDShortRest(ctx MessageContext) error {
if err := SaveDnDCharacter(c); err != nil {
return p.SendDM(ctx.Sender, "Couldn't save rest state: "+err.Error())
}
markActedToday(ctx.Sender)
var msg string
if c.HPCurrent > before {
@@ -169,6 +189,9 @@ func (p *AdventurePlugin) handleDnDLongRest(ctx MessageContext) error {
return p.SendDM(ctx.Sender, "Couldn't load your Adv 2.0 sheet.")
}
if msg := restBlockedReason(ctx.Sender); msg != "" {
return p.SendDM(ctx.Sender, msg)
}
if c.LastLongRestAt != nil {
elapsed := time.Since(*c.LastLongRestAt)
if elapsed < dndLongRestCooldown {
@@ -208,6 +231,7 @@ func (p *AdventurePlugin) handleDnDLongRest(ctx MessageContext) error {
if err := SaveDnDCharacter(c); err != nil {
return p.SendDM(ctx.Sender, "Couldn't save rest state: "+err.Error())
}
markActedToday(ctx.Sender)
_ = refreshAllResources(ctx.Sender)
// Phase 9: spell slots refresh on long rest.
_ = refreshSpellSlots(ctx.Sender)

View File

@@ -268,12 +268,22 @@ func applySpellBuff(spell SpellDefinition, c *DnDCharacter, stats *CombatStats,
stats.AttackBonus += 1
mods.DamageBonus += 0.05
case "spiritual_weapon":
// Spectral bonus-action attack each round. Reuse pet-attack channel.
if mods.PetAttackProc < 0.5 {
mods.PetAttackProc = 0.5
// Spectral bonus-action attack each round on its own channel so the
// narration doesn't borrow pet flavor (the cleric may have no pet).
// 1d8 + spell mod base, +1d8 per 2 slot levels above 2nd; the engine
// rolls the d5 variance, so Dmg carries the average + upcast bump.
base := 4 + spellAttackBonus(c)
if slot > 2 {
base += 4 * ((slot - 2) / 2)
}
if mods.PetAttackDmg < 6 {
mods.PetAttackDmg = 6
if base < 4 {
base = 4
}
if mods.SpiritWeaponProc < 0.5 {
mods.SpiritWeaponProc = 0.5
}
if mods.SpiritWeaponDmg < base {
mods.SpiritWeaponDmg = base
}
case "mirror_image":
mods.WardCharges += 2
@@ -422,6 +432,8 @@ type turnBuffDelta struct {
reflect float64
autoCrit, enemySkip bool
heal int // a MaxHP-raise (Aid) collapses to an immediate heal
dSpiritProc float64
dSpiritDmg int
}
// statComponent reports whether the buff has a re-applicable persistent stat
@@ -429,6 +441,7 @@ type turnBuffDelta struct {
func (d turnBuffDelta) statComponent() bool {
return d.dAC != 0 || d.dAtk != 0 || d.dSpeed != 0 || d.dPetDmg != 0 ||
d.dCrit != 0 || d.dDmgBonus != 0 || d.dPetProc != 0 ||
d.dSpiritProc != 0 || d.dSpiritDmg != 0 ||
(d.dReductMul > 0 && d.dReductMul != 1)
}
@@ -460,6 +473,8 @@ func diffTurnBuff(bs, as CombatStats, bm, am CombatModifiers) turnBuffDelta {
autoCrit: am.AutoCritFirst && !bm.AutoCritFirst,
enemySkip: am.SpellEnemySkipFirst && !bm.SpellEnemySkipFirst,
heal: as.MaxHP - bs.MaxHP,
dSpiritProc: am.SpiritWeaponProc - bm.SpiritWeaponProc,
dSpiritDmg: am.SpiritWeaponDmg - bm.SpiritWeaponDmg,
}
if bm.DamageReduct > 0 {
d.dReductMul = am.DamageReduct / bm.DamageReduct

View File

@@ -308,6 +308,9 @@ func (p *AdventurePlugin) zoneCmdMap(ctx MessageContext) error {
b.WriteString(renderZoneGraphMap(g, run))
b.WriteString("\n```\n")
b.WriteString("_E=Entry ?=Exploration T=Trap ★=Elite ☠=Boss ⚿=Secret · ✓ cleared ▶ here · pending locked_")
if path := renderVisitedPath(g, run); path != "" {
b.WriteString("\n**Path:** " + path)
}
return p.SendDM(ctx.Sender, b.String())
}
// No registered graph (defensive — every zone has one post-G8).
@@ -457,6 +460,23 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool)
}
_ = applyMoodDecayIfStale(run)
zone := zoneOrFallback(run.ZoneID)
// A pending fork means advanceTransitionGraph already cleared the
// current room and stopped — re-running resolveRoom would re-fire
// combat and re-drop loot on the same room. Re-emit the fork prompt
// and let the caller surface it; the player commits via !zone go <n>.
// This returns *before* crediting the daily streak: spamming `!zone
// advance` at a fork resolves no room, so it must not keep the streak alive.
if pf, derr := decodePendingFork(run.NodeChoices); derr == nil && pf != nil {
return advanceResult{
final: renderForkPrompt(zone, *pf),
reason: stopFork,
}, nil
}
// compact==true is the background auto-walk path; only credit
// player-initiated advances toward the daily streak.
if !compact {
markActedToday(ctx.Sender)
}
prev := run.CurrentRoomType()
prevIdx := run.CurrentRoom
@@ -586,6 +606,10 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool)
if gerr != nil {
return advanceResult{}, fmt.Errorf("Couldn't advance: %s", gerr.Error())
}
var campStruck string
if kind := autoBreakCampOnMove(ctx.Sender); kind != "" {
campStruck = fmt.Sprintf("⛺ Camp struck (**%s**) — the party moved on.\n\n", kind)
}
if complete {
_, _ = applyMoodEvent(run.RunID, MoodEventZoneComplete)
var b strings.Builder
@@ -593,7 +617,19 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool)
b.WriteString(outcome)
b.WriteString("\n\n")
}
b.WriteString(fmt.Sprintf("🏆 **Cleared %s.** Run complete.\n\n", zone.Display))
if campStruck != "" {
b.WriteString(campStruck)
}
// A "complete" run is only a full zone clear when it isn't a mid-zone
// region clear of a multi-region zone. For the latter, name the region
// and point at the next one — "Cleared {zone}. Run complete." reads
// wrong right before the auto-advance transit block (and is shared with
// manual `!region travel`, which advances next).
if region, next, midZone := midZoneRegionClear(ctx.Sender, run.RunID); midZone {
b.WriteString(fmt.Sprintf("🏁 **Cleared %s.** The way to %s opens ahead.\n\n", region.Name, next.Name))
} else {
b.WriteString(fmt.Sprintf("🏆 **Cleared %s.** Run complete.\n\n", zone.Display))
}
if line := twinBeeLine(zone.ID, DMZoneComplete, run.RunID, prevIdx); line != "" {
b.WriteString(line)
b.WriteString("\n\n")
@@ -604,6 +640,16 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool)
b.WriteString("• " + id + "\n")
}
}
// Success-path expedition close-out: flip the wrapping expedition to
// 'complete' (when this clear finishes the whole zone) and surface any
// completion milestones. No-op for standalone runs / mid-zone region
// clears.
if lines := p.finalizeExpeditionOnZoneClear(ctx.Sender, run.RunID); len(lines) > 0 {
b.WriteString("\n")
for _, line := range lines {
b.WriteString(line)
}
}
return advanceResult{
preStream: preStream,
intro: intro,
@@ -619,6 +665,9 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool)
b.WriteString("\n\n")
}
b.WriteString(fmt.Sprintf("✓ Cleared room %d (%s).\n\n", prevIdx+1, prettyRoomType(prev)))
if campStruck != "" {
b.WriteString(campStruck)
}
b.WriteString(forkMsg)
return advanceResult{
preStream: preStream,
@@ -629,6 +678,9 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool)
}, nil
}
finalMsg := p.formatNextRoomMessage(run, zone, prev, prevIdx, outcome, next)
if campStruck != "" {
finalMsg = campStruck + finalMsg
}
// H2 — auto-harvest the room the player just walked into. Only fires
// for Exploration rooms (Entry/Trap/Elite/Boss self-skip via
@@ -734,7 +786,7 @@ func (p *AdventurePlugin) formatNextRoomMessage(run *DungeonRun, zone ZoneDefini
case RoomElite:
b.WriteString("`!fight` when ready.")
default:
b.WriteString("`!zone advance` to continue.")
b.WriteString(continueHint(id.UserID(run.UserID)))
}
return b.String()
}
@@ -778,6 +830,30 @@ func (p *AdventurePlugin) streamFlow(userID id.UserID, phaseMessages []string, f
return nil
}
// streamFlowThen behaves like streamFlow but runs after() once the final
// message has actually been delivered. Emergence follow-ups (e.g. the pet
// arrival DM) must land strictly *after* the paced run narration — rolling
// them before streamFlow returns races the streamer's goroutine and surfaces
// "there's an animal in your house" ahead of the "Run complete" beat the
// player is still waiting on. after may be nil.
func (p *AdventurePlugin) streamFlowThen(userID id.UserID, phaseMessages []string, finalMessage string, after func()) error {
if len(phaseMessages) == 0 {
err := p.SendDM(userID, finalMessage)
if after != nil {
after()
}
return err
}
done := p.sendZoneCombatMessages(userID, phaseMessages, finalMessage)
if after != nil {
go func() {
<-done
after()
}()
}
return nil
}
// resolveRoom dispatches to the per-room-type resolver. Returns staged
// messages (intro, phases, outcome) so combat rooms can be paced with
// inter-phase delays — see resolveCombatRoom for the contract. For

View File

@@ -169,7 +169,7 @@ func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error {
return p.SendDM(ctx.Sender, "Couldn't decode pending fork: "+derr.Error())
}
if pf == nil {
return p.SendDM(ctx.Sender, "No fork pending. Use `!zone advance` to continue.")
return p.SendDM(ctx.Sender, "No fork pending. Use "+continueHint(ctx.Sender))
}
rest = strings.TrimSpace(rest)
if rest == "" {
@@ -187,6 +187,7 @@ func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error {
if aerr := advanceZoneRunNode(run.RunID, chosen.To); aerr != nil {
return p.SendDM(ctx.Sender, "Couldn't advance: "+aerr.Error())
}
markActedToday(ctx.Sender)
g, _ := loadZoneGraph(run.ZoneID)
zone := zoneOrFallback(run.ZoneID)
fromNode := g.Nodes[run.CurrentNode]
@@ -195,6 +196,9 @@ func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error {
nextRoom := nodeKindToRoomType(nextNode.Kind)
nextIdx := run.CurrentRoom + 1
var b strings.Builder
if kind := autoBreakCampOnMove(ctx.Sender); kind != "" {
b.WriteString(fmt.Sprintf("⛺ Camp struck (**%s**) — the party moved on.\n\n", kind))
}
b.WriteString(fmt.Sprintf("➡ You take the path: **%s**.\n\n", chosen.Label))
if nextRoom == RoomBoss {
if line := composeBossEntry(zone.ID, run.RunID, nextIdx); line != "" {
@@ -207,7 +211,14 @@ func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error {
b.WriteString("\n\n")
}
}
b.WriteString(fmt.Sprintf("**Room %d/%d — %s.** `!zone advance` to continue.",
nextIdx+1, run.TotalRooms, prettyRoomType(nextRoom)))
b.WriteString(fmt.Sprintf("**Room %d/%d — %s.** ", nextIdx+1, run.TotalRooms, prettyRoomType(nextRoom)))
switch nextRoom {
case RoomBoss:
b.WriteString("`!fight` when you're ready for the boss.")
case RoomElite:
b.WriteString("`!fight` when ready.")
default:
b.WriteString(continueHint(ctx.Sender))
}
return p.SendDM(ctx.Sender, b.String())
}

View File

@@ -35,9 +35,9 @@ import (
const (
// ambientCooldown — minimum time between ambient events for one
// expedition. Tuned so a player offline for a workday sees ~3 hits,
// not a flood, but a multi-day expedition feels alive.
ambientCooldown = 3 * time.Hour
// expedition. Tuned so a player offline for a workday sees ~12 hits,
// not a flood, but a multi-day expedition still feels alive.
ambientCooldown = 6 * time.Hour
// ambientNearScheduleWindow — skip if we're within this many minutes
// of a scheduled briefing (06:00 UTC) or recap (21:00 UTC).

View File

@@ -34,12 +34,13 @@ const (
// autoRunTickInterval — how often the ticker wakes. The per-expedition
// cooldown is what actually paces; the tick just has to be frequent
// enough that the cooldown is enforced cleanly.
autoRunTickInterval = 5 * time.Minute
autoRunTickInterval = 15 * time.Minute
// autoRunCooldown — minimum gap between background walks for one
// expedition. 15 min keeps the dungeon moving while leaving room for
// the player to step in and steer if they want.
autoRunCooldown = 15 * time.Minute
// expedition. 2h is the slow cadence: the dungeon still moves on its
// own while the player is away, but the auto-walk DM stays a
// once-in-a-while ping instead of a steady drip.
autoRunCooldown = 2 * time.Hour
// autoRunMinExpeditionAge — don't auto-walk a brand-new expedition;
// let the player walk the first beat manually so the opening reads
@@ -93,6 +94,14 @@ func (p *AdventurePlugin) fireExpeditionAutoRuns(now time.Time) {
if hasActiveCombatSession(uid) {
continue
}
// Honor the rest lockout — short/long rest set RestingUntil and
// foreground !expedition run checks it; background must too, or
// the auto-walk ticker fires through a long rest.
if c, cerr := LoadDnDCharacter(uid); cerr == nil && c != nil {
if restingLockoutRemaining(c) > 0 {
continue
}
}
if err := p.tryAutoRun(e, now); err != nil {
slog.Warn("expedition: autorun step", "expedition", e.ID, "err", err)
}
@@ -154,13 +163,18 @@ func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error {
// "no expedition" / "no run" — race with abandon/extract. Silent.
return nil
}
if !shouldDMAutoRun(r) {
return nil
// Emergence seam: a run-complete reached by the background ticker is
// still a live emergence — roll pet arrival. See maybeRollPetArrivalOnEmerge.
// Deferred until after the run-summary DM below so the "animal in your
// house" prompt lands after the summary, not ahead of it.
if shouldDMAutoRun(r) {
body := renderAutoRunDM(r)
if err := p.SendDM(uid, body); err != nil {
slog.Warn("expedition: autorun DM", "user", uid, "err", err)
}
}
body := renderAutoRunDM(r)
if err := p.SendDM(uid, body); err != nil {
slog.Warn("expedition: autorun DM", "user", uid, "err", err)
if r.reason == stopComplete {
p.maybeRollPetArrivalOnEmerge(uid)
}
return nil
}

View File

@@ -60,6 +60,11 @@ func (s *SimRunner) BuildCharacter(uid id.UserID, class DnDClass, level int) (*D
if err := createAdvCharacter(uid, "sim_"+string(class)); err != nil {
return nil, fmt.Errorf("createAdvCharacter: %w", err)
}
if simPetLevel > 0 {
if err := attachSimPet(uid, simPetLevel); err != nil {
return nil, fmt.Errorf("attachSimPet: %w", err)
}
}
c := &DnDCharacter{
UserID: uid,
Race: RaceHuman,
@@ -170,6 +175,26 @@ func simConsumableBundle(tier int) map[string]int {
// L7-9 → T3, L10-12 → T4, L13+ → T5). It's a "kitted-out at expected
// difficulty" baseline, not a min-max — players past the appropriate
// shop visit should be at or above this band.
// attachSimPet stamps a base housing pet (Massive Dog, no armor) at the
// given level onto the synthetic character via the normal adv-char save
// path, so combat's DerivePlayerStats sees HasPet()==true. Dog vs cat is
// numerically identical today, so type is arbitrary; armor tier stays 0 to
// model the plain "base pet" rather than a kitted one.
func attachSimPet(uid id.UserID, level int) error {
char, err := loadAdvCharacter(uid)
if err != nil {
return err
}
char.PetType = "dog"
char.PetName = "SimDog"
char.PetLevel = level
char.PetArmorTier = 0
char.PetArrived = true
char.PetChasedAway = false
char.PetXP = 0
return saveAdvCharacter(char)
}
func outfitSimCharacter(uid id.UserID, level int) error {
tier := simGearTierForLevel(level)
equip, err := loadAdvEquipment(uid)
@@ -300,6 +325,20 @@ var simIncludeTrace = false
// room). Callers should flip this on before BuildCharacter / RunExpedition.
func SetSimIncludeTrace(on bool) { simIncludeTrace = on }
// simPetLevel, when > 0, attaches a base housing pet (Massive Dog, no
// armor) at that level to every synthetic character. 0 (the default) leaves
// the character petless, matching prod char-creation. Pets are otherwise
// unreachable in the sim — synthetic chars never trigger the arrival flow —
// so this is the only way to exercise the per-round pet attack / deflect /
// whiff path for balance measurement. Combat reads pet stats off the
// AdventureCharacter (see DerivePlayerStats), so BuildCharacter stamps the
// fields there, not on the DnDCharacter.
var simPetLevel = 0
// SetSimPetLevel attaches a base pet at the given level (1-10) to sim
// characters. 0 disables. Flip before BuildCharacter.
func SetSimPetLevel(level int) { simPetLevel = level }
// SimLogEntry is a JSONL-friendly projection of one dnd_expedition_log
// row. We expose just the fields a post-hoc analyzer needs without
// pulling the full ExpeditionEntry type.
@@ -363,6 +402,29 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap int) (*S
res.Outcome = "tpk"
i = walkCap // exit
case stopComplete:
// A stopComplete that reaches the sim is normally a full zone
// clear (the walk auto-advances mid-zone region boundaries
// internally). But a mid-zone region clear can still surface
// here — e.g. the walk's auto-advance hit a transit error and
// returned stopComplete. Mirror runAutopilotWalk: if the
// expedition is still active in a multi-region zone with a
// next region, cross into it and keep simulating instead of
// scoring a premature "cleared".
if fresh, ferr := getActiveExpedition(uid); ferr == nil && fresh != nil &&
IsMultiRegionZone(fresh.ZoneID) {
if cur, ok := CurrentRegion(fresh); ok {
if next, ok := nextRegion(fresh.ZoneID, cur.ID); ok {
if _, terr := s.P.advanceToNextRegion(uid, fresh, cur, next); terr != nil {
res.Outcome = "halted"
res.StopCode = "region_transit:" + terr.Error()
i = walkCap
break
}
exp = fresh
break // continue the outer walk loop in the next region
}
}
}
res.Outcome = "cleared"
i = walkCap
case stopBoss, stopElite:

View File

@@ -122,6 +122,10 @@ const fxHelpText = "**Forex Commands**\n\n" +
// ── Command Handlers ────────────────────────────────────────────────────────
func (p *ForexPlugin) cmdRate(ctx MessageContext, args []string) error {
if msg := fxValidateAnalysisArgs(args); msg != "" {
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
}
// Check for cross-pair syntax like EUR/USD or USD/JPY
if pair := fxParsePair(args, false); pair != nil {
return p.cmdPairRateOrReport(ctx, pair, false)
@@ -277,6 +281,9 @@ func (p *ForexPlugin) fxPerUSD(cur string, fiatRates map[string]float64) (float6
}
func (p *ForexPlugin) cmdReport(ctx MessageContext, args []string) error {
if msg := fxValidateAnalysisArgs(args); msg != "" {
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
}
if pair := fxParsePair(args, false); pair != nil {
return p.cmdPairRateOrReport(ctx, pair, true)
}
@@ -404,6 +411,34 @@ func (p *ForexPlugin) checkAlerts(rates map[string]float64) {
// ── Helpers ─────────────────────────────────────────────────────────────────
// fxValidateAnalysisArgs vets the currency tokens passed to the fiat-only
// rate/report path. It returns a player-facing error message if any token is
// unusable here, or "" when the args are fine (including empty args, which fall
// back to the default tracked set). Crypto tokens get a dedicated nudge since
// crypto only works on the conversion path; anything else is just invalid.
func fxValidateAnalysisArgs(args []string) string {
for _, a := range args {
// Split pair syntax (BTC/USD) into its sides so each is checked.
raw := strings.ToUpper(a)
toks := []string{raw}
for _, sep := range []string{"/", "|", "-"} {
if strings.Contains(raw, sep) {
toks = strings.SplitN(raw, sep, 2)
break
}
}
for _, t := range toks {
if fxIsCrypto(t) {
return "Silly rabbit. Crypto is for kids! (In other words.. that's an invalid command — crypto only works for conversions like `!fx 100 USD to BTC`.)"
}
if !fxIsSupported(t) {
return fmt.Sprintf("Don't know the currency `%s`. Try `!fx help`.", t)
}
}
}
return ""
}
func fxParseCurrencies(args []string) []string {
var out []string
for _, a := range args {

View File

@@ -4,7 +4,9 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
@@ -82,8 +84,11 @@ func (p *ForexPlugin) cgSpotUSD(sym string) (float64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
url := fmt.Sprintf("%s/simple/price?ids=%s&vs_currencies=usd", coinGeckoBaseURL, info.CoinGeckoID)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
q := url.Values{}
q.Set("ids", info.CoinGeckoID)
q.Set("vs_currencies", "usd")
reqURL := coinGeckoBaseURL + "/simple/price?" + q.Encode()
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
if err != nil {
return 0, err
}
@@ -99,7 +104,7 @@ func (p *ForexPlugin) cgSpotUSD(sym string) (float64, error) {
}
var data map[string]map[string]float64
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&data); err != nil {
return 0, fmt.Errorf("coingecko decode error: %w", err)
}
entry, ok := data[info.CoinGeckoID]

View File

@@ -909,6 +909,19 @@ func resetAllPlayerMetaDailyActions() error {
return err
}
// resetAllPetMorningDefense clears the one-day pet morning-defense buff for
// every player. The buff is a fresh morning grant (25% roll in the briefing /
// overworld DM), so it must not survive past the midnight rollover. The flag
// lives inside pet_flags_json (see petFlagsJSON), so patch that key in place;
// only rows where it's currently set are touched.
func resetAllPetMorningDefense() error {
_, err := db.Get().Exec(`
UPDATE player_meta
SET pet_flags_json = json_set(pet_flags_json, '$.morning_defense', json('false'))
WHERE json_extract(pet_flags_json, '$.morning_defense') = 1`)
return err
}
// DeathState mirrors player_meta's death-state columns. Phase L5e ports
// these fields off AdvCharacter (gogobee_legacy_migration.md §7.3 L5e).
// Mutation surface is large (~50 saveAdvCharacter sites touch death

View File

@@ -172,6 +172,28 @@ func nodeGlyph(n ZoneNode) string {
return "·"
}
// renderVisitedPath renders a one-line numbered breadcrumb of the
// player's path so they can reference rooms by index (e.g. !revisit 2).
// Numbers are 1-indexed to match every other surface ("Room 2/7", etc.).
func renderVisitedPath(g ZoneGraph, run *DungeonRun) string {
if run == nil || len(run.VisitedNodes) == 0 {
return ""
}
parts := make([]string, 0, len(run.VisitedNodes))
for i, nodeID := range run.VisitedNodes {
glyph := "·"
if n, ok := g.Nodes[nodeID]; ok {
glyph = nodeGlyph(n)
}
mark := "✓"
if nodeID == run.CurrentNode {
mark = "▶"
}
parts = append(parts, fmt.Sprintf("[%d]%s%s", i+1, glyph, mark))
}
return strings.Join(parts, " → ")
}
// debugDump (test-only crutch) prints the raw column layout. Currently
// unused outside potential debugging — kept off the unused-warning list
// by routing fmt through it.