mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Compare commits
19 Commits
forex-cryp
...
restore-ph
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f72484653 | ||
|
|
2e6274c1b7 | ||
|
|
9eed921e4b | ||
|
|
2ce56cf76a | ||
|
|
7dbfa0b56f | ||
|
|
b167882e3e | ||
|
|
95e0995c7f | ||
|
|
56f896b941 | ||
|
|
ef8fbe5496 | ||
|
|
bcd4a873a5 | ||
|
|
01d2329993 | ||
|
|
e4518c9c39 | ||
|
|
20b0d027b2 | ||
|
|
28a90292f0 | ||
|
|
5d7c76fb20 | ||
|
|
100a4f1054 | ||
|
|
f6a457ae84 | ||
|
|
513cf32e42 | ||
|
|
978dc5e25f |
@@ -43,10 +43,17 @@ func main() {
|
|||||||
runs = flag.Int("runs", 1, "replicates per (class,level,zone) cell (matrix mode)")
|
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")
|
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()
|
flag.Parse()
|
||||||
|
|
||||||
|
if *petLevel < 0 || *petLevel > 10 {
|
||||||
|
fail("pet-level must be 0-10, got", *petLevel)
|
||||||
|
}
|
||||||
|
|
||||||
plugin.SetSimIncludeTrace(*trace)
|
plugin.SetSimIncludeTrace(*trace)
|
||||||
|
plugin.SetSimPetLevel(*petLevel)
|
||||||
|
|
||||||
if *matrix {
|
if *matrix {
|
||||||
// Matrix default: drop log to keep stdout manageable; explicit
|
// Matrix default: drop log to keep stdout manageable; explicit
|
||||||
|
|||||||
@@ -853,7 +853,7 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact
|
|||||||
case "pet_type":
|
case "pet_type":
|
||||||
return p.resolvePetType(ctx)
|
return p.resolvePetType(ctx)
|
||||||
case "pet_name":
|
case "pet_name":
|
||||||
return p.resolvePetName(ctx)
|
return p.resolvePetName(ctx, interaction)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ func (p *AdventurePlugin) handleBabysitCmd(ctx MessageContext, args string) erro
|
|||||||
return p.SendDM(ctx.Sender, "🍼 **Adventurer Babysitting Service**\n\n"+
|
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"+
|
"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"+
|
" • 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"+
|
" • Rival duels declined on your behalf\n\n"+
|
||||||
"`!adventure babysit week` — 7 days of service\n"+
|
"`!adventure babysit week` — 7 days of service\n"+
|
||||||
"`!adventure babysit month` — 30 days of service\n"+
|
"`!adventure babysit month` — 30 days of service\n"+
|
||||||
|
|||||||
@@ -311,7 +311,30 @@ func (c *AdventureCharacter) CanDoHarvest(isHoliday bool) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *AdventureCharacter) HasActedToday() 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 {
|
func (c *AdventureCharacter) AllActionsUsed(isHoliday bool) bool {
|
||||||
|
|||||||
@@ -329,8 +329,11 @@ func (p *AdventurePlugin) resolvePetType(ctx MessageContext) error {
|
|||||||
article, titleCase(petType)))
|
article, titleCase(petType)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolvePetName handles naming the pet.
|
// resolvePetName handles naming the pet. The dispatcher (handlePendingReply)
|
||||||
func (p *AdventurePlugin) resolvePetName(ctx MessageContext) error {
|
// 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 := p.advUserLock(ctx.Sender)
|
||||||
userMu.Lock()
|
userMu.Lock()
|
||||||
defer userMu.Unlock()
|
defer userMu.Unlock()
|
||||||
@@ -340,20 +343,15 @@ func (p *AdventurePlugin) resolvePetName(ctx MessageContext) error {
|
|||||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||||
}
|
}
|
||||||
|
|
||||||
val, ok := p.pending.LoadAndDelete(string(ctx.Sender))
|
data := interaction.Data.(*advPendingPetName)
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
pi := val.(*advPendingInteraction)
|
|
||||||
data := pi.Data.(*advPendingPetName)
|
|
||||||
|
|
||||||
name := strings.TrimSpace(ctx.Body)
|
name := strings.TrimSpace(ctx.Body)
|
||||||
if len(name) == 0 || len(name) > 30 {
|
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.")
|
return p.SendDM(ctx.Sender, "Name must be 1-30 characters. Try again.")
|
||||||
}
|
}
|
||||||
if !petNameValid.MatchString(name) {
|
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.")
|
return p.SendDM(ctx.Sender, "Name can only contain letters, numbers, spaces, hyphens, and apostrophes. Try again.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
51
internal/plugin/adventure_pets_dispatch_test.go
Normal file
51
internal/plugin/adventure_pets_dispatch_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -396,7 +396,6 @@ func (p *AdventurePlugin) midnightTicker() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *AdventurePlugin) midnightReset() error {
|
func (p *AdventurePlugin) midnightReset() error {
|
||||||
// Send idle shame DMs to players who didn't act
|
|
||||||
chars, err := loadAllAdvCharacters()
|
chars, err := loadAllAdvCharacters()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load chars: %w", err)
|
return fmt.Errorf("load chars: %w", err)
|
||||||
@@ -405,79 +404,89 @@ func (p *AdventurePlugin) midnightReset() error {
|
|||||||
today := time.Now().UTC().Format("2006-01-02")
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
yesterday := time.Now().UTC().Add(-24 * time.Hour).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
|
dmsSent := 0
|
||||||
for _, char := range chars {
|
for _, char := range chars {
|
||||||
if !char.HasActedToday() {
|
// Died inside the window — no shame, no streak change. Covers both
|
||||||
// If the player died today or yesterday, they couldn't act — no shame,
|
// currently-dead players and players revived at midnight (Alive
|
||||||
// no streak reset. This covers both currently-dead players and players
|
// already flipped to true by the reminder loop before this runs).
|
||||||
// who were just revived at midnight (Alive already flipped to true by
|
if char.LastDeathDate == today || char.LastDeathDate == yesterday {
|
||||||
// the reminder loop before midnightReset runs).
|
continue
|
||||||
if char.LastDeathDate == today || char.LastDeathDate == yesterday {
|
}
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// An active expedition — or a turn-based fight locked open across
|
// Player-initiated engagement — only this credits the streak.
|
||||||
// midnight — counts as activity. Both track their own action flow
|
// LastActionDate is stamped at action time by markActedToday + !rest;
|
||||||
// (zone/harvest/combat/transit/extract) and never touch the legacy
|
// the legacy counters get bumped by the legacy CanDo... paths.
|
||||||
// CombatActionsUsed/HarvestActionsUsed counters, so HasActedToday()
|
engaged := char.LastActionDate == today || char.LastActionDate == yesterday ||
|
||||||
// reports false. Treat them like the acted-today branch below:
|
char.CombatActionsUsed > 0 || char.HarvestActionsUsed > 0
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Jitter between DMs to avoid Matrix rate limits
|
if engaged {
|
||||||
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 char.LastActionDate == yesterday || char.LastActionDate == today {
|
if char.LastActionDate == yesterday || char.LastActionDate == today {
|
||||||
char.CurrentStreak++
|
char.CurrentStreak++
|
||||||
} else {
|
} 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
|
char.CurrentStreak = 1
|
||||||
}
|
}
|
||||||
if char.CurrentStreak > char.BestStreak {
|
if char.CurrentStreak > char.BestStreak {
|
||||||
char.BestStreak = char.CurrentStreak
|
char.BestStreak = char.CurrentStreak
|
||||||
}
|
}
|
||||||
|
char.LastActionDate = today
|
||||||
_ = saveAdvCharacter(&char)
|
_ = 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,6 +504,12 @@ func (p *AdventurePlugin) midnightReset() error {
|
|||||||
return fmt.Errorf("reset daily actions after 3 attempts: %w", resetErr)
|
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
|
// Prune expired buffs
|
||||||
if err := pruneAdvExpiredBuffs(); err != nil {
|
if err := pruneAdvExpiredBuffs(); err != nil {
|
||||||
slog.Error("adventure: failed to prune expired buffs", "err", err)
|
slog.Error("adventure: failed to prune expired buffs", "err", err)
|
||||||
|
|||||||
145
internal/plugin/adventure_scheduler_test.go
Normal file
145
internal/plugin/adventure_scheduler_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
|
|||||||
case CombatStatusActive:
|
case CombatStatusActive:
|
||||||
return p.SendDM(ctx.Sender, "You're already in this fight — `!attack` or `!flee`.")
|
return p.SendDM(ctx.Sender, "You're already in this fight — `!attack` or `!flee`.")
|
||||||
case CombatStatusWon:
|
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:
|
default:
|
||||||
return p.SendDM(ctx.Sender, "This fight is already over. `!zone status` for where you stand.")
|
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
|
// Carry fight-start one-shot resources (Abjuration Arcane Ward, etc.) onto
|
||||||
// the session so they survive the turn engine's resume/commit cycle, and
|
// the session so they survive the turn engine's resume/commit cycle. The
|
||||||
// make the one-and-only per-fight pet-attack roll.
|
// pet now rolls per-turn inside the engine, so there's no fight-start roll.
|
||||||
seeded := seedCombatSessionOneShots(sess, player.Mods)
|
if seedCombatSessionOneShots(sess, player.Mods) {
|
||||||
pet := rollCombatSessionPetProc(sess, player.Mods)
|
|
||||||
if seeded || pet {
|
|
||||||
if err := saveCombatSession(sess); err != nil {
|
if err := saveCombatSession(sess); err != nil {
|
||||||
slog.Error("combat: seed session one-shots", "user", ctx.Sender, "err", err)
|
slog.Error("combat: seed session one-shots", "user", ctx.Sender, "err", err)
|
||||||
}
|
}
|
||||||
@@ -204,6 +202,18 @@ func combatTurnPrompt(sess *CombatSession) string {
|
|||||||
|
|
||||||
// ── close-out ───────────────────────────────────────────────────────────────
|
// ── 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
|
// finishCombatSession runs the post-fight side effects once a CombatSession
|
||||||
// has reached a terminal status, and returns the player-facing outcome block.
|
// 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
|
// 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)
|
slog.Error("combat: grantDnDXP turn-based", "user", userID, "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
bossOnExpedition := false
|
||||||
if !elite {
|
if !elite {
|
||||||
// §8.1 — zone boss defeat drops expedition threat. Silent no-op
|
// §8.1 — zone boss defeat drops expedition threat. Silent no-op
|
||||||
// for standalone zone runs (no active expedition).
|
// for standalone zone runs (no active expedition).
|
||||||
if exp, eerr := getActiveExpedition(userID); eerr == nil && exp != nil {
|
if exp, eerr := getActiveExpedition(userID); eerr == nil && exp != nil {
|
||||||
_ = applyBossDefeatThreat(exp.ID)
|
_ = applyBossDefeatThreat(exp.ID)
|
||||||
|
bossOnExpedition = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if line := twinBeeLine(zone.ID, DMCombatEnd, sess.RunID, cadence); line != "" {
|
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 != "" {
|
if drop := p.dropZoneLoot(userID, zone.ID, monster, !elite); drop != "" {
|
||||||
b.WriteString(drop + "\n")
|
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:
|
case CombatStatusLost:
|
||||||
if run != nil {
|
if run != nil {
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ type CombatModifiers struct {
|
|||||||
PetAttackDmg int
|
PetAttackDmg int
|
||||||
PetDeflectProc float64
|
PetDeflectProc float64
|
||||||
PetWhiffProc float64 // pet distracts enemy → guaranteed miss
|
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
|
SniperKillProc float64 // Arina instant-kill
|
||||||
MistyHealProc float64
|
MistyHealProc float64
|
||||||
MistyHealAmt int
|
MistyHealAmt int
|
||||||
@@ -319,10 +324,6 @@ type combatState struct {
|
|||||||
// the enemy would otherwise attack).
|
// the enemy would otherwise attack).
|
||||||
enemySkipFirst bool
|
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.
|
// Phase 10 SUB2a-ii first-attack one-shots.
|
||||||
firstAttackBonusUsed bool
|
firstAttackBonusUsed bool
|
||||||
assassinateRerollUsed 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
|
// Misty heal
|
||||||
if player.Mods.MistyHealProc > 0 && st.randFloat() < player.Mods.MistyHealProc {
|
if player.Mods.MistyHealProc > 0 && st.randFloat() < player.Mods.MistyHealProc {
|
||||||
healAmt := player.Mods.MistyHealAmt
|
healAmt := player.Mods.MistyHealAmt
|
||||||
|
|||||||
@@ -236,6 +236,9 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul
|
|||||||
case "pet_attack":
|
case "pet_attack":
|
||||||
return fmt.Sprintf(pickRand(narrativePetAttack), e.Damage)
|
return fmt.Sprintf(pickRand(narrativePetAttack), e.Damage)
|
||||||
|
|
||||||
|
case "spirit_weapon_strike":
|
||||||
|
return fmt.Sprintf(pickRand(narrativeSpiritWeapon), e.Damage)
|
||||||
|
|
||||||
case "pet_deflect":
|
case "pet_deflect":
|
||||||
return pickRand(narrativePetDeflect)
|
return pickRand(narrativePetDeflect)
|
||||||
|
|
||||||
@@ -326,9 +329,9 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul
|
|||||||
case "survive_at_1":
|
case "survive_at_1":
|
||||||
return pickRand(narrativeSurvive)
|
return pickRand(narrativeSurvive)
|
||||||
case "stat_drain":
|
case "stat_drain":
|
||||||
return pickRand(narrativeStatDrain)
|
return fmt.Sprintf(pickRand(narrativeStatDrain), e.Damage)
|
||||||
case "debuff":
|
case "debuff":
|
||||||
return pickRand(narrativeDebuff)
|
return fmt.Sprintf(pickRand(narrativeDebuff), e.Damage)
|
||||||
case "max_hp_drain":
|
case "max_hp_drain":
|
||||||
return fmt.Sprintf(pickRand(narrativeMaxHPDrain), e.Damage)
|
return fmt.Sprintf(pickRand(narrativeMaxHPDrain), e.Damage)
|
||||||
|
|
||||||
@@ -346,7 +349,7 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul
|
|||||||
case "fear_resist":
|
case "fear_resist":
|
||||||
return pickRand(narrativeFearResist)
|
return pickRand(narrativeFearResist)
|
||||||
case "ally_buff":
|
case "ally_buff":
|
||||||
return pickRand(narrativeAllyBuff)
|
return fmt.Sprintf(pickRand(narrativeAllyBuff), e.Damage)
|
||||||
|
|
||||||
case "timeout":
|
case "timeout":
|
||||||
return pickRand(narrativeTimeout)
|
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.",
|
"🐾 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{
|
var narrativePetDeflect = []string{
|
||||||
"🐾 Your pet intercepts the blow. Damage halved. Your pet is now your best piece of equipment.",
|
"🐾 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.",
|
"🐾 Your pet pushes you aside at the last second. Impact reduced. You did not ask to be pushed. Results speak for themselves.",
|
||||||
|
|||||||
24
internal/plugin/combat_narrative_drain_test.go
Normal file
24
internal/plugin/combat_narrative_drain_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,14 +65,6 @@ type CombatStatuses struct {
|
|||||||
// combatState, so the flag must survive the commit between them.
|
// combatState, so the flag must survive the commit between them.
|
||||||
EnemySkipNext bool `json:"enemy_skip_next,omitempty"`
|
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
|
// Fight-scoped depleting resources — mirror the combatState charges that
|
||||||
// genuinely carry round-to-round. Seeded at fight start (Arcane Ward) or by
|
// genuinely carry round-to-round. Seeded at fight start (Arcane Ward) or by
|
||||||
// a mid-fight !cast / !consume, restored into combatState on every resume,
|
// 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"`
|
BuffDamageBonus float64 `json:"buff_damage_bonus,omitempty"`
|
||||||
BuffPetProc float64 `json:"buff_pet_proc,omitempty"`
|
BuffPetProc float64 `json:"buff_pet_proc,omitempty"`
|
||||||
BuffDamageReductMul float64 `json:"buff_damage_reduct_mul,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
|
// 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.BuffCritRate += d.dCrit
|
||||||
s.BuffDamageBonus += d.dDmgBonus
|
s.BuffDamageBonus += d.dDmgBonus
|
||||||
s.BuffPetProc += d.dPetProc
|
s.BuffPetProc += d.dPetProc
|
||||||
|
s.BuffSpiritProc += d.dSpiritProc
|
||||||
|
s.BuffSpiritDmg += d.dSpiritDmg
|
||||||
if d.dReductMul > 0 && d.dReductMul != 1 {
|
if d.dReductMul > 0 && d.dReductMul != 1 {
|
||||||
if s.BuffDamageReductMul == 0 {
|
if s.BuffDamageReductMul == 0 {
|
||||||
s.BuffDamageReductMul = d.dReductMul
|
s.BuffDamageReductMul = d.dReductMul
|
||||||
|
|||||||
@@ -149,6 +149,8 @@ func applySessionBuffs(player *Combatant, s CombatStatuses) {
|
|||||||
player.Mods.DamageBonus += s.BuffDamageBonus
|
player.Mods.DamageBonus += s.BuffDamageBonus
|
||||||
player.Mods.PetAttackProc += s.BuffPetProc
|
player.Mods.PetAttackProc += s.BuffPetProc
|
||||||
player.Mods.PetAttackDmg += s.BuffPetDmg
|
player.Mods.PetAttackDmg += s.BuffPetDmg
|
||||||
|
player.Mods.SpiritWeaponProc += s.BuffSpiritProc
|
||||||
|
player.Mods.SpiritWeaponDmg += s.BuffSpiritDmg
|
||||||
if s.BuffDamageReductMul > 0 {
|
if s.BuffDamageReductMul > 0 {
|
||||||
player.Mods.DamageReduct *= s.BuffDamageReductMul
|
player.Mods.DamageReduct *= s.BuffDamageReductMul
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -425,72 +425,120 @@ func TestTurnEngine_OneShotsRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Pet proc (per-fight) ───────────────────────────────────────────────────
|
// ── Pet proc (per-round) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
func TestRollCombatSessionPetProc(t *testing.T) {
|
// TestTurnEngine_PetStrike confirms a pet with a guaranteed proc strikes on
|
||||||
// No pet proc on the player → never rolls true, never touches statuses.
|
// every player-acting turn — the per-round roll model, matching auto-resolve.
|
||||||
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.
|
|
||||||
func TestTurnEngine_PetStrike(t *testing.T) {
|
func TestTurnEngine_PetStrike(t *testing.T) {
|
||||||
// Pools huge so no phase can end the fight; isolate the pet behaviour.
|
// Pools huge so no phase can end the fight; isolate the pet behaviour.
|
||||||
sess := turnSession(CombatPhasePlayerTurn, 100000, 100000)
|
sess := turnSession(CombatPhasePlayerTurn, 100000, 100000)
|
||||||
sess.Statuses.PetProcReady = true
|
|
||||||
player, enemy := basePlayer(), baseEnemy()
|
player, enemy := basePlayer(), baseEnemy()
|
||||||
|
player.Mods.PetAttackProc = 1.0
|
||||||
player.Mods.PetAttackDmg = 8
|
player.Mods.PetAttackDmg = 8
|
||||||
|
|
||||||
events, err := stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionAttack})
|
petHitsOnPlayerTurn := func() int {
|
||||||
if err != nil {
|
events, err := stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionAttack})
|
||||||
t.Fatal(err)
|
if err != nil {
|
||||||
}
|
t.Fatal(err)
|
||||||
petHits := 0
|
}
|
||||||
for _, e := range events {
|
hits := 0
|
||||||
if e.Action == "pet_attack" {
|
for _, e := range events {
|
||||||
petHits++
|
if e.Action == "pet_attack" {
|
||||||
if e.Damage <= 0 {
|
hits++
|
||||||
t.Errorf("pet_attack damage = %d, want > 0", e.Damage)
|
if e.Damage <= 0 {
|
||||||
|
t.Errorf("pet_attack damage = %d, want > 0", e.Damage)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
return hits
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
for sess.Phase != CombatPhasePlayerTurn {
|
||||||
if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil {
|
if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, e := range events {
|
for _, e := range events {
|
||||||
if e.Action == "pet_attack" {
|
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) {
|
func TestCombatSessionRNG_DeterministicPerPhase(t *testing.T) {
|
||||||
sess := &CombatSession{SessionID: "abc123", Round: 2, Phase: CombatPhaseEnemyTurn}
|
sess := &CombatSession{SessionID: "abc123", Round: 2, Phase: CombatPhaseEnemyTurn}
|
||||||
a := combatSessionRNG(sess)
|
a := combatSessionRNG(sess)
|
||||||
|
|||||||
@@ -116,7 +116,6 @@ func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.R
|
|||||||
armorBroken: sess.Statuses.ArmorBroken,
|
armorBroken: sess.Statuses.ArmorBroken,
|
||||||
armorBreakAmt: sess.Statuses.ArmorBreakAmt,
|
armorBreakAmt: sess.Statuses.ArmorBreakAmt,
|
||||||
enemySkipFirst: sess.Statuses.EnemySkipNext,
|
enemySkipFirst: sess.Statuses.EnemySkipNext,
|
||||||
petProcReady: sess.Statuses.PetProcReady,
|
|
||||||
// Fight-scoped depleting resources + once-per-fight one-shots: restored
|
// Fight-scoped depleting resources + once-per-fight one-shots: restored
|
||||||
// from the persisted statuses so a charge or "already used" flag can't
|
// from the persisted statuses so a charge or "already used" flag can't
|
||||||
// reset across a suspend/resume. commit writes the updated values back.
|
// reset across a suspend/resume. commit writes the updated values back.
|
||||||
@@ -220,22 +219,26 @@ func (te *turnEngine) stepPlayerTurn(action PlayerAction) {
|
|||||||
te.finish(CombatStatusWon)
|
te.finish(CombatStatusWon)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if te.spiritWeaponStrike() {
|
||||||
|
te.finish(CombatStatusWon)
|
||||||
|
return
|
||||||
|
}
|
||||||
te.sess.Phase = CombatPhaseEnemyTurn
|
te.sess.Phase = CombatPhaseEnemyTurn
|
||||||
}
|
}
|
||||||
|
|
||||||
// petStrike resolves the player's pet attack for a turn-based fight. Whether
|
// petStrike resolves the player's pet attack for a turn-based fight. The pet
|
||||||
// the pet lands a hit was decided once at fight start (rollCombatSessionPetProc)
|
// rolls fresh on every player-acting turn (PetAttackProc), mirroring the
|
||||||
// and parked on the session; the pet then strikes a single time on the player's
|
// auto-resolve engine's per-round chance rather than a once-per-fight strike.
|
||||||
// first acting turn — this clears the flag so it never repeats. Damage reuses
|
// The roll rides the per-(round,phase) step RNG, so a suspend/resume or reaper
|
||||||
// the auto-resolve formula (PetAttackDmg + d5), and PetAttackDmg already carries
|
// auto-play of the same turn reproduces the same outcome. Damage reuses the
|
||||||
// any mid-fight buff delta via applySessionBuffs. Returns true if the strike
|
// auto-resolve formula (PetAttackDmg + d5), and PetAttackDmg already carries any
|
||||||
// dropped the enemy.
|
// mid-fight buff delta via applySessionBuffs. Returns true if the strike dropped
|
||||||
|
// the enemy.
|
||||||
func (te *turnEngine) petStrike() bool {
|
func (te *turnEngine) petStrike() bool {
|
||||||
st := te.st
|
st := te.st
|
||||||
if !st.petProcReady {
|
if te.player.Mods.PetAttackProc <= 0 || st.randFloat() >= te.player.Mods.PetAttackProc {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
st.petProcReady = false
|
|
||||||
petDmg := te.player.Mods.PetAttackDmg + st.roll(5)
|
petDmg := te.player.Mods.PetAttackDmg + st.roll(5)
|
||||||
st.enemyHP = max(0, st.enemyHP-petDmg)
|
st.enemyHP = max(0, st.enemyHP-petDmg)
|
||||||
st.events = append(st.events, CombatEvent{
|
st.events = append(st.events, CombatEvent{
|
||||||
@@ -245,30 +248,22 @@ func (te *turnEngine) petStrike() bool {
|
|||||||
return enemyDown(st, turnCombatPhase.Name)
|
return enemyDown(st, turnCombatPhase.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// rollCombatSessionPetProc makes the one-and-only per-fight pet-attack roll and
|
// spiritWeaponStrike resolves the spell's bonus-action attack each round when
|
||||||
// parks the result on the session. Called once at fight start. The draw is
|
// the spiritual_weapon buff is active. Same per-turn cadence as petStrike, but
|
||||||
// deterministic — seeded off the session id on a stream distinct from the
|
// rolls and narrates on its own channel so the spectral mace doesn't borrow
|
||||||
// per-(round,phase) combat streams — so a reaper auto-play of an abandoned
|
// pet flavor on a petless caster. Returns true if the strike dropped the enemy.
|
||||||
// fight reproduces the same outcome. Returns true if the pet will attack (so
|
func (te *turnEngine) spiritWeaponStrike() bool {
|
||||||
// the caller can decide whether the session needs persisting).
|
st := te.st
|
||||||
//
|
if te.player.Mods.SpiritWeaponProc <= 0 || st.randFloat() >= te.player.Mods.SpiritWeaponProc {
|
||||||
// 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 {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
var seed uint64 = 1469598103934665603
|
dmg := te.player.Mods.SpiritWeaponDmg + st.roll(5)
|
||||||
for _, c := range sess.SessionID {
|
st.enemyHP = max(0, st.enemyHP-dmg)
|
||||||
seed = (seed ^ uint64(c)) * 1099511628211
|
st.events = append(st.events, CombatEvent{
|
||||||
}
|
Round: st.round, Phase: turnCombatPhase.Name, Actor: "spirit_weapon", Action: "spirit_weapon_strike",
|
||||||
rng := rand.New(rand.NewPCG(seed, 0x9E3779B97F4A7C15))
|
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||||
if rngFloat(rng) < playerMods.PetAttackProc {
|
})
|
||||||
sess.Statuses.PetProcReady = true
|
return enemyDown(st, turnCombatPhase.Name)
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// stepPlayerActionEffect resolves a !cast / !consume turn: the command handler
|
// stepPlayerActionEffect resolves a !cast / !consume turn: the command handler
|
||||||
@@ -322,6 +317,10 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
|
|||||||
te.finish(CombatStatusWon)
|
te.finish(CombatStatusWon)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if te.spiritWeaponStrike() {
|
||||||
|
te.finish(CombatStatusWon)
|
||||||
|
return
|
||||||
|
}
|
||||||
if eff.EnemySkip {
|
if eff.EnemySkip {
|
||||||
// fear_immune enemies shrug off control spells — the skip never arms.
|
// fear_immune enemies shrug off control spells — the skip never arms.
|
||||||
if enemyImmuneToControl(te.enemy, st) {
|
if enemyImmuneToControl(te.enemy, st) {
|
||||||
@@ -375,17 +374,32 @@ func (te *turnEngine) stepEnemyTurn() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !abilityDealtDamage {
|
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
|
// SRD multiattack: each profile entry is one attack roll resolved
|
||||||
// through the shared primitive. A registered elite/boss swings its full
|
// through the shared primitive. A registered elite/boss swings its full
|
||||||
// profile; everyone else gets a single attack from the template stats.
|
// profile; everyone else gets a single attack from the template stats.
|
||||||
// resolveEnemyAttack returns true when the fight is decided — either the
|
// resolveEnemyAttack returns true when the fight is decided — either the
|
||||||
// player went down without a death save, or a reflect consumable killed
|
// player went down without a death save, or a reflect consumable killed
|
||||||
// the enemy. Disambiguate by inspecting HP.
|
// 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 := *te.enemy
|
||||||
swing.Stats.Attack = atk.Damage
|
swing.Stats.Attack = atk.Damage
|
||||||
swing.Stats.AttackBonus = atk.AttackBonus
|
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 {
|
if te.st.playerHP <= 0 {
|
||||||
te.finish(CombatStatusLost)
|
te.finish(CombatStatusLost)
|
||||||
return
|
return
|
||||||
@@ -479,7 +493,6 @@ func (te *turnEngine) commit() {
|
|||||||
s.ArmorBroken = st.armorBroken
|
s.ArmorBroken = st.armorBroken
|
||||||
s.ArmorBreakAmt = st.armorBreakAmt
|
s.ArmorBreakAmt = st.armorBreakAmt
|
||||||
s.EnemySkipNext = st.enemySkipFirst
|
s.EnemySkipNext = st.enemySkipFirst
|
||||||
s.PetProcReady = st.petProcReady
|
|
||||||
s.WardCharges = st.wardCharges
|
s.WardCharges = st.wardCharges
|
||||||
s.SporeRounds = st.sporeRounds
|
s.SporeRounds = st.sporeRounds
|
||||||
s.ReflectFrac = st.reflectFrac
|
s.ReflectFrac = st.reflectFrac
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package plugin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -79,12 +80,9 @@ func (p *AdventurePlugin) handleCampCmd(ctx MessageContext, args string) error {
|
|||||||
case "rough", "standard":
|
case "rough", "standard":
|
||||||
// allowed in E1e
|
// allowed in E1e
|
||||||
case "fortified":
|
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 {
|
if !exp.BossDefeated {
|
||||||
return p.SendDM(ctx.Sender,
|
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":
|
case "base":
|
||||||
// E4d: §11.1 — base camps unlock per region after the region
|
// 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 <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 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 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 base` — persistent waypoint (region-boss-cleared base-camp site, +3 SU, very low risk)\n")
|
||||||
b.WriteString("`!camp break` — break camp\n\n")
|
b.WriteString("`!camp break` — break camp\n\n")
|
||||||
if exp.Camp != nil && exp.Camp.Active {
|
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)
|
return p.SendDM(ctx.Sender, problem)
|
||||||
}
|
}
|
||||||
if !cleared && kind == CampTypeStandard {
|
if !cleared && kind == CampTypeStandard {
|
||||||
// §5.2: non-cleared room forces rough.
|
// §5.2: standard camp requires a cleared room. Reject explicitly
|
||||||
kind = CampTypeRough
|
// rather than silently downgrading — the player should make the call.
|
||||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative",
|
return p.SendDM(ctx.Sender,
|
||||||
"intended standard camp; downgraded to rough (room not cleared)", "")
|
"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]
|
cost, ok := campSupplyCost[kind]
|
||||||
@@ -359,15 +357,66 @@ func campLocationCheck(exp *Expedition) (cleared bool, problem string) {
|
|||||||
case RoomTrap:
|
case RoomTrap:
|
||||||
return false, "You can't camp in a trap room — even a disarmed one."
|
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 {
|
for _, idx := range run.RoomsCleared {
|
||||||
if idx == run.CurrentRoom {
|
if idx == run.CurrentRoom {
|
||||||
cleared = true
|
return true, ""
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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.
|
// campCurrentRoomIndex returns 0 (entry) when no room context exists.
|
||||||
|
|||||||
@@ -261,6 +261,7 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
|
|||||||
// Log the start with prewritten flavor.
|
// Log the start with prewritten flavor.
|
||||||
startLine := flavor.Pick(flavor.ExpeditionStart)
|
startLine := flavor.Pick(flavor.ExpeditionStart)
|
||||||
_ = appendExpeditionLog(exp.ID, 1, "narrative", "expedition started", startLine)
|
_ = appendExpeditionLog(exp.ID, 1, "narrative", "expedition started", startLine)
|
||||||
|
markActedToday(ctx.Sender)
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(fmt.Sprintf("🗺 **Expedition begins — %s** _(T%d)_\n\n", zone.Display, int(zone.Tier)))
|
b.WriteString(fmt.Sprintf("🗺 **Expedition begins — %s** _(T%d)_\n\n", zone.Display, int(zone.Tier)))
|
||||||
@@ -484,6 +485,7 @@ func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error {
|
|||||||
if err := abandonExpedition(ctx.Sender); err != nil {
|
if err := abandonExpedition(ctx.Sender); err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't abandon: "+err.Error())
|
return p.SendDM(ctx.Sender, "Couldn't abandon: "+err.Error())
|
||||||
}
|
}
|
||||||
|
markActedToday(ctx.Sender)
|
||||||
_ = retireAllRegionRuns(exp)
|
_ = retireAllRegionRuns(exp)
|
||||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "expedition abandoned", "")
|
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "expedition abandoned", "")
|
||||||
if err := p.SendDM(ctx.Sender, fmt.Sprintf(
|
if err := p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||||
@@ -554,7 +556,19 @@ func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
|
|||||||
if r.initErr != "" {
|
if r.initErr != "" {
|
||||||
return p.SendDM(ctx.Sender, 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
|
// runAutopilotWalk runs the autopilot loop up to maxRooms times and
|
||||||
@@ -575,6 +589,21 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com
|
|||||||
return autopilotWalkResult{initErr: "No active region run. Try `!region` to refresh."}
|
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 stream []string
|
||||||
var finalMsg string
|
var finalMsg string
|
||||||
rooms := 0
|
rooms := 0
|
||||||
@@ -617,6 +646,46 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com
|
|||||||
rooms++
|
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 {
|
if res.reason != stopOK {
|
||||||
footer := autopilotFooter(res.reason, rooms)
|
footer := autopilotFooter(res.reason, rooms)
|
||||||
if footer != "" {
|
if footer != "" {
|
||||||
|
|||||||
@@ -300,6 +300,22 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if uid := id.UserID(e.UserID); uid != "" {
|
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 {
|
if err := p.SendDM(uid, body); err != nil {
|
||||||
slog.Warn("expedition: send briefing DM", "user", uid, "err", err)
|
slog.Warn("expedition: send briefing DM", "user", uid, "err", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// getResumableExpedition returns the most recent 'extracting' row for the
|
||||||
// user, regardless of age (caller checks the 7-day window).
|
// user, regardless of age (caller checks the 7-day window).
|
||||||
func getResumableExpedition(userID id.UserID) (*Expedition, error) {
|
func getResumableExpedition(userID id.UserID) (*Expedition, error) {
|
||||||
@@ -169,6 +252,7 @@ func (p *AdventurePlugin) handleExtractCmd(ctx MessageContext, _ string) error {
|
|||||||
line := flavor.Pick(flavor.ExtractionVoluntary)
|
line := flavor.Pick(flavor.ExtractionVoluntary)
|
||||||
_ = appendExpeditionLog(updated.ID, updated.CurrentDay, "narrative",
|
_ = appendExpeditionLog(updated.ID, updated.CurrentDay, "narrative",
|
||||||
"voluntary extraction", line)
|
"voluntary extraction", line)
|
||||||
|
markActedToday(ctx.Sender)
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(fmt.Sprintf("🚪 **Extraction — %s, Day %d**\n\n",
|
b.WriteString(fmt.Sprintf("🚪 **Extraction — %s, Day %d**\n\n",
|
||||||
|
|||||||
@@ -119,6 +119,9 @@ func (p *AdventurePlugin) handleExpeditionMapCmd(ctx MessageContext, _ string) e
|
|||||||
b.WriteString(renderZoneGraphMap(g, run))
|
b.WriteString(renderZoneGraphMap(g, run))
|
||||||
b.WriteString("\n```\n")
|
b.WriteString("\n```\n")
|
||||||
b.WriteString("_E=Entry ?=Exploration T=Trap ★=Elite ☠=Boss ⚿=Secret · ✓ cleared ▶ here · pending ╳ locked_")
|
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 != "" {
|
if chain != "" {
|
||||||
b.WriteString("\n_Regions: ")
|
b.WriteString("\n_Regions: ")
|
||||||
b.WriteString(renderRegionLegend(exp))
|
b.WriteString(renderRegionLegend(exp))
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gogobee/internal/flavor"
|
"gogobee/internal/flavor"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Phase 12 E4c — !region command surface and inter-region travel.
|
// 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.",
|
"You're already in **%s** — the final region of this zone. Defeat the zone boss to complete the expedition.",
|
||||||
cur.Name))
|
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).
|
// Burn one day of supplies (transit day).
|
||||||
siege := exp.SiegeMode
|
siege := exp.SiegeMode
|
||||||
harsh := exp.ThreatLevel > 60 || zoneTemporalHarsh(exp)
|
harsh := exp.ThreatLevel > 60 || zoneTemporalHarsh(exp)
|
||||||
newSupplies, burned := applyDailyBurn(exp.Supplies, harsh, siege)
|
newSupplies, burned := applyDailyBurn(exp.Supplies, harsh, siege)
|
||||||
exp.Supplies = newSupplies
|
exp.Supplies = newSupplies
|
||||||
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
|
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 {
|
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++
|
exp.CurrentDay++
|
||||||
|
|
||||||
// Transit wandering check (no camp protection — campMod = 0).
|
// Transit wandering check (no camp protection — campMod = 0).
|
||||||
c, _ := LoadDnDCharacter(ctx.Sender)
|
c, _ := LoadDnDCharacter(userID)
|
||||||
var charClass DnDClass
|
var charClass DnDClass
|
||||||
|
charLevel := 1
|
||||||
if c != nil {
|
if c != nil {
|
||||||
charClass = c.Class
|
charClass = c.Class
|
||||||
|
charLevel = c.Level
|
||||||
}
|
}
|
||||||
tc := resolveTransitWanderingCheck(exp, charClass, nil)
|
tc := resolveTransitWanderingCheck(exp, charClass, nil)
|
||||||
_ = processTransitWanderingCheck(exp, tc)
|
_ = 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
|
// R2 — retire the outgoing region's DungeonRun before mutating
|
||||||
// CurrentRegion so retireRegionRun keys the right region.
|
// CurrentRegion so retireRegionRun keys the right region.
|
||||||
if err := retireRegionRun(exp, cur.ID); err != nil {
|
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.
|
// Update CurrentRegion + visited list.
|
||||||
if err := setCurrentRegion(exp, next.ID); err != nil {
|
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 {
|
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.
|
// 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 {
|
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.
|
// Log + flavor.
|
||||||
@@ -189,7 +204,7 @@ func (p *AdventurePlugin) regionCmdTravel(ctx MessageContext, exp *Expedition) e
|
|||||||
if next.IsZoneBoss {
|
if next.IsZoneBoss {
|
||||||
b.WriteString("\n_★ Final region — the zone boss is here._")
|
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
|
// resolveTransitWanderingCheck mirrors resolveWanderingCheck (§6.1) but
|
||||||
|
|||||||
116
internal/plugin/dnd_expedition_zone_clear_test.go
Normal file
116
internal/plugin/dnd_expedition_zone_clear_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
)
|
)
|
||||||
|
|
||||||
// !rest short / !rest long.
|
// !rest short / !rest long.
|
||||||
@@ -45,6 +47,20 @@ func restingLockoutRemaining(c *DnDCharacter) time.Duration {
|
|||||||
return remaining
|
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 {
|
func (p *AdventurePlugin) handleDnDRestCmd(ctx MessageContext, args string) error {
|
||||||
args = strings.TrimSpace(strings.ToLower(args))
|
args = strings.TrimSpace(strings.ToLower(args))
|
||||||
switch args {
|
switch args {
|
||||||
@@ -77,6 +93,9 @@ func (p *AdventurePlugin) handleDnDShortRest(ctx MessageContext) error {
|
|||||||
return p.SendDM(ctx.Sender, "Couldn't load your character.")
|
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 {
|
if c.ShortRestCharges <= 0 {
|
||||||
return p.SendDM(ctx.Sender,
|
return p.SendDM(ctx.Sender,
|
||||||
"You're out of short rest charges. Take a `!rest long` to restore them.")
|
"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 {
|
if err := SaveDnDCharacter(c); err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't save rest state: "+err.Error())
|
return p.SendDM(ctx.Sender, "Couldn't save rest state: "+err.Error())
|
||||||
}
|
}
|
||||||
|
markActedToday(ctx.Sender)
|
||||||
|
|
||||||
var msg string
|
var msg string
|
||||||
if c.HPCurrent > before {
|
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.")
|
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 {
|
if c.LastLongRestAt != nil {
|
||||||
elapsed := time.Since(*c.LastLongRestAt)
|
elapsed := time.Since(*c.LastLongRestAt)
|
||||||
if elapsed < dndLongRestCooldown {
|
if elapsed < dndLongRestCooldown {
|
||||||
@@ -208,6 +231,7 @@ func (p *AdventurePlugin) handleDnDLongRest(ctx MessageContext) error {
|
|||||||
if err := SaveDnDCharacter(c); err != nil {
|
if err := SaveDnDCharacter(c); err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't save rest state: "+err.Error())
|
return p.SendDM(ctx.Sender, "Couldn't save rest state: "+err.Error())
|
||||||
}
|
}
|
||||||
|
markActedToday(ctx.Sender)
|
||||||
_ = refreshAllResources(ctx.Sender)
|
_ = refreshAllResources(ctx.Sender)
|
||||||
// Phase 9: spell slots refresh on long rest.
|
// Phase 9: spell slots refresh on long rest.
|
||||||
_ = refreshSpellSlots(ctx.Sender)
|
_ = refreshSpellSlots(ctx.Sender)
|
||||||
|
|||||||
@@ -268,12 +268,22 @@ func applySpellBuff(spell SpellDefinition, c *DnDCharacter, stats *CombatStats,
|
|||||||
stats.AttackBonus += 1
|
stats.AttackBonus += 1
|
||||||
mods.DamageBonus += 0.05
|
mods.DamageBonus += 0.05
|
||||||
case "spiritual_weapon":
|
case "spiritual_weapon":
|
||||||
// Spectral bonus-action attack each round. Reuse pet-attack channel.
|
// Spectral bonus-action attack each round on its own channel so the
|
||||||
if mods.PetAttackProc < 0.5 {
|
// narration doesn't borrow pet flavor (the cleric may have no pet).
|
||||||
mods.PetAttackProc = 0.5
|
// 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 {
|
if base < 4 {
|
||||||
mods.PetAttackDmg = 6
|
base = 4
|
||||||
|
}
|
||||||
|
if mods.SpiritWeaponProc < 0.5 {
|
||||||
|
mods.SpiritWeaponProc = 0.5
|
||||||
|
}
|
||||||
|
if mods.SpiritWeaponDmg < base {
|
||||||
|
mods.SpiritWeaponDmg = base
|
||||||
}
|
}
|
||||||
case "mirror_image":
|
case "mirror_image":
|
||||||
mods.WardCharges += 2
|
mods.WardCharges += 2
|
||||||
@@ -422,6 +432,8 @@ type turnBuffDelta struct {
|
|||||||
reflect float64
|
reflect float64
|
||||||
autoCrit, enemySkip bool
|
autoCrit, enemySkip bool
|
||||||
heal int // a MaxHP-raise (Aid) collapses to an immediate heal
|
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
|
// statComponent reports whether the buff has a re-applicable persistent stat
|
||||||
@@ -429,6 +441,7 @@ type turnBuffDelta struct {
|
|||||||
func (d turnBuffDelta) statComponent() bool {
|
func (d turnBuffDelta) statComponent() bool {
|
||||||
return d.dAC != 0 || d.dAtk != 0 || d.dSpeed != 0 || d.dPetDmg != 0 ||
|
return d.dAC != 0 || d.dAtk != 0 || d.dSpeed != 0 || d.dPetDmg != 0 ||
|
||||||
d.dCrit != 0 || d.dDmgBonus != 0 || d.dPetProc != 0 ||
|
d.dCrit != 0 || d.dDmgBonus != 0 || d.dPetProc != 0 ||
|
||||||
|
d.dSpiritProc != 0 || d.dSpiritDmg != 0 ||
|
||||||
(d.dReductMul > 0 && d.dReductMul != 1)
|
(d.dReductMul > 0 && d.dReductMul != 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,6 +473,8 @@ func diffTurnBuff(bs, as CombatStats, bm, am CombatModifiers) turnBuffDelta {
|
|||||||
autoCrit: am.AutoCritFirst && !bm.AutoCritFirst,
|
autoCrit: am.AutoCritFirst && !bm.AutoCritFirst,
|
||||||
enemySkip: am.SpellEnemySkipFirst && !bm.SpellEnemySkipFirst,
|
enemySkip: am.SpellEnemySkipFirst && !bm.SpellEnemySkipFirst,
|
||||||
heal: as.MaxHP - bs.MaxHP,
|
heal: as.MaxHP - bs.MaxHP,
|
||||||
|
dSpiritProc: am.SpiritWeaponProc - bm.SpiritWeaponProc,
|
||||||
|
dSpiritDmg: am.SpiritWeaponDmg - bm.SpiritWeaponDmg,
|
||||||
}
|
}
|
||||||
if bm.DamageReduct > 0 {
|
if bm.DamageReduct > 0 {
|
||||||
d.dReductMul = am.DamageReduct / bm.DamageReduct
|
d.dReductMul = am.DamageReduct / bm.DamageReduct
|
||||||
|
|||||||
@@ -308,6 +308,9 @@ func (p *AdventurePlugin) zoneCmdMap(ctx MessageContext) error {
|
|||||||
b.WriteString(renderZoneGraphMap(g, run))
|
b.WriteString(renderZoneGraphMap(g, run))
|
||||||
b.WriteString("\n```\n")
|
b.WriteString("\n```\n")
|
||||||
b.WriteString("_E=Entry ?=Exploration T=Trap ★=Elite ☠=Boss ⚿=Secret · ✓ cleared ▶ here · pending ╳ locked_")
|
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())
|
return p.SendDM(ctx.Sender, b.String())
|
||||||
}
|
}
|
||||||
// No registered graph (defensive — every zone has one post-G8).
|
// No registered graph (defensive — every zone has one post-G8).
|
||||||
@@ -457,6 +460,23 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool)
|
|||||||
}
|
}
|
||||||
_ = applyMoodDecayIfStale(run)
|
_ = applyMoodDecayIfStale(run)
|
||||||
zone := zoneOrFallback(run.ZoneID)
|
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()
|
prev := run.CurrentRoomType()
|
||||||
prevIdx := run.CurrentRoom
|
prevIdx := run.CurrentRoom
|
||||||
|
|
||||||
@@ -586,6 +606,10 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool)
|
|||||||
if gerr != nil {
|
if gerr != nil {
|
||||||
return advanceResult{}, fmt.Errorf("Couldn't advance: %s", gerr.Error())
|
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 {
|
if complete {
|
||||||
_, _ = applyMoodEvent(run.RunID, MoodEventZoneComplete)
|
_, _ = applyMoodEvent(run.RunID, MoodEventZoneComplete)
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
@@ -593,7 +617,19 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool)
|
|||||||
b.WriteString(outcome)
|
b.WriteString(outcome)
|
||||||
b.WriteString("\n\n")
|
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 != "" {
|
if line := twinBeeLine(zone.ID, DMZoneComplete, run.RunID, prevIdx); line != "" {
|
||||||
b.WriteString(line)
|
b.WriteString(line)
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
@@ -604,6 +640,16 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool)
|
|||||||
b.WriteString("• " + id + "\n")
|
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{
|
return advanceResult{
|
||||||
preStream: preStream,
|
preStream: preStream,
|
||||||
intro: intro,
|
intro: intro,
|
||||||
@@ -619,6 +665,9 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool)
|
|||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
b.WriteString(fmt.Sprintf("✓ Cleared room %d (%s).\n\n", prevIdx+1, prettyRoomType(prev)))
|
b.WriteString(fmt.Sprintf("✓ Cleared room %d (%s).\n\n", prevIdx+1, prettyRoomType(prev)))
|
||||||
|
if campStruck != "" {
|
||||||
|
b.WriteString(campStruck)
|
||||||
|
}
|
||||||
b.WriteString(forkMsg)
|
b.WriteString(forkMsg)
|
||||||
return advanceResult{
|
return advanceResult{
|
||||||
preStream: preStream,
|
preStream: preStream,
|
||||||
@@ -629,6 +678,9 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool)
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
finalMsg := p.formatNextRoomMessage(run, zone, prev, prevIdx, outcome, next)
|
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
|
// H2 — auto-harvest the room the player just walked into. Only fires
|
||||||
// for Exploration rooms (Entry/Trap/Elite/Boss self-skip via
|
// for Exploration rooms (Entry/Trap/Elite/Boss self-skip via
|
||||||
@@ -734,7 +786,7 @@ func (p *AdventurePlugin) formatNextRoomMessage(run *DungeonRun, zone ZoneDefini
|
|||||||
case RoomElite:
|
case RoomElite:
|
||||||
b.WriteString("`!fight` when ready.")
|
b.WriteString("`!fight` when ready.")
|
||||||
default:
|
default:
|
||||||
b.WriteString("`!zone advance` to continue.")
|
b.WriteString(continueHint(id.UserID(run.UserID)))
|
||||||
}
|
}
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
@@ -778,6 +830,30 @@ func (p *AdventurePlugin) streamFlow(userID id.UserID, phaseMessages []string, f
|
|||||||
return nil
|
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
|
// resolveRoom dispatches to the per-room-type resolver. Returns staged
|
||||||
// messages (intro, phases, outcome) so combat rooms can be paced with
|
// messages (intro, phases, outcome) so combat rooms can be paced with
|
||||||
// inter-phase delays — see resolveCombatRoom for the contract. For
|
// inter-phase delays — see resolveCombatRoom for the contract. For
|
||||||
|
|||||||
@@ -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())
|
return p.SendDM(ctx.Sender, "Couldn't decode pending fork: "+derr.Error())
|
||||||
}
|
}
|
||||||
if pf == nil {
|
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)
|
rest = strings.TrimSpace(rest)
|
||||||
if 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 {
|
if aerr := advanceZoneRunNode(run.RunID, chosen.To); aerr != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't advance: "+aerr.Error())
|
return p.SendDM(ctx.Sender, "Couldn't advance: "+aerr.Error())
|
||||||
}
|
}
|
||||||
|
markActedToday(ctx.Sender)
|
||||||
g, _ := loadZoneGraph(run.ZoneID)
|
g, _ := loadZoneGraph(run.ZoneID)
|
||||||
zone := zoneOrFallback(run.ZoneID)
|
zone := zoneOrFallback(run.ZoneID)
|
||||||
fromNode := g.Nodes[run.CurrentNode]
|
fromNode := g.Nodes[run.CurrentNode]
|
||||||
@@ -195,6 +196,9 @@ func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error {
|
|||||||
nextRoom := nodeKindToRoomType(nextNode.Kind)
|
nextRoom := nodeKindToRoomType(nextNode.Kind)
|
||||||
nextIdx := run.CurrentRoom + 1
|
nextIdx := run.CurrentRoom + 1
|
||||||
var b strings.Builder
|
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))
|
b.WriteString(fmt.Sprintf("➡ You take the path: **%s**.\n\n", chosen.Label))
|
||||||
if nextRoom == RoomBoss {
|
if nextRoom == RoomBoss {
|
||||||
if line := composeBossEntry(zone.ID, run.RunID, nextIdx); line != "" {
|
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("\n\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
b.WriteString(fmt.Sprintf("**Room %d/%d — %s.** `!zone advance` to continue.",
|
b.WriteString(fmt.Sprintf("**Room %d/%d — %s.** ", nextIdx+1, run.TotalRooms, prettyRoomType(nextRoom)))
|
||||||
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())
|
return p.SendDM(ctx.Sender, b.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,12 +34,13 @@ const (
|
|||||||
// autoRunTickInterval — how often the ticker wakes. The per-expedition
|
// autoRunTickInterval — how often the ticker wakes. The per-expedition
|
||||||
// cooldown is what actually paces; the tick just has to be frequent
|
// cooldown is what actually paces; the tick just has to be frequent
|
||||||
// enough that the cooldown is enforced cleanly.
|
// enough that the cooldown is enforced cleanly.
|
||||||
autoRunTickInterval = 5 * time.Minute
|
autoRunTickInterval = 15 * time.Minute
|
||||||
|
|
||||||
// autoRunCooldown — minimum gap between background walks for one
|
// autoRunCooldown — minimum gap between background walks for one
|
||||||
// expedition. 15 min keeps the dungeon moving while leaving room for
|
// expedition. 2h is the slow cadence: the dungeon still moves on its
|
||||||
// the player to step in and steer if they want.
|
// own while the player is away, but the auto-walk DM stays a
|
||||||
autoRunCooldown = 15 * time.Minute
|
// once-in-a-while ping instead of a steady drip.
|
||||||
|
autoRunCooldown = 2 * time.Hour
|
||||||
|
|
||||||
// autoRunMinExpeditionAge — don't auto-walk a brand-new expedition;
|
// autoRunMinExpeditionAge — don't auto-walk a brand-new expedition;
|
||||||
// let the player walk the first beat manually so the opening reads
|
// 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) {
|
if hasActiveCombatSession(uid) {
|
||||||
continue
|
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 {
|
if err := p.tryAutoRun(e, now); err != nil {
|
||||||
slog.Warn("expedition: autorun step", "expedition", e.ID, "err", err)
|
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.
|
// "no expedition" / "no run" — race with abandon/extract. Silent.
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !shouldDMAutoRun(r) {
|
// Emergence seam: a run-complete reached by the background ticker is
|
||||||
return nil
|
// 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if r.reason == stopComplete {
|
||||||
body := renderAutoRunDM(r)
|
p.maybeRollPetArrivalOnEmerge(uid)
|
||||||
if err := p.SendDM(uid, body); err != nil {
|
|
||||||
slog.Warn("expedition: autorun DM", "user", uid, "err", err)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
if err := createAdvCharacter(uid, "sim_"+string(class)); err != nil {
|
||||||
return nil, fmt.Errorf("createAdvCharacter: %w", err)
|
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{
|
c := &DnDCharacter{
|
||||||
UserID: uid,
|
UserID: uid,
|
||||||
Race: RaceHuman,
|
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
|
// 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
|
// difficulty" baseline, not a min-max — players past the appropriate
|
||||||
// shop visit should be at or above this band.
|
// 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 {
|
func outfitSimCharacter(uid id.UserID, level int) error {
|
||||||
tier := simGearTierForLevel(level)
|
tier := simGearTierForLevel(level)
|
||||||
equip, err := loadAdvEquipment(uid)
|
equip, err := loadAdvEquipment(uid)
|
||||||
@@ -300,6 +325,20 @@ var simIncludeTrace = false
|
|||||||
// room). Callers should flip this on before BuildCharacter / RunExpedition.
|
// room). Callers should flip this on before BuildCharacter / RunExpedition.
|
||||||
func SetSimIncludeTrace(on bool) { simIncludeTrace = on }
|
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
|
// SimLogEntry is a JSONL-friendly projection of one dnd_expedition_log
|
||||||
// row. We expose just the fields a post-hoc analyzer needs without
|
// row. We expose just the fields a post-hoc analyzer needs without
|
||||||
// pulling the full ExpeditionEntry type.
|
// pulling the full ExpeditionEntry type.
|
||||||
@@ -363,6 +402,29 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap int) (*S
|
|||||||
res.Outcome = "tpk"
|
res.Outcome = "tpk"
|
||||||
i = walkCap // exit
|
i = walkCap // exit
|
||||||
case stopComplete:
|
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"
|
res.Outcome = "cleared"
|
||||||
i = walkCap
|
i = walkCap
|
||||||
case stopBoss, stopElite:
|
case stopBoss, stopElite:
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -82,8 +84,11 @@ func (p *ForexPlugin) cgSpotUSD(sym string) (float64, error) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/simple/price?ids=%s&vs_currencies=usd", coinGeckoBaseURL, info.CoinGeckoID)
|
q := url.Values{}
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
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 {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -99,7 +104,7 @@ func (p *ForexPlugin) cgSpotUSD(sym string) (float64, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var data map[string]map[string]float64
|
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)
|
return 0, fmt.Errorf("coingecko decode error: %w", err)
|
||||||
}
|
}
|
||||||
entry, ok := data[info.CoinGeckoID]
|
entry, ok := data[info.CoinGeckoID]
|
||||||
|
|||||||
@@ -909,6 +909,19 @@ func resetAllPlayerMetaDailyActions() error {
|
|||||||
return err
|
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
|
// DeathState mirrors player_meta's death-state columns. Phase L5e ports
|
||||||
// these fields off AdvCharacter (gogobee_legacy_migration.md §7.3 L5e).
|
// these fields off AdvCharacter (gogobee_legacy_migration.md §7.3 L5e).
|
||||||
// Mutation surface is large (~50 saveAdvCharacter sites touch death
|
// Mutation surface is large (~50 saveAdvCharacter sites touch death
|
||||||
|
|||||||
@@ -172,6 +172,28 @@ func nodeGlyph(n ZoneNode) string {
|
|||||||
return "·"
|
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
|
// debugDump (test-only crutch) prints the raw column layout. Currently
|
||||||
// unused outside potential debugging — kept off the unused-warning list
|
// unused outside potential debugging — kept off the unused-warning list
|
||||||
// by routing fmt through it.
|
// by routing fmt through it.
|
||||||
|
|||||||
Reference in New Issue
Block a user