mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Adv 2.0 Phase L1: Babysit pivot + legacy resolver retire
Babysit pivots from harvest service to pet-care + safe-rest perk: - runBabysitDaily/runAutoBabysitDay/runBabysitAction deleted; replaced by runBabysitDailyTrickle (3 pet XP/day while subscribed). - BabysitSafeRest predicate promotes Standard camps to Fortified-tier rest (HP+1d6, threat -5, resources refresh) and reduces wandering monster check campMod from 0 to -4. Hooks live in dnd_expedition_camp.go and dnd_expedition_night.go. - !adventure babysit auto/focus subcommands removed; status/purchase DMs reflect the new perks. Scheduler drops the auto-babysit fallback block (no more silent debits + harvest-day for missed logins). Babysit subscribers still skip the morning DM; trickle runs in the background. TwinBee daily simulator self-contained: simulateTwinBeeOutcome + simulateTwinBeeLoot replace the resolveAdvAction call against a fake CombatLevel=35 character. Outcome distribution and reward shape preserved; gold-share to active players unchanged. Legacy activity resolver retired: resolveAdvAction, advEligibleLocations, AdvEligibleLocation, resolveAdvEmptyOutcome deleted from adventure_activities.go (zero production callers post-babysit/twinbee). AdvActionResult kept — combat_bridge still produces it for arena/dungeon flows; dies in L2/L3. Migration plan doc gogobee_legacy_migration.md added: five-phase L1-L5 plan covering arena (Adv 2.0 boss flow rebuild), co-op deletion, perk-gate sweep, and final teardown of AdventureCharacter + CombatLevel + combat_engine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -779,173 +779,6 @@ func advEquipmentMasteryBonus(equip map[EquipmentSlot]*AdvEquipment) float64 {
|
||||
return total
|
||||
}
|
||||
|
||||
func resolveAdvAction(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, loc *AdvLocation, bonuses *AdvBonusSummary, inPenaltyZone bool) *AdvActionResult {
|
||||
result := &AdvActionResult{
|
||||
Location: loc,
|
||||
XPSkill: advXPSkill(loc.Activity),
|
||||
}
|
||||
|
||||
// Apply equipment-mastery loot bonus on a local copy so we don't mutate
|
||||
// the caller's bonuses struct (it's reused across multiple actions).
|
||||
localBonuses := *bonuses
|
||||
localBonuses.LootQuality += advEquipmentMasteryBonus(equip)
|
||||
bonuses = &localBonuses
|
||||
|
||||
probs := calculateAdvProbabilities(char, equip, loc, bonuses, inPenaltyZone)
|
||||
|
||||
// Overlevel penalty — reduces loot and XP for farming low-tier content
|
||||
skillLevel := advEffectiveSkill(char, loc.Activity, bonuses)
|
||||
overlevelMult := advOverlevelMultiplier(skillLevel, loc)
|
||||
|
||||
// Roll outcome
|
||||
roll := rand.Float64() * 100
|
||||
|
||||
switch {
|
||||
case roll < probs.DeathPct:
|
||||
result.Outcome = AdvOutcomeDeath
|
||||
case roll < probs.DeathPct+probs.EmptyPct:
|
||||
// Activity-specific empty outcomes
|
||||
result.Outcome = resolveAdvEmptyOutcome(loc, roll)
|
||||
case roll < probs.DeathPct+probs.EmptyPct+probs.SuccessPct:
|
||||
result.Outcome = AdvOutcomeSuccess
|
||||
default:
|
||||
result.Outcome = AdvOutcomeExceptional
|
||||
}
|
||||
|
||||
// Near-death check: survived within 2% of death threshold
|
||||
if result.Outcome != AdvOutcomeDeath && roll < probs.DeathPct+2 && roll >= probs.DeathPct {
|
||||
result.NearDeath = true
|
||||
}
|
||||
|
||||
// Generate loot for success/exceptional
|
||||
if result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional {
|
||||
result.LootItems = generateAdvLoot(loc, result.Outcome == AdvOutcomeExceptional, bonuses.LootQuality)
|
||||
// Apply overlevel penalty to loot values
|
||||
if overlevelMult < 1.0 {
|
||||
for i := range result.LootItems {
|
||||
result.LootItems[i].Value = max(1, int64(float64(result.LootItems[i].Value)*overlevelMult))
|
||||
}
|
||||
}
|
||||
for _, item := range result.LootItems {
|
||||
result.TotalLootValue += item.Value
|
||||
}
|
||||
}
|
||||
|
||||
// XP calculation
|
||||
xp := advXPForOutcome(loc.Activity, loc.Tier, result.Outcome)
|
||||
if result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional {
|
||||
xp = advXPTable[loc.Activity][loc.Tier].Success
|
||||
if result.Outcome == AdvOutcomeExceptional {
|
||||
xp = advXPTable[loc.Activity][loc.Tier].Exceptional
|
||||
}
|
||||
}
|
||||
|
||||
xpResult := applyXPBonuses(XPBonusParams{
|
||||
BaseXP: xp,
|
||||
NearDeath: result.NearDeath,
|
||||
BonusMult: bonuses.XPMultiplier,
|
||||
Ironclad: advEquippedArenaSets(equip)["ironclad"],
|
||||
OverlevelMult: overlevelMult,
|
||||
})
|
||||
result.XPGained = xpResult.Total
|
||||
result.XPBreakdown = xpResult.Breakdown
|
||||
|
||||
// Equipment degradation on bad outcomes
|
||||
if result.Outcome == AdvOutcomeDeath || result.Outcome == AdvOutcomeEmpty ||
|
||||
result.Outcome == AdvOutcomeCaveIn || result.Outcome == AdvOutcomeBear ||
|
||||
result.Outcome == AdvOutcomeRiver {
|
||||
result.EquipDamage = applyAdvEquipDegradation(equip, result.Outcome)
|
||||
result.EquipBroken = advCheckBrokenEquipment(equip)
|
||||
}
|
||||
|
||||
// Increment actions_used for equipment mastery only on slots relevant
|
||||
// to the activity — a foraging trip shouldn't master a sword, and a
|
||||
// dungeon run shouldn't master a pickaxe. Detect threshold crossings
|
||||
// so the caller can DM a celebration.
|
||||
for slot, eq := range equip {
|
||||
if eq == nil {
|
||||
continue
|
||||
}
|
||||
if !advSlotRelevantToActivity(slot, loc.Activity) {
|
||||
continue
|
||||
}
|
||||
before := eq.ActionsUsed
|
||||
eq.ActionsUsed++
|
||||
for _, t := range advMasteryThresholds {
|
||||
if before < t && eq.ActionsUsed >= t {
|
||||
result.MasteryCrossings = append(result.MasteryCrossings, AdvMasteryCrossing{
|
||||
Slot: slot,
|
||||
ItemName: eq.Name,
|
||||
Threshold: t,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// resolveAdvEmptyOutcome returns an activity-specific "empty" outcome.
|
||||
func resolveAdvEmptyOutcome(loc *AdvLocation, _ float64) AdvOutcomeType {
|
||||
switch loc.Activity {
|
||||
case AdvActivityMining:
|
||||
// 40% chance of cave-in on "empty" result
|
||||
if rand.Float64() < 0.4 {
|
||||
return AdvOutcomeCaveIn
|
||||
}
|
||||
return AdvOutcomeEmpty
|
||||
|
||||
case AdvActivityForaging:
|
||||
// Split empty into specific outcomes
|
||||
r := rand.Float64()
|
||||
switch {
|
||||
case r < 0.35:
|
||||
return AdvOutcomeHornets
|
||||
case r < 0.55:
|
||||
return AdvOutcomeBear
|
||||
case r < 0.70:
|
||||
return AdvOutcomeRiver
|
||||
default:
|
||||
return AdvOutcomeEmpty
|
||||
}
|
||||
|
||||
case AdvActivityFishing:
|
||||
// Fishing empty is just empty — no sub-outcomes
|
||||
return AdvOutcomeEmpty
|
||||
|
||||
default:
|
||||
return AdvOutcomeEmpty
|
||||
}
|
||||
}
|
||||
|
||||
// ── Eligible Locations for DM Menu ───────────────────────────────────────────
|
||||
|
||||
type AdvEligibleLocation struct {
|
||||
Location *AdvLocation
|
||||
InPenaltyZone bool
|
||||
DeathPct float64
|
||||
ExceptionalPct float64
|
||||
}
|
||||
|
||||
func advEligibleLocations(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, activity AdvActivityType, bonuses *AdvBonusSummary) []AdvEligibleLocation {
|
||||
var eligible []AdvEligibleLocation
|
||||
for _, loc := range allAdvLocations(activity) {
|
||||
loc := loc
|
||||
ok, penalty := advIsEligible(char, equip, &loc, bonuses)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
probs := calculateAdvProbabilities(char, equip, &loc, bonuses, penalty)
|
||||
eligible = append(eligible, AdvEligibleLocation{
|
||||
Location: &loc,
|
||||
InPenaltyZone: penalty,
|
||||
DeathPct: probs.DeathPct,
|
||||
ExceptionalPct: probs.ExceptionalPct,
|
||||
})
|
||||
}
|
||||
return eligible
|
||||
}
|
||||
|
||||
// ── Party Bonus Check ────────────────────────────────────────────────────────
|
||||
|
||||
// advCheckPartyBonus checks if other players visited the same location today.
|
||||
|
||||
@@ -3,7 +3,6 @@ package plugin
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -18,44 +17,62 @@ func babysitDailyCost(combatLevel int) int {
|
||||
return 100 + (combatLevel * 20)
|
||||
}
|
||||
|
||||
// ── Weakest Skill ───────────────────────────────────────────────────────────
|
||||
// ── Pet-care daily trickle ─────────────────────────────────────────────────
|
||||
|
||||
func babysitWeakestSkill(char *AdventureCharacter) string {
|
||||
skills := []struct {
|
||||
name string
|
||||
level int
|
||||
}{
|
||||
{"mining", char.MiningSkill},
|
||||
{"fishing", char.FishingSkill},
|
||||
{"foraging", char.ForagingSkill},
|
||||
// petXPPerBabysitDay is the daily pet XP awarded while a babysit subscription
|
||||
// is active. Picked to be a meaningful but not overwhelming push toward L10:
|
||||
// roughly equivalent to a couple of player-driven actions per day.
|
||||
const petXPPerBabysitDay = 3
|
||||
|
||||
// runBabysitDailyTrickle grants daily pet XP while a babysit subscription is
|
||||
// active and logs an entry for the end-of-service summary. Caller is
|
||||
// responsible for saving the character afterwards.
|
||||
func (p *AdventurePlugin) runBabysitDailyTrickle(char *AdventureCharacter) {
|
||||
if !char.BabysitActive {
|
||||
return
|
||||
}
|
||||
minLevel := skills[0].level
|
||||
for _, s := range skills[1:] {
|
||||
if s.level < minLevel {
|
||||
minLevel = s.level
|
||||
leveled := false
|
||||
if char.HasPet() && char.PetLevel < 10 {
|
||||
// Bypass petGrantXP's per-action constant — we want a flat trickle.
|
||||
char.PetXP += petXPPerBabysitDay * 100
|
||||
for char.PetLevel < 10 {
|
||||
needed := petXPToNextLevel(char.PetLevel) * 100
|
||||
if char.PetXP < needed {
|
||||
break
|
||||
}
|
||||
char.PetXP -= needed
|
||||
char.PetLevel++
|
||||
leveled = true
|
||||
}
|
||||
if char.PetLevel >= 10 && char.PetLevel10Date == "" {
|
||||
char.PetLevel10Date = time.Now().UTC().Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
// Collect ties
|
||||
var tied []string
|
||||
for _, s := range skills {
|
||||
if s.level == minLevel {
|
||||
tied = append(tied, s.name)
|
||||
}
|
||||
outcome := "pet_care"
|
||||
if leveled {
|
||||
outcome = "pet_care_levelup"
|
||||
}
|
||||
return tied[rand.IntN(len(tied))]
|
||||
logBabysitActivity(char.UserID, "pet_care", outcome, 0, petXPPerBabysitDay, "")
|
||||
}
|
||||
|
||||
// skillToActivity maps a skill name to its activity type.
|
||||
func skillToActivity(skill string) AdvActivityType {
|
||||
switch skill {
|
||||
case "mining":
|
||||
return AdvActivityMining
|
||||
case "fishing":
|
||||
return AdvActivityFishing
|
||||
case "foraging":
|
||||
return AdvActivityForaging
|
||||
// BabysitSafeRest reports whether the user has an active babysit subscription
|
||||
// that should let standard camps qualify for fortified-tier rest perks.
|
||||
// Returns false on any error (treat as no babysit). Safe to call from tests
|
||||
// where the global DB has not been initialized — the panic is swallowed.
|
||||
func BabysitSafeRest(userID id.UserID) (active bool) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
active = false
|
||||
}
|
||||
}()
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err != nil || char == nil || !char.BabysitActive {
|
||||
return false
|
||||
}
|
||||
return AdvActivityMining
|
||||
if char.BabysitExpiresAt != nil && time.Now().UTC().After(*char.BabysitExpiresAt) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Command Handlers ────────────────────────────────────────────────────────
|
||||
@@ -72,82 +89,19 @@ func (p *AdventurePlugin) handleBabysitCmd(ctx MessageContext, args string) erro
|
||||
return p.handleBabysitPurchase(ctx, 7)
|
||||
case lower == "month":
|
||||
return p.handleBabysitPurchase(ctx, 30)
|
||||
case lower == "auto":
|
||||
return p.handleBabysitAutoToggle(ctx)
|
||||
case strings.HasPrefix(lower, "focus"):
|
||||
return p.handleBabysitFocus(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "focus")))
|
||||
default:
|
||||
return p.SendDM(ctx.Sender, "🍼 **Adventurer Babysitting Service**\n\n"+
|
||||
"Hire a babysitter to look after your camp and tend the pet while you sleep:\n"+
|
||||
" • Daily pet XP trickle (your pet still grows while you focus elsewhere)\n"+
|
||||
" • Standard camps act like fortified ones — rest deeply, no need for boss-cleared rooms\n"+
|
||||
" • Rival duels declined on your behalf\n\n"+
|
||||
"`!adventure babysit week` — 7 days of service\n"+
|
||||
"`!adventure babysit month` — 30 days of service\n"+
|
||||
"`!adventure babysit auto` — toggle auto-babysit on missed days\n"+
|
||||
"`!adventure babysit focus <mining|fishing|foraging>` — pick the skill auto-babysit trains\n"+
|
||||
"`!adventure babysit status` — check service status\n"+
|
||||
"`!adventure babysit cancel` — cancel early (no refund)")
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleBabysitFocus(ctx MessageContext, arg string) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
char, err := loadAdvCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "No adventurer found. Type `!adventure` to create one.")
|
||||
}
|
||||
|
||||
switch arg {
|
||||
case "":
|
||||
current := char.AutoBabysitFocus
|
||||
if current == "" {
|
||||
current = "weakest skill (default)"
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"🎯 **Auto-babysit focus:** %s\n\nSet with `!adventure babysit focus <mining|fishing|foraging>`. Use `!adventure babysit focus clear` to revert to the weakest-skill default.",
|
||||
current))
|
||||
case "clear", "default", "weakest", "off":
|
||||
char.AutoBabysitFocus = ""
|
||||
case "mining", "fishing", "foraging":
|
||||
char.AutoBabysitFocus = arg
|
||||
default:
|
||||
return p.SendDM(ctx.Sender, "Pick one of `mining`, `fishing`, `foraging`, or `clear`.")
|
||||
}
|
||||
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("babysit: failed to save focus", "user", ctx.Sender, "err", err)
|
||||
return p.SendDM(ctx.Sender, "Something went wrong. Try again.")
|
||||
}
|
||||
|
||||
if char.AutoBabysitFocus == "" {
|
||||
return p.SendDM(ctx.Sender, "🎯 Auto-babysit focus cleared. The babysitter will train your weakest skill.")
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("🎯 Auto-babysit focus set to **%s**. The babysitter will train this on missed days.", char.AutoBabysitFocus))
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleBabysitAutoToggle(ctx MessageContext) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
char, err := loadAdvCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "No adventurer found. Type `!adventure` to create one.")
|
||||
}
|
||||
|
||||
char.AutoBabysit = !char.AutoBabysit
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("babysit: failed to save auto-babysit toggle", "user", ctx.Sender, "err", err)
|
||||
return p.SendDM(ctx.Sender, "Something went wrong. Try again.")
|
||||
}
|
||||
|
||||
daily := babysitDailyCost(char.CombatLevel)
|
||||
if char.AutoBabysit {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("🍼 **Auto-babysit: ON**\n\nIf you miss a day, the babysitter steps in automatically (€%d/day). Your streak stays alive. Disable anytime with `!adventure babysit auto`.", daily))
|
||||
}
|
||||
return p.SendDM(ctx.Sender, "🍼 **Auto-babysit: OFF**\n\nYou're on your own. Miss a day and your streak takes the hit.")
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
@@ -173,24 +127,19 @@ func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) er
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("🍼 The babysitting service costs %s for %d days. You have %s. The service has standards. Not many, but some.", fmtEuro(totalCost), days, fmtEuro(balance)))
|
||||
}
|
||||
|
||||
// Debit gold
|
||||
if !p.euro.Debit(char.UserID, float64(totalCost), "babysit_purchase") {
|
||||
return p.SendDM(ctx.Sender, "Payment failed. The babysitter looked at your wallet and walked away.")
|
||||
}
|
||||
|
||||
// Clear any leftover logs from prior service so this period's summary is clean
|
||||
clearBabysitLogs(char.UserID)
|
||||
|
||||
// Set babysit fields
|
||||
skill := babysitWeakestSkill(char)
|
||||
expires := time.Now().UTC().Add(time.Duration(days) * 24 * time.Hour)
|
||||
char.BabysitActive = true
|
||||
char.BabysitExpiresAt = &expires
|
||||
char.BabysitSkillFocus = skill
|
||||
char.BabysitSkillFocus = "" // legacy field; no longer used
|
||||
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("babysit: failed to save character", "user", char.UserID, "err", err)
|
||||
// Refund
|
||||
p.euro.Credit(char.UserID, float64(totalCost), "babysit_refund")
|
||||
return p.SendDM(ctx.Sender, "Something went wrong activating the service. Your gold has been refunded.")
|
||||
}
|
||||
@@ -201,13 +150,18 @@ func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) er
|
||||
durLabel = "1 month"
|
||||
}
|
||||
|
||||
petLine := "No pet to tend yet — the babysitter will keep that in mind."
|
||||
if char.HasPet() {
|
||||
petLine = fmt.Sprintf("Pet: %s (L%d) — daily care included", char.PetName, char.PetLevel)
|
||||
}
|
||||
|
||||
text := fmt.Sprintf("🍼 **Adventurer Babysitting Service — Activated**\n\n"+
|
||||
"Duration: %s (%d days)\n"+
|
||||
"Cost: €%d\n"+
|
||||
"Focus: %s (currently level %d)\n"+
|
||||
"%s\n"+
|
||||
"Camp safety: standard camps now rest like fortified ones\n"+
|
||||
"Rival duels: declined on your behalf\n\n"+
|
||||
"Daily DMs are suspended until the service ends.\n\n"+
|
||||
"_%s_", durLabel, days, totalCost, titleCase(skill), babysitSkillLevel(char, skill), confirm)
|
||||
"_%s_", durLabel, days, totalCost, petLine, confirm)
|
||||
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
}
|
||||
@@ -218,13 +172,8 @@ func (p *AdventurePlugin) handleBabysitStatus(ctx MessageContext) error {
|
||||
return p.SendDM(ctx.Sender, "No adventurer found.")
|
||||
}
|
||||
|
||||
autoLabel := "OFF"
|
||||
if char.AutoBabysit {
|
||||
autoLabel = "ON"
|
||||
}
|
||||
|
||||
if !char.BabysitActive {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("🍼 No active babysitting service.\nAuto-babysit: **%s** (`!adventure babysit auto` to toggle)\n\nUse `!adventure babysit week` or `!adventure babysit month` to start.", autoLabel))
|
||||
return p.SendDM(ctx.Sender, "🍼 No active babysitting service.\n\nUse `!adventure babysit week` or `!adventure babysit month` to start. The babysitter tends your pet daily and lets you rest deeply at standard camps.")
|
||||
}
|
||||
|
||||
remaining := "unknown"
|
||||
@@ -237,23 +186,24 @@ func (p *AdventurePlugin) handleBabysitStatus(ctx MessageContext) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Load log stats
|
||||
logs, err := loadBabysitLogs(char.UserID)
|
||||
if err != nil {
|
||||
slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err)
|
||||
}
|
||||
totalGold, totalXP, itemsClaimed, rivalsRefused := babysitLogStats(logs)
|
||||
totalXP, petDays, rivalsRefused := babysitLogStats(logs)
|
||||
|
||||
petLine := "No pet to tend"
|
||||
if char.HasPet() {
|
||||
petLine = fmt.Sprintf("%s (L%d)", char.PetName, char.PetLevel)
|
||||
}
|
||||
|
||||
text := fmt.Sprintf("🍼 **Babysitting Service — Status**\n\n"+
|
||||
"Time remaining: %s\n"+
|
||||
"Skill focus: %s\n"+
|
||||
"Days completed: %d\n"+
|
||||
"Gold earned: €%d\n"+
|
||||
"XP gained: %d\n"+
|
||||
"Items claimed by babysitter: %d\n"+
|
||||
"Rivals declined: %d\n"+
|
||||
"Auto-babysit: %s",
|
||||
remaining, titleCase(char.BabysitSkillFocus), len(logs), totalGold, totalXP, itemsClaimed, rivalsRefused, autoLabel)
|
||||
"Pet under care: %s\n"+
|
||||
"Days of pet care given: %d\n"+
|
||||
"Pet XP trickled: %d\n"+
|
||||
"Rivals declined: %d",
|
||||
remaining, petLine, petDays, totalXP, rivalsRefused)
|
||||
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
}
|
||||
@@ -272,14 +222,12 @@ func (p *AdventurePlugin) handleBabysitCancel(ctx MessageContext) error {
|
||||
return p.SendDM(ctx.Sender, "🍼 There's nothing to cancel. The babysitter isn't here.")
|
||||
}
|
||||
|
||||
// Compile partial summary
|
||||
logs, err := loadBabysitLogs(char.UserID)
|
||||
if err != nil {
|
||||
slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err)
|
||||
}
|
||||
summary := renderBabysitSummary(char, logs)
|
||||
|
||||
// Clear babysit state
|
||||
char.BabysitActive = false
|
||||
char.BabysitExpiresAt = nil
|
||||
char.BabysitSkillFocus = ""
|
||||
@@ -290,177 +238,6 @@ func (p *AdventurePlugin) handleBabysitCancel(ctx MessageContext) error {
|
||||
return p.SendDM(ctx.Sender, "🍼 Service cancelled. No refund. The babysitter was already there.\n\n"+summary)
|
||||
}
|
||||
|
||||
// ── Daily Auto-Resolution ───────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) runBabysitDaily(char *AdventureCharacter) {
|
||||
equip, err := loadAdvEquipment(char.UserID)
|
||||
if err != nil {
|
||||
slog.Error("babysit: failed to load equipment", "user", char.UserID, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
isHol, _ := isHolidayToday()
|
||||
harvestMax := maxHarvestActions
|
||||
if isHol {
|
||||
harvestMax++
|
||||
}
|
||||
|
||||
bonuses := &AdvBonusSummary{}
|
||||
focusActivity := skillToActivity(char.BabysitSkillFocus)
|
||||
|
||||
var totalGold int
|
||||
var totalXP int
|
||||
var allItems []string
|
||||
|
||||
// Use all harvest actions on the focused skill — no combat, too dangerous
|
||||
for char.HarvestActionsUsed < harvestMax {
|
||||
gold, xp, items := p.runBabysitAction(char, equip, focusActivity, bonuses)
|
||||
totalGold += gold
|
||||
totalXP += xp
|
||||
allItems = append(allItems, items...)
|
||||
char.HarvestActionsUsed++
|
||||
}
|
||||
|
||||
char.ActionTakenToday = true
|
||||
char.LastActionDate = time.Now().UTC().Format("2006-01-02")
|
||||
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("babysit: failed to save character after daily", "user", char.UserID, "err", err)
|
||||
}
|
||||
|
||||
// Log combined daily totals
|
||||
itemsJSON := ""
|
||||
if len(allItems) > 0 {
|
||||
itemsJSON = strings.Join(allItems, ", ")
|
||||
}
|
||||
logBabysitActivity(char.UserID, string(focusActivity), "babysit_daily",
|
||||
totalGold, totalXP, itemsJSON)
|
||||
}
|
||||
|
||||
// runBabysitAction resolves a single action for the babysitter. Returns gold, xp, item names.
|
||||
func (p *AdventurePlugin) runBabysitAction(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, activity AdvActivityType, bonuses *AdvBonusSummary) (int, int, []string) {
|
||||
eligible := advEligibleLocations(char, equip, activity, bonuses)
|
||||
if len(eligible) == 0 {
|
||||
return 0, 0, nil
|
||||
}
|
||||
loc := eligible[len(eligible)-1].Location
|
||||
inPenalty := eligible[len(eligible)-1].InPenaltyZone
|
||||
|
||||
result := resolveAdvAction(char, equip, loc, bonuses, inPenalty)
|
||||
|
||||
// Babysitter never lets the adventurer die
|
||||
if result.Outcome == AdvOutcomeDeath {
|
||||
result.Outcome = AdvOutcomeEmpty
|
||||
result.LootItems = nil
|
||||
result.TotalLootValue = 0
|
||||
result.EquipDamage = nil
|
||||
result.EquipBroken = nil
|
||||
}
|
||||
|
||||
// Double XP/money boost
|
||||
advApplyBoost(result)
|
||||
|
||||
// Apply XP
|
||||
switch result.XPSkill {
|
||||
case "combat":
|
||||
char.CombatXP += result.XPGained
|
||||
case "mining":
|
||||
char.MiningXP += result.XPGained
|
||||
case "foraging":
|
||||
char.ForagingXP += result.XPGained
|
||||
case "fishing":
|
||||
char.FishingXP += result.XPGained
|
||||
}
|
||||
checkAdvLevelUp(char, result.XPSkill)
|
||||
|
||||
// Credit gold
|
||||
if result.TotalLootValue > 0 {
|
||||
net, _ := communityTax(char.UserID, float64(result.TotalLootValue), 0.05)
|
||||
p.euro.Credit(char.UserID, net, "babysit_haul")
|
||||
}
|
||||
|
||||
// No treasure drops during babysitting
|
||||
result.TreasureFound = nil
|
||||
|
||||
var items []string
|
||||
for _, item := range result.LootItems {
|
||||
items = append(items, item.Name)
|
||||
}
|
||||
return int(result.TotalLootValue), result.XPGained, items
|
||||
}
|
||||
|
||||
// AutoBabysitDayResult summarizes one day of auto-babysit work, surfaced in
|
||||
// the morning DM so the babysitter feels like a companion that did
|
||||
// something rather than a silent insurance product.
|
||||
type AutoBabysitDayResult struct {
|
||||
Skill string
|
||||
Gold int
|
||||
XP int
|
||||
Items []string
|
||||
Highlight bool // true if the day produced an unusually large single haul or multiple item finds
|
||||
}
|
||||
|
||||
// runAutoBabysitDay runs a single day of babysit actions for auto-babysit.
|
||||
// Called by the scheduler when a player with auto-babysit enabled misses a day.
|
||||
func (p *AdventurePlugin) runAutoBabysitDay(char *AdventureCharacter) AutoBabysitDayResult {
|
||||
equip, err := loadAdvEquipment(char.UserID)
|
||||
if err != nil {
|
||||
slog.Error("auto-babysit: failed to load equipment", "user", char.UserID, "err", err)
|
||||
return AutoBabysitDayResult{}
|
||||
}
|
||||
|
||||
isHol, _ := isHolidayToday()
|
||||
harvestMax := maxHarvestActions
|
||||
if isHol {
|
||||
harvestMax++
|
||||
}
|
||||
|
||||
bonuses := &AdvBonusSummary{}
|
||||
// Honor a player-set focus if it's a valid harvest skill; otherwise
|
||||
// fall back to the weakest-skill default.
|
||||
skill := char.AutoBabysitFocus
|
||||
switch skill {
|
||||
case "mining", "fishing", "foraging":
|
||||
// player preference
|
||||
default:
|
||||
skill = babysitWeakestSkill(char)
|
||||
}
|
||||
activity := skillToActivity(skill)
|
||||
|
||||
var totalGold int
|
||||
var totalXP int
|
||||
var allItems []string
|
||||
bestSingleHaul := 0
|
||||
for char.HarvestActionsUsed < harvestMax {
|
||||
gold, xp, items := p.runBabysitAction(char, equip, activity, bonuses)
|
||||
totalGold += gold
|
||||
totalXP += xp
|
||||
allItems = append(allItems, items...)
|
||||
if gold > bestSingleHaul {
|
||||
bestSingleHaul = gold
|
||||
}
|
||||
char.HarvestActionsUsed++
|
||||
}
|
||||
|
||||
char.ActionTakenToday = true
|
||||
char.LastActionDate = time.Now().UTC().Format("2006-01-02")
|
||||
|
||||
itemsJSON := ""
|
||||
if len(allItems) > 0 {
|
||||
itemsJSON = strings.Join(allItems, ", ")
|
||||
}
|
||||
logBabysitActivity(char.UserID, string(activity), "auto_babysit_daily",
|
||||
totalGold, totalXP, itemsJSON)
|
||||
|
||||
return AutoBabysitDayResult{
|
||||
Skill: skill,
|
||||
Gold: totalGold,
|
||||
XP: totalXP,
|
||||
Items: allItems,
|
||||
Highlight: bestSingleHaul >= 200 || len(allItems) >= 2,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Expiry Check ────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) checkBabysitExpiry(chars []AdventureCharacter) {
|
||||
@@ -473,7 +250,6 @@ func (p *AdventurePlugin) checkBabysitExpiry(chars []AdventureCharacter) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Service expired — compile summary and send DM
|
||||
logs, err := loadBabysitLogs(char.UserID)
|
||||
if err != nil {
|
||||
slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err)
|
||||
@@ -497,20 +273,20 @@ func (p *AdventurePlugin) checkBabysitExpiry(chars []AdventureCharacter) {
|
||||
// ── Summary Rendering ───────────────────────────────────────────────────────
|
||||
|
||||
func renderBabysitSummary(char *AdventureCharacter, logs []babysitLogEntry) string {
|
||||
totalGold, totalXP, itemsClaimed, rivalsRefused := babysitLogStats(logs)
|
||||
totalXP, petDays, rivalsRefused := babysitLogStats(logs)
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("🍼 **BABYSITTING SERVICE — END OF REPORT**\n\n")
|
||||
sb.WriteString(fmt.Sprintf("Duration: %d days\n", len(logs)))
|
||||
sb.WriteString(fmt.Sprintf("Tasks completed: %d\n", len(logs)))
|
||||
sb.WriteString(fmt.Sprintf("Skill focused: %s\n", titleCase(char.BabysitSkillFocus)))
|
||||
sb.WriteString(fmt.Sprintf("Gold earned from hauls: €%d\n", totalGold))
|
||||
sb.WriteString(fmt.Sprintf("XP gained: %d\n", totalXP))
|
||||
sb.WriteString(fmt.Sprintf("Items dropped: %d items. Claimed by the babysitter as per the terms.\n", itemsClaimed))
|
||||
sb.WriteString(fmt.Sprintf("Days of service: %d\n", petDays))
|
||||
if char.HasPet() {
|
||||
sb.WriteString(fmt.Sprintf("Pet looked after: %s (L%d)\n", char.PetName, char.PetLevel))
|
||||
} else {
|
||||
sb.WriteString("Pet looked after: none — the babysitter played solitaire.\n")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("Pet XP trickled: %d\n", totalXP))
|
||||
|
||||
if rivalsRefused > 0 {
|
||||
sb.WriteString(fmt.Sprintf("\nRival challenges: %d declined\n", rivalsRefused))
|
||||
// Pick a rival refusal flavor (generic — no specific rival name available)
|
||||
for _, log := range logs {
|
||||
if log.RivalRefused != "" {
|
||||
line := pickBabysitFlavor(babysitRivalRefusalLines)
|
||||
@@ -519,11 +295,8 @@ func renderBabysitSummary(char *AdventureCharacter, logs []babysitLogEntry) stri
|
||||
}
|
||||
}
|
||||
|
||||
// Diaper line
|
||||
sb.WriteString("\n" + pickBabysitFlavor(babysitDiaperLines))
|
||||
|
||||
// Closing
|
||||
sb.WriteString(fmt.Sprintf("\n\nYour adventurer is fed, rested, and slightly better at %s.", char.BabysitSkillFocus))
|
||||
sb.WriteString("\n\nYour adventurer is fed and rested. The pet is suspiciously well-trained.")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
@@ -591,27 +364,13 @@ func clearBabysitLogs(userID id.UserID) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
// ── Stats helper ────────────────────────────────────────────────────────────
|
||||
|
||||
func babysitSkillLevel(char *AdventureCharacter, skill string) int {
|
||||
switch skill {
|
||||
case "mining":
|
||||
return char.MiningSkill
|
||||
case "fishing":
|
||||
return char.FishingSkill
|
||||
case "foraging":
|
||||
return char.ForagingSkill
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func babysitLogStats(logs []babysitLogEntry) (totalGold, totalXP, itemsClaimed, rivalsRefused int) {
|
||||
func babysitLogStats(logs []babysitLogEntry) (totalXP, petDays, rivalsRefused int) {
|
||||
for _, l := range logs {
|
||||
totalGold += l.GoldEarned
|
||||
totalXP += l.XPGained
|
||||
if l.ItemsDropped != "" {
|
||||
// Count comma-separated items
|
||||
itemsClaimed += len(strings.Split(l.ItemsDropped, ", "))
|
||||
if l.Activity == "pet_care" {
|
||||
petDays++
|
||||
}
|
||||
if l.RivalRefused != "" {
|
||||
rivalsRefused++
|
||||
|
||||
108
internal/plugin/adventure_babysit_test.go
Normal file
108
internal/plugin/adventure_babysit_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// L1: babysit pivot from harvest to pet-care + safe-rest.
|
||||
// These tests cover the pure pieces — DB-touching paths exercise via
|
||||
// integration only.
|
||||
|
||||
func TestBabysitSafeRest_NoDB_ReturnsFalse(t *testing.T) {
|
||||
// Tests run without db.Init(); BabysitSafeRest must recover and
|
||||
// return false rather than panicking.
|
||||
if BabysitSafeRest(id.UserID("@nodb:example")) {
|
||||
t.Errorf("expected false for un-initialized DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBabysitDailyTrickle_NoPetGrantsNothing(t *testing.T) {
|
||||
// Without a pet, the trickle is a logging-only no-op for character
|
||||
// state. Without DB, the log call panics inside db.Get; recover and
|
||||
// assert that the in-memory char fields stay clean.
|
||||
defer func() { _ = recover() }()
|
||||
|
||||
expires := time.Now().UTC().Add(7 * 24 * time.Hour)
|
||||
char := &AdventureCharacter{
|
||||
UserID: id.UserID("@nopet:example"),
|
||||
BabysitActive: true,
|
||||
BabysitExpiresAt: &expires,
|
||||
}
|
||||
p := &AdventurePlugin{}
|
||||
p.runBabysitDailyTrickle(char)
|
||||
if char.PetXP != 0 {
|
||||
t.Errorf("char without pet should not gain PetXP, got %d", char.PetXP)
|
||||
}
|
||||
if char.PetLevel != 0 {
|
||||
t.Errorf("char without pet should not change PetLevel, got %d", char.PetLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBabysitDailyTrickle_SkipsWhenInactive(t *testing.T) {
|
||||
defer func() { _ = recover() }()
|
||||
char := &AdventureCharacter{
|
||||
UserID: id.UserID("@inactive:example"),
|
||||
BabysitActive: false,
|
||||
PetType: "dog",
|
||||
PetName: "Rex",
|
||||
PetLevel: 1,
|
||||
PetXP: 0,
|
||||
}
|
||||
p := &AdventurePlugin{}
|
||||
p.runBabysitDailyTrickle(char)
|
||||
if char.PetXP != 0 {
|
||||
t.Errorf("inactive babysit should grant no XP, got %d", char.PetXP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBabysitDailyTrickle_AccumulatesAndLevels(t *testing.T) {
|
||||
// Pet at L9 with 49 XP needed → 1 day at +3 trickle should *not* level
|
||||
// (49→52 in centi-XP / 100 = no, wait: petXPToNextLevel at L9 = 50 ×
|
||||
// 100 = 5000 centi-XP). Verify we accumulate centi-XP correctly across
|
||||
// multiple daily ticks and eventually level up.
|
||||
defer func() { _ = recover() }()
|
||||
|
||||
char := &AdventureCharacter{
|
||||
UserID: id.UserID("@accum:example"),
|
||||
PetType: "dog",
|
||||
PetName: "Rex",
|
||||
PetLevel: 1, // needs 10 XP = 1000 centi-XP to L2
|
||||
PetXP: 900,
|
||||
BabysitActive: true,
|
||||
}
|
||||
expires := time.Now().UTC().Add(48 * time.Hour)
|
||||
char.BabysitExpiresAt = &expires
|
||||
p := &AdventurePlugin{}
|
||||
|
||||
// One trickle adds 3*100 = 300 centi-XP. 900 + 300 = 1200 >= 1000 →
|
||||
// level to 2, carry 200 centi-XP.
|
||||
p.runBabysitDailyTrickle(char)
|
||||
if char.PetLevel != 2 {
|
||||
t.Errorf("expected level 2 after trickle, got %d (xp=%d)", char.PetLevel, char.PetXP)
|
||||
}
|
||||
if char.PetXP != 200 {
|
||||
t.Errorf("expected 200 carryover centi-XP, got %d", char.PetXP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBabysitLogStats_CountsPetCareDays(t *testing.T) {
|
||||
logs := []babysitLogEntry{
|
||||
{Activity: "pet_care", XPGained: 3},
|
||||
{Activity: "pet_care", XPGained: 3},
|
||||
{Activity: "pet_care", XPGained: 3, RivalRefused: ""},
|
||||
{Activity: "rival_refused", RivalRefused: "Garth"},
|
||||
}
|
||||
xp, days, rivals := babysitLogStats(logs)
|
||||
if days != 3 {
|
||||
t.Errorf("petDays = %d, want 3", days)
|
||||
}
|
||||
if xp != 9 {
|
||||
t.Errorf("totalXP = %d, want 9", xp)
|
||||
}
|
||||
if rivals != 1 {
|
||||
t.Errorf("rivalsRefused = %d, want 1", rivals)
|
||||
}
|
||||
}
|
||||
@@ -71,89 +71,6 @@ func TestAdvSlotRelevantToActivity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAdvAction_MasteryOnlyCountsRelevantSlots(t *testing.T) {
|
||||
// A foraging action should bump tool but leave weapon/armor/helmet/boots
|
||||
// untouched. The reverse for combat.
|
||||
char := &AdventureCharacter{
|
||||
CombatLevel: 5, ForagingSkill: 5, MiningSkill: 5, FishingSkill: 5,
|
||||
}
|
||||
bonuses := &AdvBonusSummary{}
|
||||
|
||||
t.Run("foraging only bumps tool", func(t *testing.T) {
|
||||
loc := &AdvLocation{Name: "T", Tier: 1, Activity: AdvActivityForaging,
|
||||
MinLevel: 1, BaseDeathPct: 1, EmptyPct: 50}
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Tier: 1, Condition: 100, ActionsUsed: 10},
|
||||
SlotArmor: {Tier: 1, Condition: 100, ActionsUsed: 10},
|
||||
SlotHelmet: {Tier: 1, Condition: 100, ActionsUsed: 10},
|
||||
SlotBoots: {Tier: 1, Condition: 100, ActionsUsed: 10},
|
||||
SlotTool: {Tier: 1, Condition: 100, ActionsUsed: 10},
|
||||
}
|
||||
resolveAdvAction(char, equip, loc, bonuses, false)
|
||||
if equip[SlotTool].ActionsUsed != 11 {
|
||||
t.Errorf("tool should have advanced 10→11, got %d", equip[SlotTool].ActionsUsed)
|
||||
}
|
||||
for _, slot := range []EquipmentSlot{SlotWeapon, SlotArmor, SlotHelmet, SlotBoots} {
|
||||
if equip[slot].ActionsUsed != 10 {
|
||||
t.Errorf("%s should be unchanged at 10, got %d", slot, equip[slot].ActionsUsed)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("combat only bumps combat slots", func(t *testing.T) {
|
||||
loc := &AdvLocation{Name: "T", Tier: 1, Activity: AdvActivityDungeon,
|
||||
MinLevel: 1, BaseDeathPct: 1, EmptyPct: 50}
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Tier: 1, Condition: 100, ActionsUsed: 10},
|
||||
SlotArmor: {Tier: 1, Condition: 100, ActionsUsed: 10},
|
||||
SlotHelmet: {Tier: 1, Condition: 100, ActionsUsed: 10},
|
||||
SlotBoots: {Tier: 1, Condition: 100, ActionsUsed: 10},
|
||||
SlotTool: {Tier: 1, Condition: 100, ActionsUsed: 10},
|
||||
}
|
||||
resolveAdvAction(char, equip, loc, bonuses, false)
|
||||
for _, slot := range []EquipmentSlot{SlotWeapon, SlotArmor, SlotHelmet, SlotBoots} {
|
||||
if equip[slot].ActionsUsed != 11 {
|
||||
t.Errorf("%s should have advanced 10→11, got %d", slot, equip[slot].ActionsUsed)
|
||||
}
|
||||
}
|
||||
if equip[SlotTool].ActionsUsed != 10 {
|
||||
t.Errorf("tool should be unchanged at 10, got %d", equip[SlotTool].ActionsUsed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestResolveAdvAction_MasteryCrossingFiresOnceAtBoundary(t *testing.T) {
|
||||
// Action 49→50 should report a single crossing at threshold 50.
|
||||
// Action 50→51 should report none (idempotent past the boundary).
|
||||
loc := &AdvLocation{
|
||||
Name: "Test", Tier: 1, Activity: AdvActivityForaging,
|
||||
MinLevel: 1, MinEquipTier: 0, BaseDeathPct: 1, EmptyPct: 50,
|
||||
}
|
||||
char := &AdventureCharacter{
|
||||
CombatLevel: 5, ForagingSkill: 5, MiningSkill: 5, FishingSkill: 5,
|
||||
}
|
||||
bonuses := &AdvBonusSummary{}
|
||||
|
||||
mkEquip := func(used int) map[EquipmentSlot]*AdvEquipment {
|
||||
return map[EquipmentSlot]*AdvEquipment{
|
||||
SlotTool: {Tier: 1, Condition: 100, ActionsUsed: used},
|
||||
}
|
||||
}
|
||||
|
||||
res1 := resolveAdvAction(char, mkEquip(49), loc, bonuses, false)
|
||||
if got := len(res1.MasteryCrossings); got != 1 {
|
||||
t.Fatalf("49→50: expected 1 crossing, got %d", got)
|
||||
}
|
||||
if res1.MasteryCrossings[0].Threshold != 50 {
|
||||
t.Errorf("expected threshold 50, got %d", res1.MasteryCrossings[0].Threshold)
|
||||
}
|
||||
|
||||
res2 := resolveAdvAction(char, mkEquip(50), loc, bonuses, false)
|
||||
if got := len(res2.MasteryCrossings); got != 0 {
|
||||
t.Errorf("50→51: expected 0 crossings, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvMasteryRowSegment(t *testing.T) {
|
||||
cases := []struct {
|
||||
used int
|
||||
|
||||
@@ -208,28 +208,6 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]*
|
||||
sb.WriteString(" — `!adventure rivals` for details\n")
|
||||
}
|
||||
|
||||
// Today's actions
|
||||
isHolSheet, _ := isHolidayToday()
|
||||
combatMax := maxCombatActions
|
||||
harvestMax := maxHarvestActions
|
||||
if isHolSheet {
|
||||
combatMax++
|
||||
harvestMax++
|
||||
}
|
||||
combatRemaining := combatMax - char.CombatActionsUsed
|
||||
if combatRemaining < 0 {
|
||||
combatRemaining = 0
|
||||
}
|
||||
harvestRemaining := harvestMax - char.HarvestActionsUsed
|
||||
if harvestRemaining < 0 {
|
||||
harvestRemaining = 0
|
||||
}
|
||||
if char.HasActedToday() {
|
||||
sb.WriteString(fmt.Sprintf("\n📅 Today: %d combat, %d harvest actions remaining", combatRemaining, harvestRemaining))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("\n📅 Today: %d combat, %d harvest actions available", combatRemaining, harvestRemaining))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
@@ -356,9 +334,11 @@ func renderRivalNudge(char *AdventureCharacter) string {
|
||||
func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, balance float64, bonuses *AdvBonusSummary, holidayName string) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Holiday notice (before greeting)
|
||||
// Holiday notice (before greeting). Today's perks: TwinBee starts new
|
||||
// runs in a slightly better mood (+5), expedition outfitting includes a
|
||||
// complimentary standard pack, and every harvest yields one extra unit.
|
||||
if holidayName != "" {
|
||||
sb.WriteString(fmt.Sprintf("🎉 Happy %s! In recognition of %s, you get an extra combat and harvest action today.\n\n", holidayName, holidayName))
|
||||
sb.WriteString(fmt.Sprintf("🎉 Happy %s! In recognition of %s, today's adventures come with TwinBee's blessing — new zone & expedition runs start at +5 mood, expedition outfitting includes a complimentary standard pack, and every harvest yields one extra unit.\n\n", holidayName, holidayName))
|
||||
}
|
||||
|
||||
// Pick a morning greeting
|
||||
@@ -399,30 +379,17 @@ func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEq
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// Action budget
|
||||
isHol := holidayName != ""
|
||||
combatMax := maxCombatActions
|
||||
harvestMax := maxHarvestActions
|
||||
if isHol {
|
||||
combatMax++
|
||||
harvestMax++
|
||||
}
|
||||
combatLeft := combatMax - char.CombatActionsUsed
|
||||
if combatLeft < 0 {
|
||||
combatLeft = 0
|
||||
}
|
||||
harvestLeft := harvestMax - char.HarvestActionsUsed
|
||||
|
||||
// Co-op participants have combat locked for the duration of the run.
|
||||
// Under Adv 2.0 the per-day combat/harvest action caps are no longer
|
||||
// surfaced — harvesting is gated by per-room nodes, supplies, and the
|
||||
// threat clock; combat is gated by zones/expeditions/arena themselves.
|
||||
coopRun, _ := loadCoopRunForUser(char.UserID)
|
||||
inCoop := coopRun != nil && coopRun.Status == "active"
|
||||
|
||||
if inCoop {
|
||||
sb.WriteString(fmt.Sprintf("📋 **Actions:** combat locked (in Co-op #%d, day %d/%d) · %d/%d harvest\n\n",
|
||||
coopRun.ID, coopRun.CurrentDay, coopRun.TotalDays, harvestLeft, harvestMax))
|
||||
sb.WriteString(fmt.Sprintf("📋 **Co-op #%d** — day %d/%d, combat locked for the duration of the run\n\n",
|
||||
coopRun.ID, coopRun.CurrentDay, coopRun.TotalDays))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("📋 **Actions:** %d/%d combat · %d/%d harvest\n\n", combatLeft, combatMax, harvestLeft, harvestMax))
|
||||
|
||||
// Co-op teaser — show open runs the player could join.
|
||||
if line := renderCoopTeaser(char); line != "" {
|
||||
sb.WriteString(line)
|
||||
@@ -758,31 +725,6 @@ func masteryBar(value, total int) string {
|
||||
return strings.Repeat("▰", filled) + strings.Repeat("▱", 10-filled)
|
||||
}
|
||||
|
||||
// ── Auto-Babysit DM ─────────────────────────────────────────────────────────
|
||||
|
||||
// renderAutoBabysitDM builds the morning notification when auto-babysit
|
||||
// covered an idle day. Surfaces what the babysitter actually accomplished
|
||||
// (skill focus, gold/XP, items) plus a flavor highlight on lucky days, so
|
||||
// the system feels like an active companion instead of a silent debit.
|
||||
func renderAutoBabysitDM(daily int, streak int, res AutoBabysitDayResult) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("🍼 **Auto-babysit activated** — €%d deducted. Your streak is safe at %d days.\n", daily, streak))
|
||||
if res.Skill != "" {
|
||||
sb.WriteString(fmt.Sprintf("Focus: %s · €%d earned · %d XP", res.Skill, res.Gold, res.XP))
|
||||
if len(res.Items) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" · items: %s", strings.Join(res.Items, ", ")))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
if res.Highlight && res.Skill != "" {
|
||||
line := pickBabysitFlavor(babysitHighlightLines)
|
||||
if line != "" {
|
||||
sb.WriteString("\n_" + fmt.Sprintf(line, res.Skill) + "_")
|
||||
}
|
||||
}
|
||||
return strings.TrimRight(sb.String(), "\n")
|
||||
}
|
||||
|
||||
// ── Idle Shame DM ────────────────────────────────────────────────────────────
|
||||
|
||||
func renderAdvIdleShameDM(char *AdventureCharacter) string {
|
||||
@@ -828,7 +770,7 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
|
||||
sb.WriteString(fmt.Sprintf("📜 **ADVENTURER DAILY REPORT**\n%s\n\n", date))
|
||||
|
||||
if holidayName != "" {
|
||||
sb.WriteString(fmt.Sprintf("🎉 In recognition of **%s**, adventurers had two actions today.\n\n", holidayName))
|
||||
sb.WriteString(fmt.Sprintf("🎉 In recognition of **%s**, adventurers ran with TwinBee's blessing today: +5 starting mood, a free standard pack at outfitting, and +1 to every harvest yield.\n\n", holidayName))
|
||||
}
|
||||
|
||||
// TwinBee section
|
||||
@@ -963,47 +905,6 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Holiday stats
|
||||
if holidayName != "" {
|
||||
tookBoth := 0
|
||||
totalActive := 0
|
||||
for _, p := range players {
|
||||
if p.IsDead || p.IsResting {
|
||||
if p.HolidayActions > 0 {
|
||||
totalActive++
|
||||
}
|
||||
if p.HolidayActions >= 2 {
|
||||
tookBoth++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if p.Activity != "" {
|
||||
totalActive++
|
||||
}
|
||||
if p.HolidayActions >= 2 {
|
||||
tookBoth++
|
||||
}
|
||||
}
|
||||
if totalActive > 0 {
|
||||
sb.WriteString(fmt.Sprintf("🎉 %s double-action day — %d of %d adventurers took both actions.\n\n", holidayName, tookBoth, totalActive))
|
||||
}
|
||||
|
||||
// Note players who died before their second action
|
||||
for _, d := range dead {
|
||||
if d.HolidayActions == 1 {
|
||||
sb.WriteString(fmt.Sprintf("• %s — died in %s before their second action. Rough holiday.\n", d.DisplayName, d.Location))
|
||||
}
|
||||
}
|
||||
if len(dead) > 0 {
|
||||
// Check if any had HolidayActions == 1
|
||||
for _, d := range dead {
|
||||
if d.HolidayActions == 1 {
|
||||
sb.WriteString("\n")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Standout
|
||||
if bestPlayer != nil && bestPlayer.LootValue > 0 {
|
||||
|
||||
@@ -84,13 +84,17 @@ func (p *AdventurePlugin) sendMorningDMs() {
|
||||
}
|
||||
}
|
||||
|
||||
// Babysitting: auto-resolve daily action, skip DM
|
||||
// Babysitting: pet-care trickle (no harvest actions; safe-rest perk
|
||||
// is consumed inside the expedition camp/rest path). Still skips the
|
||||
// morning DM — the babysitter is handling things in the background.
|
||||
if char.BabysitActive {
|
||||
if !char.Alive {
|
||||
// Dead and not yet ready to respawn — skip babysit action
|
||||
continue
|
||||
}
|
||||
p.runBabysitDaily(&char)
|
||||
p.runBabysitDailyTrickle(&char)
|
||||
if err := saveAdvCharacter(&char); err != nil {
|
||||
slog.Error("babysit: failed to save after daily trickle", "user", char.UserID, "err", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -384,40 +388,6 @@ func (p *AdventurePlugin) midnightReset() error {
|
||||
continue
|
||||
}
|
||||
|
||||
// Auto-babysit: if enabled, alive, and affordable, run a single babysit day instead of losing streak
|
||||
autoBabysitShortfall := int64(0)
|
||||
if char.AutoBabysit && char.Alive && !char.BabysitActive {
|
||||
daily := babysitDailyCost(char.CombatLevel)
|
||||
bal := p.euro.GetBalance(char.UserID)
|
||||
if bal >= float64(daily) {
|
||||
if p.euro.Debit(char.UserID, float64(daily), "auto_babysit") {
|
||||
res := p.runAutoBabysitDay(&char)
|
||||
if char.CurrentStreak > 0 {
|
||||
char.CurrentStreak++
|
||||
if char.CurrentStreak > char.BestStreak {
|
||||
char.BestStreak = char.CurrentStreak
|
||||
}
|
||||
} else {
|
||||
char.CurrentStreak = 1
|
||||
}
|
||||
_ = saveAdvCharacter(&char)
|
||||
|
||||
if p.achievements != nil {
|
||||
p.achievements.GrantAchievement(char.UserID, "adv_auto_babysit")
|
||||
}
|
||||
|
||||
if dmsSent > 0 {
|
||||
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond)
|
||||
}
|
||||
dmsSent++
|
||||
p.SendDM(char.UserID, renderAutoBabysitDM(daily, char.CurrentStreak, res))
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
autoBabysitShortfall = int64(float64(daily) - bal)
|
||||
}
|
||||
}
|
||||
|
||||
// Jitter between DMs to avoid Matrix rate limits
|
||||
if dmsSent > 0 {
|
||||
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond)
|
||||
@@ -426,9 +396,6 @@ func (p *AdventurePlugin) midnightReset() error {
|
||||
|
||||
// Idle shame DM
|
||||
text := renderAdvIdleShameDM(&char)
|
||||
if autoBabysitShortfall > 0 {
|
||||
text += fmt.Sprintf("\n\n💸 Auto-babysit was on but couldn't cover today (€%d short). Top up the wallet and TwinBee can step in next time.", autoBabysitShortfall)
|
||||
}
|
||||
if char.CurrentStreak > 0 {
|
||||
oldStreak := char.CurrentStreak
|
||||
char.CurrentStreak /= 2
|
||||
|
||||
@@ -4,30 +4,13 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// ── TwinBee Character (fixed stats) ──────────────────────────────────────────
|
||||
|
||||
var twinBeeChar = AdventureCharacter{
|
||||
DisplayName: "TwinBee 🐝",
|
||||
CombatLevel: 35,
|
||||
MiningSkill: 28,
|
||||
ForagingSkill: 22,
|
||||
Alive: true,
|
||||
}
|
||||
|
||||
var twinBeeEquip = map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Slot: SlotWeapon, Tier: 4, Condition: 100, Name: "The Spread Gun"},
|
||||
SlotArmor: {Slot: SlotArmor, Tier: 4, Condition: 100, Name: "Enchanted Plate"},
|
||||
SlotHelmet: {Slot: SlotHelmet, Tier: 4, Condition: 100, Name: "Guardian's Helm"},
|
||||
SlotBoots: {Slot: SlotBoots, Tier: 4, Condition: 100, Name: "Ranger's Boots"},
|
||||
SlotTool: {Slot: SlotTool, Tier: 4, Condition: 100, Name: "Mithril Pickaxe"},
|
||||
}
|
||||
|
||||
// ── TwinBee Action Selection ─────────────────────────────────────────────────
|
||||
|
||||
type twinBeeActionWeight struct {
|
||||
@@ -117,6 +100,65 @@ type TwinBeeResult struct {
|
||||
FlavorText string
|
||||
}
|
||||
|
||||
// simulateTwinBeeOutcome rolls a flat outcome distribution for TwinBee's
|
||||
// off-screen daily run. TwinBee never dies — death rolls become empty.
|
||||
// Distribution by tier roughly matches the legacy simulator's spread:
|
||||
// tier 3 → 60% success / 25% exceptional / 15% empty; tier 4 → 55/20/25;
|
||||
// tier 5 → 50/15/35 (deeper places are stingier even for TwinBee).
|
||||
func simulateTwinBeeOutcome(tier int) AdvOutcomeType {
|
||||
roll := rand.IntN(100)
|
||||
var emptyPct, successPct int
|
||||
switch tier {
|
||||
case 5:
|
||||
emptyPct, successPct = 35, 50
|
||||
case 4:
|
||||
emptyPct, successPct = 25, 55
|
||||
default:
|
||||
emptyPct, successPct = 15, 60
|
||||
}
|
||||
switch {
|
||||
case roll < emptyPct:
|
||||
return AdvOutcomeEmpty
|
||||
case roll < emptyPct+successPct:
|
||||
return AdvOutcomeSuccess
|
||||
default:
|
||||
return AdvOutcomeExceptional
|
||||
}
|
||||
}
|
||||
|
||||
// simulateTwinBeeLoot returns gold value + 1–3 themed item names for the
|
||||
// rolled outcome. Empty → zero/no items. Exceptional ≈ 2.2× success base.
|
||||
func simulateTwinBeeLoot(loc *AdvLocation, outcome AdvOutcomeType) (int64, []string) {
|
||||
if outcome == AdvOutcomeEmpty || loc == nil {
|
||||
return 0, nil
|
||||
}
|
||||
tier := loc.Tier
|
||||
if tier < 1 {
|
||||
tier = 1
|
||||
}
|
||||
// Base loot scales as tier^2 * 75 with ±25% jitter.
|
||||
base := int64(tier*tier*75) + int64(rand.IntN(tier*tier*40))
|
||||
if outcome == AdvOutcomeExceptional {
|
||||
base = base * 22 / 10
|
||||
}
|
||||
// Item names borrow from the location's denizens string for flavor; no
|
||||
// inventory is actually deposited for TwinBee — these are display only.
|
||||
names := strings.Split(loc.Denizens, ", ")
|
||||
picks := 1 + rand.IntN(2)
|
||||
if outcome == AdvOutcomeExceptional {
|
||||
picks = 2 + rand.IntN(2)
|
||||
}
|
||||
if picks > len(names) {
|
||||
picks = len(names)
|
||||
}
|
||||
chosen := make([]string, 0, picks)
|
||||
rand.Shuffle(len(names), func(i, j int) { names[i], names[j] = names[j], names[i] })
|
||||
for i := 0; i < picks && i < len(names); i++ {
|
||||
chosen = append(chosen, strings.TrimSpace(names[i]))
|
||||
}
|
||||
return base, chosen
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) runTwinBeeDaily() *TwinBeeResult {
|
||||
activity, loc := selectTwinBeeAction()
|
||||
if loc == nil {
|
||||
@@ -124,46 +166,19 @@ func (p *AdventurePlugin) runTwinBeeDaily() *TwinBeeResult {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Copy equipment so mutations don't accumulate on the global template.
|
||||
equip := make(map[EquipmentSlot]*AdvEquipment, len(twinBeeEquip))
|
||||
for k, v := range twinBeeEquip {
|
||||
copy := *v
|
||||
equip[k] = ©
|
||||
}
|
||||
outcome := simulateTwinBeeOutcome(loc.Tier)
|
||||
value, items := simulateTwinBeeLoot(loc, outcome)
|
||||
|
||||
bonuses := &AdvBonusSummary{} // TwinBee has no treasures/buffs
|
||||
result := resolveAdvAction(&twinBeeChar, equip, loc, bonuses, false)
|
||||
|
||||
// TwinBee never dies — reroll death to empty
|
||||
if result.Outcome == AdvOutcomeDeath {
|
||||
result.Outcome = AdvOutcomeEmpty
|
||||
result.LootItems = nil
|
||||
result.TotalLootValue = 0
|
||||
result.EquipDamage = nil
|
||||
result.EquipBroken = nil
|
||||
}
|
||||
|
||||
// No treasure drops for TwinBee
|
||||
result.TreasureFound = nil
|
||||
|
||||
// Select TwinBee-specific flavor text
|
||||
tbResult := &TwinBeeResult{
|
||||
Activity: activity,
|
||||
Location: loc,
|
||||
Outcome: result.Outcome,
|
||||
LootValue: result.TotalLootValue,
|
||||
Outcome: outcome,
|
||||
LootValue: value,
|
||||
}
|
||||
if len(items) > 0 {
|
||||
tbResult.LootDesc = joinAdvItems(items)
|
||||
}
|
||||
|
||||
// Build loot description
|
||||
if len(result.LootItems) > 0 {
|
||||
names := make([]string, len(result.LootItems))
|
||||
for i, item := range result.LootItems {
|
||||
names[i] = item.Name
|
||||
}
|
||||
tbResult.LootDesc = joinAdvItems(names)
|
||||
}
|
||||
|
||||
// Select flavor
|
||||
tbResult.FlavorText = p.selectTwinBeeFlavor(tbResult)
|
||||
|
||||
return tbResult
|
||||
|
||||
@@ -242,7 +242,16 @@ func processOvernightCamp(e *Expedition) string {
|
||||
_ = updateCamp(e.ID, nil)
|
||||
return ""
|
||||
}
|
||||
// Babysit safe-rest: an active subscription promotes a Standard camp
|
||||
// to Fortified for rest purposes (no need for boss-cleared arrangements).
|
||||
// Rough/Base are unchanged — Rough still implies no shelter, and Base
|
||||
// already exceeds Fortified.
|
||||
babysitUpgraded := false
|
||||
kind := e.Camp.Type
|
||||
if kind == CampTypeStandard && BabysitSafeRest(uid) {
|
||||
kind = CampTypeFortified
|
||||
babysitUpgraded = true
|
||||
}
|
||||
prevHP := c.HPCurrent
|
||||
bonusHP := 0
|
||||
|
||||
@@ -304,8 +313,12 @@ func processOvernightCamp(e *Expedition) string {
|
||||
case CampTypeStandard:
|
||||
return fmt.Sprintf("Long rest: HP %d → %d, spell slots & resources refreshed.", prevHP, c.HPCurrent)
|
||||
case CampTypeFortified, CampTypeBase:
|
||||
summary := fmt.Sprintf("Fortified rest: HP %d → %d (+1d6 = %d bonus); threat clock −5; resources refreshed.",
|
||||
prevHP, c.HPCurrent, bonusHP)
|
||||
label := "Fortified rest"
|
||||
if babysitUpgraded {
|
||||
label = "Fortified rest (babysitter watching the camp)"
|
||||
}
|
||||
summary := fmt.Sprintf("%s: HP %d → %d (+1d6 = %d bonus); threat clock −5; resources refreshed.",
|
||||
label, prevHP, c.HPCurrent, bonusHP)
|
||||
if heatReduced > 0 {
|
||||
summary += fmt.Sprintf(" Heat stacks −%d.", heatReduced)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"gogobee/internal/flavor"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Phase 12 E2b — Wandering monster system & night phase resolution.
|
||||
@@ -74,8 +76,14 @@ func resolveWanderingCheck(e *Expedition, charClass DnDClass, roll1d20 func() in
|
||||
threatMod = (e.ThreatLevel - 30) / 10
|
||||
}
|
||||
|
||||
// Babysit safe-rest: an active subscription gives Standard camps the
|
||||
// fortified-tier night-risk reduction (the babysitter is watching).
|
||||
effectiveCamp := e.Camp.Type
|
||||
if effectiveCamp == CampTypeStandard && BabysitSafeRest(id.UserID(e.UserID)) {
|
||||
effectiveCamp = CampTypeFortified
|
||||
}
|
||||
campMod := 0
|
||||
switch e.Camp.Type {
|
||||
switch effectiveCamp {
|
||||
case CampTypeRough:
|
||||
campMod = 3
|
||||
case CampTypeFortified:
|
||||
|
||||
Reference in New Issue
Block a user