mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +00:00
Compare commits
7 Commits
56f896b941
...
restore-ph
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f72484653 | ||
|
|
2e6274c1b7 | ||
|
|
9eed921e4b | ||
|
|
2ce56cf76a | ||
|
|
7dbfa0b56f | ||
|
|
b167882e3e | ||
|
|
95e0995c7f |
@@ -43,10 +43,17 @@ func main() {
|
||||
runs = flag.Int("runs", 1, "replicates per (class,level,zone) cell (matrix mode)")
|
||||
|
||||
trace = flag.Bool("trace", false, "include raw per-round CombatEvent stream on the LAST combat of each expedition (boss room) — for J2 diagnostic sweeps")
|
||||
|
||||
petLevel = flag.Int("pet-level", 0, "attach a base housing pet at this level (1-10) to every sim character; 0 = no pet (default, matches prod char-creation)")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
if *petLevel < 0 || *petLevel > 10 {
|
||||
fail("pet-level must be 0-10, got", *petLevel)
|
||||
}
|
||||
|
||||
plugin.SetSimIncludeTrace(*trace)
|
||||
plugin.SetSimPetLevel(*petLevel)
|
||||
|
||||
if *matrix {
|
||||
// Matrix default: drop log to keep stdout manageable; explicit
|
||||
|
||||
@@ -853,7 +853,7 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact
|
||||
case "pet_type":
|
||||
return p.resolvePetType(ctx)
|
||||
case "pet_name":
|
||||
return p.resolvePetName(ctx)
|
||||
return p.resolvePetName(ctx, interaction)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -329,8 +329,11 @@ func (p *AdventurePlugin) resolvePetType(ctx MessageContext) error {
|
||||
article, titleCase(petType)))
|
||||
}
|
||||
|
||||
// resolvePetName handles naming the pet.
|
||||
func (p *AdventurePlugin) resolvePetName(ctx MessageContext) error {
|
||||
// resolvePetName handles naming the pet. The dispatcher (handlePendingReply)
|
||||
// has already deleted the pending interaction and passes it in, so this must
|
||||
// NOT re-load from p.pending — doing so previously made every adoption fail
|
||||
// silently (the carried PetType was lost and the save never ran).
|
||||
func (p *AdventurePlugin) resolvePetName(ctx MessageContext, interaction *advPendingInteraction) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
@@ -340,20 +343,15 @@ func (p *AdventurePlugin) resolvePetName(ctx MessageContext) error {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
|
||||
val, ok := p.pending.LoadAndDelete(string(ctx.Sender))
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
pi := val.(*advPendingInteraction)
|
||||
data := pi.Data.(*advPendingPetName)
|
||||
data := interaction.Data.(*advPendingPetName)
|
||||
|
||||
name := strings.TrimSpace(ctx.Body)
|
||||
if len(name) == 0 || len(name) > 30 {
|
||||
p.pending.Store(string(ctx.Sender), pi)
|
||||
p.pending.Store(string(ctx.Sender), interaction)
|
||||
return p.SendDM(ctx.Sender, "Name must be 1-30 characters. Try again.")
|
||||
}
|
||||
if !petNameValid.MatchString(name) {
|
||||
p.pending.Store(string(ctx.Sender), pi)
|
||||
p.pending.Store(string(ctx.Sender), interaction)
|
||||
return p.SendDM(ctx.Sender, "Name can only contain letters, numbers, spaces, hyphens, and apostrophes. Try again.")
|
||||
}
|
||||
|
||||
|
||||
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 {
|
||||
// Send idle shame DMs to players who didn't act
|
||||
chars, err := loadAllAdvCharacters()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load chars: %w", err)
|
||||
@@ -405,93 +404,89 @@ func (p *AdventurePlugin) midnightReset() error {
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
|
||||
|
||||
// Unified activity oracle — same source the daily report uses. Unions
|
||||
// adventure_activity_log + dnd_zone_run + dnd_expedition_log, so
|
||||
// background-autopilot walks, expedition extracts/completions, and any
|
||||
// subsystem that logged a beat all count as "something happened on this
|
||||
// player's behalf." LastActionDate alone misses the autopilot path
|
||||
// (dnd_zone_cmd.go gates markActedToday on !compact to keep autopilot
|
||||
// out of streak credit) — without this oracle, a player who let the
|
||||
// autopilot run a full expedition gets shamed at midnight.
|
||||
todayActs, _ := loadAdvDailyActivity(today)
|
||||
yesterdayActs, _ := loadAdvDailyActivity(yesterday)
|
||||
|
||||
dmsSent := 0
|
||||
for _, char := range chars {
|
||||
// This runs at 00:00 of the new UTC day, closing out the day that just
|
||||
// ended (= yesterday). HasActedToday() compares LastActionDate against
|
||||
// time.Now()'s date (the *new* day), so DnD-side players who credited
|
||||
// the closing day via LastActionDate=yesterday would read as idle and
|
||||
// get their streak halved. Credit activity on the closing day directly:
|
||||
// legacy counters are still non-zero here (reset happens further below),
|
||||
// and LastActionDate==yesterday means they played the day we're closing.
|
||||
acted := char.CombatActionsUsed > 0 || char.HarvestActionsUsed > 0 ||
|
||||
char.LastActionDate == today || char.LastActionDate == yesterday
|
||||
if !acted {
|
||||
// If the player died today or yesterday, they couldn't act — no shame,
|
||||
// no streak reset. This covers both currently-dead players and players
|
||||
// who were just revived at midnight (Alive already flipped to true by
|
||||
// the reminder loop before midnightReset runs).
|
||||
if char.LastDeathDate == today || char.LastDeathDate == yesterday {
|
||||
continue
|
||||
}
|
||||
// Died inside the window — no shame, no streak change. Covers both
|
||||
// currently-dead players and players revived at midnight (Alive
|
||||
// already flipped to true by the reminder loop before this runs).
|
||||
if char.LastDeathDate == today || char.LastDeathDate == yesterday {
|
||||
continue
|
||||
}
|
||||
|
||||
// An active expedition — or a turn-based fight locked open across
|
||||
// midnight — counts as activity. Both track their own action flow
|
||||
// (zone/harvest/combat/transit/extract) and never touch the legacy
|
||||
// CombatActionsUsed/HarvestActionsUsed counters, so HasActedToday()
|
||||
// reports false. Treat them like the acted-today branch below:
|
||||
// advance the streak and bail out (no idle-shame, no streak decay).
|
||||
busy := false
|
||||
if exp, err := getActiveExpedition(char.UserID); err != nil {
|
||||
slog.Warn("adventure: failed to check active expedition for idle reaper", "user", char.UserID, "err", err)
|
||||
} else if exp != nil {
|
||||
busy = true
|
||||
}
|
||||
if !busy && hasActiveCombatSession(char.UserID) {
|
||||
busy = true
|
||||
}
|
||||
if busy {
|
||||
if char.LastActionDate == yesterday || char.LastActionDate == today {
|
||||
char.CurrentStreak++
|
||||
} else {
|
||||
char.CurrentStreak = 1
|
||||
}
|
||||
if char.CurrentStreak > char.BestStreak {
|
||||
char.BestStreak = char.CurrentStreak
|
||||
}
|
||||
char.LastActionDate = today
|
||||
_ = saveAdvCharacter(&char)
|
||||
continue
|
||||
}
|
||||
// Player-initiated engagement — only this credits the streak.
|
||||
// LastActionDate is stamped at action time by markActedToday + !rest;
|
||||
// the legacy counters get bumped by the legacy CanDo... paths.
|
||||
engaged := char.LastActionDate == today || char.LastActionDate == yesterday ||
|
||||
char.CombatActionsUsed > 0 || char.HarvestActionsUsed > 0
|
||||
|
||||
// Jitter between DMs to avoid Matrix rate limits
|
||||
if dmsSent > 0 {
|
||||
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond)
|
||||
}
|
||||
dmsSent++
|
||||
|
||||
// Idle shame DM
|
||||
text := renderAdvIdleShameDM(char.UserID)
|
||||
if char.CurrentStreak > 0 {
|
||||
oldStreak := char.CurrentStreak
|
||||
char.CurrentStreak /= 2
|
||||
char.StreakDecayed = true
|
||||
text += fmt.Sprintf("\n\n🔥 Streak: %d → %d days", oldStreak, char.CurrentStreak)
|
||||
if char.CurrentStreak > 0 {
|
||||
text += " — not all is lost."
|
||||
}
|
||||
_ = saveAdvCharacter(&char)
|
||||
}
|
||||
if err := p.SendDM(char.UserID, text); err != nil {
|
||||
slog.Error("adventure: failed to send idle shame DM", "user", char.UserID, "err", err)
|
||||
}
|
||||
} else {
|
||||
// Update streak — LastActionDate was set at action time
|
||||
if engaged {
|
||||
if char.LastActionDate == yesterday || char.LastActionDate == today {
|
||||
char.CurrentStreak++
|
||||
} else {
|
||||
// Gap in activity — start fresh
|
||||
// Legacy-only path: counters bumped but LastActionDate stale.
|
||||
// Without this fall-through the streak would reset to 1 every
|
||||
// night even with continuous play.
|
||||
char.CurrentStreak = 1
|
||||
}
|
||||
if char.CurrentStreak > char.BestStreak {
|
||||
char.BestStreak = char.CurrentStreak
|
||||
}
|
||||
// Restamp to today (mirrors the busy branch above). A purely-legacy
|
||||
// actor whose action path bumps CombatActionsUsed/HarvestActionsUsed
|
||||
// but never stamps LastActionDate lands here with a stale date; without
|
||||
// this its streak would reset to 1 every night even with continuous play.
|
||||
char.LastActionDate = today
|
||||
_ = saveAdvCharacter(&char)
|
||||
continue
|
||||
}
|
||||
|
||||
// Activity happened on the player's behalf without an explicit tap —
|
||||
// autopilot walked rooms, expedition log gained beats, a fight session
|
||||
// is still locked open. Hold the streak: no bump, no shame, no decay.
|
||||
// The player engaged earlier to kick this off; autopilot is a feature,
|
||||
// not a way to game streaks, and absence of taps shouldn't strip
|
||||
// progress they earned through real play.
|
||||
if len(todayActs[char.UserID]) > 0 || len(yesterdayActs[char.UserID]) > 0 {
|
||||
continue
|
||||
}
|
||||
// Safety net for live state the activity logs don't reflect yet
|
||||
// (e.g. an active expedition that's been quiet today, or a combat
|
||||
// session locked open across midnight without a log append).
|
||||
if exp, err := getActiveExpedition(char.UserID); err != nil {
|
||||
slog.Warn("adventure: failed to check active expedition for idle reaper", "user", char.UserID, "err", err)
|
||||
} else if exp != nil {
|
||||
continue
|
||||
}
|
||||
if hasActiveCombatSession(char.UserID) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Truly idle — shame DM + streak halve.
|
||||
if dmsSent > 0 {
|
||||
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond)
|
||||
}
|
||||
dmsSent++
|
||||
|
||||
text := renderAdvIdleShameDM(char.UserID)
|
||||
if char.CurrentStreak > 0 {
|
||||
oldStreak := char.CurrentStreak
|
||||
char.CurrentStreak /= 2
|
||||
char.StreakDecayed = true
|
||||
text += fmt.Sprintf("\n\n🔥 Streak: %d → %d days", oldStreak, char.CurrentStreak)
|
||||
if char.CurrentStreak > 0 {
|
||||
text += " — not all is lost."
|
||||
}
|
||||
_ = saveAdvCharacter(&char)
|
||||
}
|
||||
if err := p.SendDM(char.UserID, text); err != nil {
|
||||
slog.Error("adventure: failed to send idle shame DM", "user", char.UserID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,11 @@ type CombatModifiers struct {
|
||||
PetAttackDmg int
|
||||
PetDeflectProc float64
|
||||
PetWhiffProc float64 // pet distracts enemy → guaranteed miss
|
||||
// Spiritual Weapon — separate channel from the pet so the spectral mace
|
||||
// gets its own narration when a cleric without a companion casts it.
|
||||
// Damage formula mirrors PetAttack (Dmg + d5), proc rolls per round.
|
||||
SpiritWeaponProc float64
|
||||
SpiritWeaponDmg int
|
||||
SniperKillProc float64 // Arina instant-kill
|
||||
MistyHealProc float64
|
||||
MistyHealAmt int
|
||||
@@ -645,6 +650,19 @@ func simulateRound(st *combatState, player, enemy *Combatant, phase *CombatPhase
|
||||
}
|
||||
}
|
||||
|
||||
// Spiritual Weapon strike
|
||||
if player.Mods.SpiritWeaponProc > 0 && st.randFloat() < player.Mods.SpiritWeaponProc {
|
||||
swDmg := player.Mods.SpiritWeaponDmg + st.roll(5)
|
||||
st.enemyHP = max(0, st.enemyHP-swDmg)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "spirit_weapon", Action: "spirit_weapon_strike",
|
||||
Damage: swDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
if enemyDown(st, phaseName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Misty heal
|
||||
if player.Mods.MistyHealProc > 0 && st.randFloat() < player.Mods.MistyHealProc {
|
||||
healAmt := player.Mods.MistyHealAmt
|
||||
|
||||
@@ -236,6 +236,9 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul
|
||||
case "pet_attack":
|
||||
return fmt.Sprintf(pickRand(narrativePetAttack), e.Damage)
|
||||
|
||||
case "spirit_weapon_strike":
|
||||
return fmt.Sprintf(pickRand(narrativeSpiritWeapon), e.Damage)
|
||||
|
||||
case "pet_deflect":
|
||||
return pickRand(narrativePetDeflect)
|
||||
|
||||
@@ -326,9 +329,9 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul
|
||||
case "survive_at_1":
|
||||
return pickRand(narrativeSurvive)
|
||||
case "stat_drain":
|
||||
return pickRand(narrativeStatDrain)
|
||||
return fmt.Sprintf(pickRand(narrativeStatDrain), e.Damage)
|
||||
case "debuff":
|
||||
return pickRand(narrativeDebuff)
|
||||
return fmt.Sprintf(pickRand(narrativeDebuff), e.Damage)
|
||||
case "max_hp_drain":
|
||||
return fmt.Sprintf(pickRand(narrativeMaxHPDrain), e.Damage)
|
||||
|
||||
@@ -346,7 +349,7 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul
|
||||
case "fear_resist":
|
||||
return pickRand(narrativeFearResist)
|
||||
case "ally_buff":
|
||||
return pickRand(narrativeAllyBuff)
|
||||
return fmt.Sprintf(pickRand(narrativeAllyBuff), e.Damage)
|
||||
|
||||
case "timeout":
|
||||
return pickRand(narrativeTimeout)
|
||||
@@ -526,6 +529,13 @@ var narrativePetAttack = []string{
|
||||
"🐾 Your faithful companion lands a hit for %d damage. More faithful than accurate, but today both applied.",
|
||||
}
|
||||
|
||||
var narrativeSpiritWeapon = []string{
|
||||
"✨ The spectral mace swings on its own and lands for %d damage. Floating menace, well-balanced.",
|
||||
"✨ Your spiritual weapon hovers, picks an angle, strikes — %d damage. No grip, all conviction.",
|
||||
"✨ A glowing weapon arcs in from beside you. %d damage. The enemy keeps trying to track it. Cannot.",
|
||||
"✨ The spectral blade flickers, then bites. %d damage. It does not tire. It does not blink.",
|
||||
}
|
||||
|
||||
var narrativePetDeflect = []string{
|
||||
"🐾 Your pet intercepts the blow. Damage halved. Your pet is now your best piece of equipment.",
|
||||
"🐾 Your pet pushes you aside at the last second. Impact reduced. You did not ask to be pushed. Results speak for themselves.",
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,8 @@ type CombatStatuses struct {
|
||||
BuffDamageBonus float64 `json:"buff_damage_bonus,omitempty"`
|
||||
BuffPetProc float64 `json:"buff_pet_proc,omitempty"`
|
||||
BuffDamageReductMul float64 `json:"buff_damage_reduct_mul,omitempty"`
|
||||
BuffSpiritProc float64 `json:"buff_spirit_proc,omitempty"`
|
||||
BuffSpiritDmg int `json:"buff_spirit_dmg,omitempty"`
|
||||
}
|
||||
|
||||
// applyBuffDelta folds one resolved buff (the result of a !cast / !consume
|
||||
@@ -134,6 +136,8 @@ func (s *CombatStatuses) applyBuffDelta(d turnBuffDelta) {
|
||||
s.BuffCritRate += d.dCrit
|
||||
s.BuffDamageBonus += d.dDmgBonus
|
||||
s.BuffPetProc += d.dPetProc
|
||||
s.BuffSpiritProc += d.dSpiritProc
|
||||
s.BuffSpiritDmg += d.dSpiritDmg
|
||||
if d.dReductMul > 0 && d.dReductMul != 1 {
|
||||
if s.BuffDamageReductMul == 0 {
|
||||
s.BuffDamageReductMul = d.dReductMul
|
||||
|
||||
@@ -149,6 +149,8 @@ func applySessionBuffs(player *Combatant, s CombatStatuses) {
|
||||
player.Mods.DamageBonus += s.BuffDamageBonus
|
||||
player.Mods.PetAttackProc += s.BuffPetProc
|
||||
player.Mods.PetAttackDmg += s.BuffPetDmg
|
||||
player.Mods.SpiritWeaponProc += s.BuffSpiritProc
|
||||
player.Mods.SpiritWeaponDmg += s.BuffSpiritDmg
|
||||
if s.BuffDamageReductMul > 0 {
|
||||
player.Mods.DamageReduct *= s.BuffDamageReductMul
|
||||
}
|
||||
|
||||
@@ -219,6 +219,10 @@ func (te *turnEngine) stepPlayerTurn(action PlayerAction) {
|
||||
te.finish(CombatStatusWon)
|
||||
return
|
||||
}
|
||||
if te.spiritWeaponStrike() {
|
||||
te.finish(CombatStatusWon)
|
||||
return
|
||||
}
|
||||
te.sess.Phase = CombatPhaseEnemyTurn
|
||||
}
|
||||
|
||||
@@ -244,6 +248,24 @@ func (te *turnEngine) petStrike() bool {
|
||||
return enemyDown(st, turnCombatPhase.Name)
|
||||
}
|
||||
|
||||
// spiritWeaponStrike resolves the spell's bonus-action attack each round when
|
||||
// the spiritual_weapon buff is active. Same per-turn cadence as petStrike, but
|
||||
// rolls and narrates on its own channel so the spectral mace doesn't borrow
|
||||
// pet flavor on a petless caster. Returns true if the strike dropped the enemy.
|
||||
func (te *turnEngine) spiritWeaponStrike() bool {
|
||||
st := te.st
|
||||
if te.player.Mods.SpiritWeaponProc <= 0 || st.randFloat() >= te.player.Mods.SpiritWeaponProc {
|
||||
return false
|
||||
}
|
||||
dmg := te.player.Mods.SpiritWeaponDmg + st.roll(5)
|
||||
st.enemyHP = max(0, st.enemyHP-dmg)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: turnCombatPhase.Name, Actor: "spirit_weapon", Action: "spirit_weapon_strike",
|
||||
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
return enemyDown(st, turnCombatPhase.Name)
|
||||
}
|
||||
|
||||
// stepPlayerActionEffect resolves a !cast / !consume turn: the command handler
|
||||
// has already rolled the spell / picked the item and spent the resource, so the
|
||||
// engine only applies the HP deltas and emits the event before handing off to
|
||||
@@ -295,6 +317,10 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
|
||||
te.finish(CombatStatusWon)
|
||||
return
|
||||
}
|
||||
if te.spiritWeaponStrike() {
|
||||
te.finish(CombatStatusWon)
|
||||
return
|
||||
}
|
||||
if eff.EnemySkip {
|
||||
// fear_immune enemies shrug off control spells — the skip never arms.
|
||||
if enemyImmuneToControl(te.enemy, st) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -369,7 +370,13 @@ func campLocationCheck(exp *Expedition) (cleared bool, problem string) {
|
||||
encID := encounterIDForRoom(run.CurrentRoom)
|
||||
sess, err := getCombatSessionForEncounter(run.RunID, encID)
|
||||
if err != nil {
|
||||
return false, ""
|
||||
// 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."
|
||||
@@ -377,6 +384,41 @@ func campLocationCheck(exp *Expedition) (cleared bool, problem string) {
|
||||
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.
|
||||
func campCurrentRoomIndex(exp *Expedition) int {
|
||||
if exp.RunID == "" {
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// !rest short / !rest long.
|
||||
@@ -45,6 +47,20 @@ func restingLockoutRemaining(c *DnDCharacter) time.Duration {
|
||||
return remaining
|
||||
}
|
||||
|
||||
// restBlockedReason returns a player-facing message when the character
|
||||
// cannot rest right now because they're mid-fight or mid-expedition. An
|
||||
// empty string means rest is allowed. Both !rest short and !rest long
|
||||
// honor this — the dungeon shouldn't be a campfire.
|
||||
func restBlockedReason(uid id.UserID) string {
|
||||
if hasActiveCombatSession(uid) {
|
||||
return "You're mid-fight. Finish it (or `!flee`) before resting."
|
||||
}
|
||||
if exp, _ := getActiveExpedition(uid); exp != nil {
|
||||
return "You can't rest while on an expedition. Use `!expedition extract` first."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleDnDRestCmd(ctx MessageContext, args string) error {
|
||||
args = strings.TrimSpace(strings.ToLower(args))
|
||||
switch args {
|
||||
@@ -77,6 +93,9 @@ func (p *AdventurePlugin) handleDnDShortRest(ctx MessageContext) error {
|
||||
return p.SendDM(ctx.Sender, "Couldn't load your character.")
|
||||
}
|
||||
|
||||
if msg := restBlockedReason(ctx.Sender); msg != "" {
|
||||
return p.SendDM(ctx.Sender, msg)
|
||||
}
|
||||
if c.ShortRestCharges <= 0 {
|
||||
return p.SendDM(ctx.Sender,
|
||||
"You're out of short rest charges. Take a `!rest long` to restore them.")
|
||||
@@ -170,6 +189,9 @@ func (p *AdventurePlugin) handleDnDLongRest(ctx MessageContext) error {
|
||||
return p.SendDM(ctx.Sender, "Couldn't load your Adv 2.0 sheet.")
|
||||
}
|
||||
|
||||
if msg := restBlockedReason(ctx.Sender); msg != "" {
|
||||
return p.SendDM(ctx.Sender, msg)
|
||||
}
|
||||
if c.LastLongRestAt != nil {
|
||||
elapsed := time.Since(*c.LastLongRestAt)
|
||||
if elapsed < dndLongRestCooldown {
|
||||
|
||||
@@ -268,12 +268,22 @@ func applySpellBuff(spell SpellDefinition, c *DnDCharacter, stats *CombatStats,
|
||||
stats.AttackBonus += 1
|
||||
mods.DamageBonus += 0.05
|
||||
case "spiritual_weapon":
|
||||
// Spectral bonus-action attack each round. Reuse pet-attack channel.
|
||||
if mods.PetAttackProc < 0.5 {
|
||||
mods.PetAttackProc = 0.5
|
||||
// Spectral bonus-action attack each round on its own channel so the
|
||||
// narration doesn't borrow pet flavor (the cleric may have no pet).
|
||||
// 1d8 + spell mod base, +1d8 per 2 slot levels above 2nd; the engine
|
||||
// rolls the d5 variance, so Dmg carries the average + upcast bump.
|
||||
base := 4 + spellAttackBonus(c)
|
||||
if slot > 2 {
|
||||
base += 4 * ((slot - 2) / 2)
|
||||
}
|
||||
if mods.PetAttackDmg < 6 {
|
||||
mods.PetAttackDmg = 6
|
||||
if base < 4 {
|
||||
base = 4
|
||||
}
|
||||
if mods.SpiritWeaponProc < 0.5 {
|
||||
mods.SpiritWeaponProc = 0.5
|
||||
}
|
||||
if mods.SpiritWeaponDmg < base {
|
||||
mods.SpiritWeaponDmg = base
|
||||
}
|
||||
case "mirror_image":
|
||||
mods.WardCharges += 2
|
||||
@@ -422,6 +432,8 @@ type turnBuffDelta struct {
|
||||
reflect float64
|
||||
autoCrit, enemySkip bool
|
||||
heal int // a MaxHP-raise (Aid) collapses to an immediate heal
|
||||
dSpiritProc float64
|
||||
dSpiritDmg int
|
||||
}
|
||||
|
||||
// statComponent reports whether the buff has a re-applicable persistent stat
|
||||
@@ -429,6 +441,7 @@ type turnBuffDelta struct {
|
||||
func (d turnBuffDelta) statComponent() bool {
|
||||
return d.dAC != 0 || d.dAtk != 0 || d.dSpeed != 0 || d.dPetDmg != 0 ||
|
||||
d.dCrit != 0 || d.dDmgBonus != 0 || d.dPetProc != 0 ||
|
||||
d.dSpiritProc != 0 || d.dSpiritDmg != 0 ||
|
||||
(d.dReductMul > 0 && d.dReductMul != 1)
|
||||
}
|
||||
|
||||
@@ -460,6 +473,8 @@ func diffTurnBuff(bs, as CombatStats, bm, am CombatModifiers) turnBuffDelta {
|
||||
autoCrit: am.AutoCritFirst && !bm.AutoCritFirst,
|
||||
enemySkip: am.SpellEnemySkipFirst && !bm.SpellEnemySkipFirst,
|
||||
heal: as.MaxHP - bs.MaxHP,
|
||||
dSpiritProc: am.SpiritWeaponProc - bm.SpiritWeaponProc,
|
||||
dSpiritDmg: am.SpiritWeaponDmg - bm.SpiritWeaponDmg,
|
||||
}
|
||||
if bm.DamageReduct > 0 {
|
||||
d.dReductMul = am.DamageReduct / bm.DamageReduct
|
||||
|
||||
@@ -606,6 +606,10 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool)
|
||||
if gerr != nil {
|
||||
return advanceResult{}, fmt.Errorf("Couldn't advance: %s", gerr.Error())
|
||||
}
|
||||
var campStruck string
|
||||
if kind := autoBreakCampOnMove(ctx.Sender); kind != "" {
|
||||
campStruck = fmt.Sprintf("⛺ Camp struck (**%s**) — the party moved on.\n\n", kind)
|
||||
}
|
||||
if complete {
|
||||
_, _ = applyMoodEvent(run.RunID, MoodEventZoneComplete)
|
||||
var b strings.Builder
|
||||
@@ -613,6 +617,9 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool)
|
||||
b.WriteString(outcome)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
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
|
||||
@@ -658,6 +665,9 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("✓ Cleared room %d (%s).\n\n", prevIdx+1, prettyRoomType(prev)))
|
||||
if campStruck != "" {
|
||||
b.WriteString(campStruck)
|
||||
}
|
||||
b.WriteString(forkMsg)
|
||||
return advanceResult{
|
||||
preStream: preStream,
|
||||
@@ -668,6 +678,9 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool)
|
||||
}, nil
|
||||
}
|
||||
finalMsg := p.formatNextRoomMessage(run, zone, prev, prevIdx, outcome, next)
|
||||
if campStruck != "" {
|
||||
finalMsg = campStruck + finalMsg
|
||||
}
|
||||
|
||||
// H2 — auto-harvest the room the player just walked into. Only fires
|
||||
// for Exploration rooms (Entry/Trap/Elite/Boss self-skip via
|
||||
|
||||
@@ -196,6 +196,9 @@ func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error {
|
||||
nextRoom := nodeKindToRoomType(nextNode.Kind)
|
||||
nextIdx := run.CurrentRoom + 1
|
||||
var b strings.Builder
|
||||
if kind := autoBreakCampOnMove(ctx.Sender); kind != "" {
|
||||
b.WriteString(fmt.Sprintf("⛺ Camp struck (**%s**) — the party moved on.\n\n", kind))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("➡ You take the path: **%s**.\n\n", chosen.Label))
|
||||
if nextRoom == RoomBoss {
|
||||
if line := composeBossEntry(zone.ID, run.RunID, nextIdx); line != "" {
|
||||
|
||||
@@ -60,6 +60,11 @@ func (s *SimRunner) BuildCharacter(uid id.UserID, class DnDClass, level int) (*D
|
||||
if err := createAdvCharacter(uid, "sim_"+string(class)); err != nil {
|
||||
return nil, fmt.Errorf("createAdvCharacter: %w", err)
|
||||
}
|
||||
if simPetLevel > 0 {
|
||||
if err := attachSimPet(uid, simPetLevel); err != nil {
|
||||
return nil, fmt.Errorf("attachSimPet: %w", err)
|
||||
}
|
||||
}
|
||||
c := &DnDCharacter{
|
||||
UserID: uid,
|
||||
Race: RaceHuman,
|
||||
@@ -170,6 +175,26 @@ func simConsumableBundle(tier int) map[string]int {
|
||||
// L7-9 → T3, L10-12 → T4, L13+ → T5). It's a "kitted-out at expected
|
||||
// difficulty" baseline, not a min-max — players past the appropriate
|
||||
// shop visit should be at or above this band.
|
||||
// attachSimPet stamps a base housing pet (Massive Dog, no armor) at the
|
||||
// given level onto the synthetic character via the normal adv-char save
|
||||
// path, so combat's DerivePlayerStats sees HasPet()==true. Dog vs cat is
|
||||
// numerically identical today, so type is arbitrary; armor tier stays 0 to
|
||||
// model the plain "base pet" rather than a kitted one.
|
||||
func attachSimPet(uid id.UserID, level int) error {
|
||||
char, err := loadAdvCharacter(uid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
char.PetType = "dog"
|
||||
char.PetName = "SimDog"
|
||||
char.PetLevel = level
|
||||
char.PetArmorTier = 0
|
||||
char.PetArrived = true
|
||||
char.PetChasedAway = false
|
||||
char.PetXP = 0
|
||||
return saveAdvCharacter(char)
|
||||
}
|
||||
|
||||
func outfitSimCharacter(uid id.UserID, level int) error {
|
||||
tier := simGearTierForLevel(level)
|
||||
equip, err := loadAdvEquipment(uid)
|
||||
@@ -300,6 +325,20 @@ var simIncludeTrace = false
|
||||
// room). Callers should flip this on before BuildCharacter / RunExpedition.
|
||||
func SetSimIncludeTrace(on bool) { simIncludeTrace = on }
|
||||
|
||||
// simPetLevel, when > 0, attaches a base housing pet (Massive Dog, no
|
||||
// armor) at that level to every synthetic character. 0 (the default) leaves
|
||||
// the character petless, matching prod char-creation. Pets are otherwise
|
||||
// unreachable in the sim — synthetic chars never trigger the arrival flow —
|
||||
// so this is the only way to exercise the per-round pet attack / deflect /
|
||||
// whiff path for balance measurement. Combat reads pet stats off the
|
||||
// AdventureCharacter (see DerivePlayerStats), so BuildCharacter stamps the
|
||||
// fields there, not on the DnDCharacter.
|
||||
var simPetLevel = 0
|
||||
|
||||
// SetSimPetLevel attaches a base pet at the given level (1-10) to sim
|
||||
// characters. 0 disables. Flip before BuildCharacter.
|
||||
func SetSimPetLevel(level int) { simPetLevel = level }
|
||||
|
||||
// SimLogEntry is a JSONL-friendly projection of one dnd_expedition_log
|
||||
// row. We expose just the fields a post-hoc analyzer needs without
|
||||
// pulling the full ExpeditionEntry type.
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -82,8 +84,11 @@ func (p *ForexPlugin) cgSpotUSD(sym string) (float64, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
|
||||
url := fmt.Sprintf("%s/simple/price?ids=%s&vs_currencies=usd", coinGeckoBaseURL, info.CoinGeckoID)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
q := url.Values{}
|
||||
q.Set("ids", info.CoinGeckoID)
|
||||
q.Set("vs_currencies", "usd")
|
||||
reqURL := coinGeckoBaseURL + "/simple/price?" + q.Encode()
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -99,7 +104,7 @@ func (p *ForexPlugin) cgSpotUSD(sym string) (float64, error) {
|
||||
}
|
||||
|
||||
var data map[string]map[string]float64
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&data); err != nil {
|
||||
return 0, fmt.Errorf("coingecko decode error: %w", err)
|
||||
}
|
||||
entry, ok := data[info.CoinGeckoID]
|
||||
|
||||
Reference in New Issue
Block a user