Files
gogobee/internal/plugin/adventure_activities.go
prosolis 1953eec3b5 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>
2026-05-09 14:25:22 -07:00

841 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
import (
"fmt"
"math"
"math/rand/v2"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
const advLocationCooldownDuration = 3 * time.Hour
// ── Activity & Outcome Types ─────────────────────────────────────────────────
type AdvActivityType string
const (
AdvActivityDungeon AdvActivityType = "dungeon"
AdvActivityMining AdvActivityType = "mining"
AdvActivityForaging AdvActivityType = "foraging"
AdvActivityFishing AdvActivityType = "fishing"
AdvActivityRest AdvActivityType = "rest"
AdvActivityShop AdvActivityType = "shop"
)
type AdvOutcomeType string
const (
AdvOutcomeDeath AdvOutcomeType = "death"
AdvOutcomeEmpty AdvOutcomeType = "empty"
AdvOutcomeSuccess AdvOutcomeType = "success"
AdvOutcomeExceptional AdvOutcomeType = "exceptional"
AdvOutcomeCaveIn AdvOutcomeType = "cave_in"
AdvOutcomeHornets AdvOutcomeType = "hornets"
AdvOutcomeBear AdvOutcomeType = "bear"
AdvOutcomeRiver AdvOutcomeType = "river"
)
// ── Location Definitions ─────────────────────────────────────────────────────
type AdvLocation struct {
Name string
Activity AdvActivityType
Tier int
Denizens string // monsters/resources description
BaseDeathPct float64
EmptyPct float64
MinLevel int
MinEquipTier int
}
type AdvLootDef struct {
Name string
Type string
MinValue int64
MaxValue int64
}
var advDungeons = []AdvLocation{
{"The Soggy Cellar", AdvActivityDungeon, 1, "Giant Rats, Angry Badgers, Wet Slimes", 8, 15, 1, 0},
{"Goblin Warrens", AdvActivityDungeon, 2, "Goblins, Kobolds, Trap Spiders", 18, 15, 8, 1},
{"The Cursed Crypt", AdvActivityDungeon, 3, "Skeletons, Ghosts, Draugr", 30, 15, 20, 2},
{"Troll Bridge Depths", AdvActivityDungeon, 4, "Trolls, Stone Giants, Cursed Knights", 45, 15, 35, 3},
{"The Abyssal Maw", AdvActivityDungeon, 5, "Demons, Elder Drakes, The Unnamed", 60, 15, 48, 4},
}
var advMines = []AdvLocation{
{"Surface Pits", AdvActivityMining, 1, "Copper, Tin, Coal", 3, 20, 1, 0},
{"Iron Ridge", AdvActivityMining, 2, "Iron, Lead, Saltpetre", 8, 20, 8, 1},
{"Silver Seam", AdvActivityMining, 3, "Silver, Quartz, Nickel", 15, 20, 18, 2},
{"The Deeprock", AdvActivityMining, 4, "Gold, Sapphire, Titanium", 25, 20, 30, 3},
{"Mythril Caverns", AdvActivityMining, 5, "Mythril, Dragon Crystal, Voidstone", 35, 20, 44, 4},
}
var advForests = []AdvLocation{
{"The Meadow", AdvActivityForaging, 1, "Berries, Twigs, Common Herbs", 1, 10, 1, 0},
{"Old Forest", AdvActivityForaging, 2, "Hardwood, Wild Fruit, Mushrooms", 3, 10, 8, 1},
{"Ancient Grove", AdvActivityForaging, 3, "Ancient Timber, Rare Herbs, Honey", 7, 10, 16, 2},
{"The Deep Jungle", AdvActivityForaging, 4, "Exotic Wood, Tropical Fruits, Spores", 12, 10, 28, 3},
{"Primal Wilds", AdvActivityForaging, 5, "Primordial Bark, Spirit Herbs, Starfruit", 20, 10, 40, 4},
}
var advFishingSpots = []AdvLocation{
{"Muddy Pond", AdvActivityFishing, 1, "Sad Fish, Boots, Tin Cans", 5, 20, 1, 0},
{"Iron Creek", AdvActivityFishing, 2, "Creek Fish, Cheep Cheeps, Wet Rocks", 12, 18, 8, 1},
{"Silver Lake", AdvActivityFishing, 3, "Lake Fish, Bloopers, Legends", 22, 16, 18, 2},
{"The Deep Current", AdvActivityFishing, 4, "River Monsters, Sea Serpents, Whirlpools", 35, 14, 30, 3},
{"Abyssal Trench", AdvActivityFishing, 5, "Ancient Things, The Unnamed, The Deep", 50, 12, 44, 4},
}
// allAdvLocations returns all locations for a given activity type.
func allAdvLocations(activity AdvActivityType) []AdvLocation {
switch activity {
case AdvActivityDungeon:
return advDungeons
case AdvActivityMining:
return advMines
case AdvActivityForaging:
return advForests
case AdvActivityFishing:
return advFishingSpots
}
return nil
}
// findAdvLocation finds a location by name (case-insensitive substring match).
func findAdvLocation(activity AdvActivityType, name string) *AdvLocation {
locs := allAdvLocations(activity)
for i := range locs {
if containsFold(locs[i].Name, name) {
return &locs[i]
}
}
return nil
}
// findAdvLocationByTier returns the location for a given activity and tier.
func findAdvLocationByTier(activity AdvActivityType, tier int) *AdvLocation {
locs := allAdvLocations(activity)
for i := range locs {
if locs[i].Tier == tier {
return &locs[i]
}
}
return nil
}
// ── Loot Tables ──────────────────────────────────────────────────────────────
var advDungeonLoot = map[int][]AdvLootDef{
1: {{"Copper Coins", "treasure", 5, 12}, {"Rat Pelt", "treasure", 8, 18}, {"Mouldy Bread", "treasure", 3, 8}, {"Bent Nail", "treasure", 2, 6}},
2: {{"Iron Scraps", "ore", 20, 40}, {"Goblin Trinket", "treasure", 25, 50}, {"Small Gem", "gem", 40, 80}},
3: {{"Silver Bar", "ore", 100, 200}, {"Ancient Artifact", "treasure", 150, 300}, {"Quality Gem", "gem", 200, 400}},
4: {{"Gold Ingot", "ore", 500, 1000}, {"Enchanted Fragment", "treasure", 800, 1500}, {"Rare Gem", "gem", 1000, 2000}},
5: {{"Legendary Fragment", "treasure", 2000, 5000}, {"Dragon Scale", "treasure", 3000, 8000}, {"Mythic Treasure", "treasure", 5000, 15000}},
}
var advMiningLoot = map[int][]AdvLootDef{
1: {{"Copper Ore", "ore", 5, 12}, {"Tin Ore", "ore", 6, 14}, {"Coal", "ore", 4, 10}},
2: {{"Iron Ore", "ore", 15, 25}, {"Lead Ore", "ore", 18, 30}, {"Saltpetre", "ore", 20, 40}},
3: {{"Silver Ore", "ore", 60, 100}, {"Quartz", "ore", 80, 120}, {"Nickel Ore", "ore", 70, 110}},
4: {{"Gold Ore", "ore", 200, 400}, {"Sapphire", "gem", 300, 500}, {"Titanium Ore", "ore", 250, 450}},
5: {{"Mythril Ore", "ore", 1000, 2500}, {"Dragon Crystal", "gem", 2000, 4000}, {"Voidstone", "ore", 1500, 3500}},
}
var advForagingLoot = map[int][]AdvLootDef{
1: {{"Berries", "fruit", 3, 10}, {"Twigs", "wood", 5, 12}, {"Common Herbs", "fruit", 6, 15}},
2: {{"Hardwood", "wood", 10, 20}, {"Wild Fruit", "fruit", 12, 22}, {"Mushrooms", "fruit", 15, 30}},
3: {{"Ancient Timber", "wood", 40, 80}, {"Rare Herbs", "fruit", 50, 100}, {"Honey", "fruit", 60, 120}},
4: {{"Exotic Wood", "wood", 150, 300}, {"Tropical Fruits", "fruit", 180, 400}, {"Spores", "fruit", 200, 500}},
5: {{"Primordial Bark", "wood", 600, 1500}, {"Spirit Herbs", "fruit", 800, 2000}, {"Starfruit", "fruit", 1000, 3000}},
}
var advFishingLoot = map[int][]AdvLootDef{
1: {{"Sad Fish", "fish", 4, 10}, {"Old Boot", "junk", 5, 12}, {"Tin Can", "junk", 3, 8}},
2: {{"Creek Trout", "fish", 12, 22}, {"Iron Scale", "fish", 15, 28}, {"River Pearl", "gem", 20, 40}},
3: {{"Silver Bass", "fish", 50, 90}, {"Lake Sturgeon", "fish", 60, 110}, {"Blooper Ink", "treasure", 80, 150}},
4: {{"Deep Eel", "fish", 180, 350}, {"River Serpent Scale", "treasure", 250, 500}, {"Abyssal Pearl", "gem", 300, 600}},
5: {{"Ancient Leviathan Tooth", "treasure", 800, 2000}, {"Trench Horror", "fish", 1200, 3000}, {"Void Pearl", "gem", 1500, 4000}},
}
func advLootTable(activity AdvActivityType) map[int][]AdvLootDef {
switch activity {
case AdvActivityDungeon:
return advDungeonLoot
case AdvActivityMining:
return advMiningLoot
case AdvActivityForaging:
return advForagingLoot
case AdvActivityFishing:
return advFishingLoot
}
return nil
}
// ── XP Tables ────────────────────────────────────────────────────────────────
type advXPEntry struct {
Success int
Failure int
Death int
Exceptional int
}
var advXPTable = map[AdvActivityType]map[int]advXPEntry{
AdvActivityDungeon: {
1: {60, 20, 10, 90},
2: {100, 30, 15, 150},
3: {160, 45, 20, 240},
4: {230, 60, 25, 345},
5: {320, 80, 30, 480},
},
AdvActivityMining: {
1: {50, 18, 10, 75},
2: {85, 28, 15, 128},
3: {135, 40, 18, 203},
4: {200, 55, 22, 300},
5: {280, 70, 28, 420},
},
AdvActivityForaging: {
1: {40, 15, 8, 60},
2: {70, 22, 12, 105},
3: {110, 35, 15, 165},
4: {165, 48, 18, 248},
5: {230, 62, 22, 345},
},
AdvActivityFishing: {
1: {45, 16, 8, 68},
2: {78, 25, 12, 117},
3: {120, 38, 16, 180},
4: {180, 52, 20, 270},
5: {250, 68, 25, 375},
},
}
func advXPForOutcome(activity AdvActivityType, tier int, outcome AdvOutcomeType) int {
table, ok := advXPTable[activity]
if !ok {
return 0
}
entry, ok := table[tier]
if !ok {
return 0
}
switch outcome {
case AdvOutcomeDeath:
return entry.Death
case AdvOutcomeEmpty, AdvOutcomeHornets:
return entry.Failure
case AdvOutcomeSuccess, AdvOutcomeCaveIn, AdvOutcomeBear, AdvOutcomeRiver:
return entry.Failure // partial successes get failure XP
case AdvOutcomeExceptional:
return entry.Exceptional
}
return entry.Success
}
// advXPSkill returns which skill receives XP for an activity.
func advXPSkill(activity AdvActivityType) string {
switch activity {
case AdvActivityDungeon:
return "combat"
case AdvActivityMining:
return "mining"
case AdvActivityForaging:
return "foraging"
case AdvActivityFishing:
return "fishing"
}
return ""
}
// ── Bonus Summary ────────────────────────────────────────────────────────────
type AdvBonusSummary struct {
CombatBonus int
MiningBonus int
ForagingBonus int
FishingBonus int
DeathModifier float64 // negative = less death
LootQuality float64 // percentage modifier
XPMultiplier float64 // percentage modifier
ExceptionalBonus float64 // percentage modifier
SuccessBonus float64 // percentage modifier
}
func computeAdvBonuses(treasures []AdvTreasureBonus, buffs []AdvBuff, streak int, hasGrudge bool) *AdvBonusSummary {
b := &AdvBonusSummary{}
// Treasure bonuses
for _, t := range treasures {
switch t.BonusType {
case "combat_level":
b.CombatBonus += int(t.BonusValue)
case "mining_skill":
b.MiningBonus += int(t.BonusValue)
case "foraging_skill":
b.ForagingBonus += int(t.BonusValue)
case "fishing_skill":
b.FishingBonus += int(t.BonusValue)
case "all_skills":
b.CombatBonus += int(t.BonusValue)
b.MiningBonus += int(t.BonusValue)
b.ForagingBonus += int(t.BonusValue)
b.FishingBonus += int(t.BonusValue)
case "death_chance":
b.DeathModifier += t.BonusValue
case "loot_quality":
b.LootQuality += t.BonusValue
case "xp_multiplier":
b.XPMultiplier += t.BonusValue
case "exceptional_chance":
b.ExceptionalBonus += t.BonusValue
case "success_chance":
b.SuccessBonus += t.BonusValue
}
}
// Buff bonuses
for _, buf := range buffs {
switch buf.BuffType {
case "success_chance":
b.SuccessBonus += buf.Modifier
case "death_chance":
b.DeathModifier += buf.Modifier
case "loot_quality":
b.LootQuality += buf.Modifier
case "xp_multiplier":
b.XPMultiplier += buf.Modifier
case "exceptional_chance":
b.ExceptionalBonus += buf.Modifier
case "mining_success":
b.MiningBonus += int(buf.Modifier)
case "foraging_death":
b.DeathModifier += buf.Modifier
}
}
// Streak bonuses
switch {
case streak >= 30:
b.XPMultiplier += 20
b.LootQuality += 15
b.DeathModifier -= 5
case streak >= 14:
b.XPMultiplier += 15
b.LootQuality += 10
b.DeathModifier -= 3
case streak >= 7:
b.XPMultiplier += 10
b.LootQuality += 5
case streak >= 3:
b.XPMultiplier += 5
}
// Grudge bonus
if hasGrudge {
b.SuccessBonus += 10
b.XPMultiplier += 25
}
return b
}
// ── Location Cooldown ───────────────────────────────────────────────────────
// advLocationCooldown returns how long until a player can run the same location
// again. Returns 0 if no cooldown is active. Only successful runs trigger cooldown.
func advLocationCooldown(userID id.UserID, location string) time.Duration {
d := db.Get()
var loggedAt string
err := d.QueryRow(
`SELECT logged_at FROM adventure_activity_log
WHERE user_id = ? AND location = ? AND outcome IN ('success', 'exceptional')
ORDER BY logged_at DESC LIMIT 1`,
string(userID), location,
).Scan(&loggedAt)
if err != nil {
return 0
}
t, err := time.Parse("2006-01-02 15:04:05", loggedAt)
if err != nil {
return 0
}
remaining := advLocationCooldownDuration - time.Since(t)
if remaining <= 0 {
return 0
}
return remaining
}
// ── Eligibility ──────────────────────────────────────────────────────────────
// advIsEligible checks if a character can enter a location.
// Returns (eligible, inPenaltyZone).
func advIsEligible(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, loc *AdvLocation, bonuses *AdvBonusSummary) (bool, bool) {
// Tier gating uses base skill only — buffs improve success chances, not access.
baseLevel := advBaseSkill(char, loc.Activity)
if baseLevel < loc.MinLevel {
return false, false
}
// Check minimum equipment tier — no equipment means tier 0
minTier := 0
if len(equip) > 0 {
minTier = 99
for _, eq := range equip {
if eq.Tier < minTier {
minTier = eq.Tier
}
}
}
if minTier < loc.MinEquipTier {
return false, false
}
// Penalty zone: within 3 levels of minimum (base skill only)
penalty := baseLevel-loc.MinLevel < 3
return true, penalty
}
func advBaseSkill(char *AdventureCharacter, activity AdvActivityType) int {
switch activity {
case AdvActivityDungeon:
return char.CombatLevel
case AdvActivityMining:
return char.MiningSkill
case AdvActivityForaging:
return char.ForagingSkill
case AdvActivityFishing:
return char.FishingSkill
}
return 1
}
func advEffectiveSkill(char *AdventureCharacter, activity AdvActivityType, bonuses *AdvBonusSummary) int {
switch activity {
case AdvActivityDungeon:
return char.CombatLevel + bonuses.CombatBonus
case AdvActivityMining:
return char.MiningSkill + bonuses.MiningBonus
case AdvActivityForaging:
return char.ForagingSkill + bonuses.ForagingBonus
case AdvActivityFishing:
return char.FishingSkill + bonuses.FishingBonus
}
return 1
}
// ── Probability Calculation ──────────────────────────────────────────────────
type advProbabilities struct {
DeathPct float64
EmptyPct float64
SuccessPct float64
ExceptionalPct float64
}
func calculateAdvProbabilities(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, loc *AdvLocation, bonuses *AdvBonusSummary, inPenaltyZone bool) advProbabilities {
eqScore := advEquipmentScore(equip)
skillLevel := float64(advEffectiveSkill(char, loc.Activity, bonuses))
deathPct := loc.BaseDeathPct - (eqScore * 0.8) - (skillLevel * 0.5) + bonuses.DeathModifier
if inPenaltyZone {
deathPct += 5
}
// Clamp death
deathPct = math.Max(1, math.Min(85, deathPct))
emptyPct := loc.EmptyPct
// Success modifiers
baseSuccess := 100 - deathPct - emptyPct
successMod := (eqScore * 1.2) + (skillLevel * 0.8) + bonuses.SuccessBonus
// Masterwork gear: +5% skill-specific success bonus (mining sword → mining, etc.)
if advMasterworkSkillBonus(equip, loc.Activity) {
successMod += 5
}
// Bloodied set: Survivor's Instinct — +3% to all activity success rates
arenaSets := advEquippedArenaSets(equip)
if arenaSets["bloodied"] {
successMod += 3
}
if inPenaltyZone {
successMod -= 15
}
// Exceptional is 10% base, modified by bonuses
exceptionalPct := 10.0 + bonuses.ExceptionalBonus
exceptionalPct = math.Max(2, math.Min(25, exceptionalPct))
successPct := baseSuccess + successMod - exceptionalPct
successPct = math.Max(5, math.Min(90-exceptionalPct, successPct))
// Normalize if over 100
total := deathPct + emptyPct + successPct + exceptionalPct
if total > 100 {
scale := 100 / total
deathPct *= scale
emptyPct *= scale
successPct *= scale
exceptionalPct *= scale
} else if total < 100 {
// Give remaining to success
successPct += 100 - total
}
return advProbabilities{
DeathPct: deathPct,
EmptyPct: emptyPct,
SuccessPct: successPct,
ExceptionalPct: exceptionalPct,
}
}
// ── Loot Generation ──────────────────────────────────────────────────────────
func generateAdvLoot(loc *AdvLocation, exceptional bool, lootQualityMod float64) []AdvItem {
table := advLootTable(loc.Activity)
if table == nil {
return nil
}
defs, ok := table[loc.Tier]
if !ok || len(defs) == 0 {
return nil
}
// Number of items: 1-2 for normal, 2-3 for exceptional
count := 1 + rand.IntN(2)
if exceptional {
count = 2 + rand.IntN(2)
}
var items []AdvItem
for i := 0; i < count; i++ {
def := defs[rand.IntN(len(defs))]
value := def.MinValue + rand.Int64N(def.MaxValue-def.MinValue+1)
// Apply loot quality modifier
if lootQualityMod != 0 {
value = int64(float64(value) * (1 + lootQualityMod/100))
}
// Exceptional items are worth more
if exceptional {
value = int64(float64(value) * 1.5)
}
items = append(items, AdvItem{
Name: def.Name,
Type: def.Type,
Tier: loc.Tier,
Value: value,
})
}
return items
}
// ── Equipment Degradation ────────────────────────────────────────────────────
func applyAdvEquipDegradation(equip map[EquipmentSlot]*AdvEquipment, outcome AdvOutcomeType) map[EquipmentSlot]int {
damage := make(map[EquipmentSlot]int)
switch outcome {
case AdvOutcomeDeath:
for _, slot := range allSlots {
damage[slot] = 20
}
damage[SlotWeapon] = 30
damage[SlotArmor] = 30
case AdvOutcomeCaveIn:
damage[SlotTool] = 25
damage[SlotArmor] = 10
case AdvOutcomeEmpty:
damage[SlotWeapon] = 15
damage[SlotArmor] = 10
case AdvOutcomeBear:
damage[SlotArmor] = 20
damage[SlotBoots] = 15
case AdvOutcomeRiver:
damage[SlotBoots] = 20
case AdvOutcomeHornets:
}
return applyDegradationModifiers(damage, equip)
}
// advCheckBrokenEquipment checks which slots hit 0 condition and reverts them to tier 0.
func advCheckBrokenEquipment(equip map[EquipmentSlot]*AdvEquipment) []EquipmentSlot {
var broken []EquipmentSlot
for _, slot := range allSlots {
eq, ok := equip[slot]
if !ok || eq.Condition > 0 {
continue
}
// Revert to tier 0
def := equipmentTiers[slot][0]
eq.Tier = 0
eq.Condition = 100
eq.Name = def.Name
eq.ActionsUsed = 0
eq.ArenaTier = 0
eq.ArenaSet = ""
eq.Masterwork = false
eq.SkillSource = ""
broken = append(broken, slot)
}
return broken
}
// ── Overlevel Penalty ───────────────────────────────────────────────────────
// advOverlevelMultiplier returns a multiplier (0.051.0) that reduces XP and
// loot when a character's effective level far exceeds the location's minimum.
// Gap 0-3: no penalty. Gap 4+: 15% per level over 3, floor 5%.
// No penalty if no higher-tier location of the same activity is accessible.
func advOverlevelMultiplier(effectiveLevel int, loc *AdvLocation) float64 {
gap := effectiveLevel - loc.MinLevel
if gap <= 3 {
return 1.0
}
hasHigherAccessible := false
for _, other := range allAdvLocations(loc.Activity) {
if other.MinLevel > loc.MinLevel && other.MinLevel <= effectiveLevel {
hasHigherAccessible = true
break
}
}
if !hasHigherAccessible {
return 1.0
}
mult := 1.0 - 0.15*float64(gap-3)
return math.Max(0.05, mult)
}
// ── Outcome Resolution ───────────────────────────────────────────────────────
type AdvActionResult struct {
Outcome AdvOutcomeType
Location *AdvLocation
LootItems []AdvItem
TotalLootValue int64
XPGained int
XPBreakdown string // human-readable bonus breakdown
XPSkill string
EquipDamage map[EquipmentSlot]int
LeveledUp bool
NewLevel int
TreasureFound *AdvTreasureDrop
FlavorText string
FlavorKey string
EquipBroken []EquipmentSlot
NearDeath bool
StreakBonus int
CombatLog *CombatResult
// MasteryCrossings records equipment slots whose ActionsUsed crossed a
// mastery threshold (advMasteryThresholds) on this action. The caller
// uses this to DM a celebration so the silent counter becomes visible.
MasteryCrossings []AdvMasteryCrossing
}
type AdvMasteryCrossing struct {
Slot EquipmentSlot
ItemName string
Threshold int // 50, 100, or 250
}
// advMasteryThresholds defines the action counts at which equipment mastery
// is celebrated. Picked to span an early-game milestone (50), a mid-game
// commitment (100), and a hard-to-reach veteran tier (250).
var advMasteryThresholds = []int{50, 100, 250}
// advMasteryTier reports how many mastery thresholds a piece has crossed
// (03) and the next threshold. nextThreshold is 0 when the piece has
// already crossed every threshold.
func advMasteryTier(actionsUsed int) (tier, nextThreshold int) {
for _, t := range advMasteryThresholds {
if actionsUsed >= t {
tier++
} else {
nextThreshold = t
break
}
}
return tier, nextThreshold
}
// advMasteryMarker returns the visual tier marker for a piece of equipment.
// Empty for sub-50; ✦/✦✦/✦✦✦ at the 50/100/250 thresholds.
func advMasteryMarker(actionsUsed int) string {
tier, _ := advMasteryTier(actionsUsed)
switch tier {
case 1:
return "✦"
case 2:
return "✦✦"
case 3:
return "✦✦✦"
}
return ""
}
// advSlotRelevantToActivity reports whether using a slot during the given
// activity should count toward that slot's mastery. Combat actions exercise
// the combat loadout (weapon/armor/helmet/boots); harvest actions exercise
// only the tool. This is what makes mastery feel like "you used this
// piece" rather than "you played for a while" — without this gate every
// slot ticks in lockstep and the per-slot view is meaningless.
func advSlotRelevantToActivity(slot EquipmentSlot, activity AdvActivityType) bool {
if isCombatActivity(activity) {
return slot == SlotWeapon || slot == SlotArmor || slot == SlotHelmet || slot == SlotBoots
}
if isHarvestActivity(activity) {
return slot == SlotTool
}
return false
}
// advMasteryRowSegment formats the `23/50`, `73/100 ✦`, `142/250 ✦✦`, or
// `250 ✦✦✦` segment shown inline on each equipment row in the character
// sheet so progress is visible at a glance — not just a tier marker that
// flips on at 50/100/250 with nothing in between.
func advMasteryRowSegment(actionsUsed int) string {
if actionsUsed <= 0 {
return ""
}
_, next := advMasteryTier(actionsUsed)
marker := advMasteryMarker(actionsUsed)
if next == 0 {
// At max — there's no "next" threshold to count toward.
return fmt.Sprintf("%d %s", actionsUsed, marker)
}
if marker == "" {
return fmt.Sprintf("%d/%d", actionsUsed, next)
}
return fmt.Sprintf("%d/%d %s", actionsUsed, next, marker)
}
// AdvMasteryRollup summarizes mastery state across all equipped slots so the
// character sheet can show one informative aggregate line without a
// per-slot block. AnyProgress is true when at least one slot has any
// ActionsUsed — used to decide whether to show a "building up" hint when
// no thresholds have crossed yet.
type AdvMasteryRollup struct {
ThresholdsCrossed int // raw count, pre-cap (sum across all slots)
MaxedSlots int // slots with all 3 thresholds crossed
Bonus float64 // capped %, mirrors advEquipmentMasteryBonus
AnyProgress bool
}
func advMasteryRollup(equip map[EquipmentSlot]*AdvEquipment) AdvMasteryRollup {
r := AdvMasteryRollup{}
for _, eq := range equip {
if eq == nil {
continue
}
if eq.ActionsUsed > 0 {
r.AnyProgress = true
}
tier, _ := advMasteryTier(eq.ActionsUsed)
r.ThresholdsCrossed += tier
if tier == len(advMasteryThresholds) {
r.MaxedSlots++
}
}
r.Bonus = advEquipmentMasteryBonus(equip)
return r
}
// advEquipmentMasteryBonus returns the loot-quality bonus from accumulated
// mastery across all equipped slots. Each crossed threshold grants +1% loot
// quality on its slot, capped at +10% total to keep balance reasonable.
func advEquipmentMasteryBonus(equip map[EquipmentSlot]*AdvEquipment) float64 {
total := 0.0
for _, eq := range equip {
if eq == nil {
continue
}
for _, t := range advMasteryThresholds {
if eq.ActionsUsed >= t {
total++
}
}
}
if total > 10 {
total = 10
}
return total
}
// ── Party Bonus Check ────────────────────────────────────────────────────────
// advCheckPartyBonus checks if other players visited the same location today.
func advCheckPartyBonus(userID id.UserID, location string) bool {
logs, err := loadAdvLogsForDate(time.Now().UTC().Format("2006-01-02"))
if err != nil {
return false
}
for _, l := range logs {
if l.UserID != userID && l.Location == location {
return true
}
}
return false
}
// ── Helpers ──────────────────────────────────────────────────────────────────
func containsFold(s, substr string) bool {
if len(substr) == 0 {
return true
}
sl := make([]byte, len(s))
subl := make([]byte, len(substr))
for i := range s {
if s[i] >= 'A' && s[i] <= 'Z' {
sl[i] = s[i] + 32
} else {
sl[i] = s[i]
}
}
for i := range substr {
if substr[i] >= 'A' && substr[i] <= 'Z' {
subl[i] = substr[i] + 32
} else {
subl[i] = substr[i]
}
}
return containsBytes(sl, subl)
}
func containsBytes(s, sub []byte) bool {
if len(sub) > len(s) {
return false
}
for i := 0; i <= len(s)-len(sub); i++ {
match := true
for j := range sub {
if s[i+j] != sub[j] {
match = false
break
}
}
if match {
return true
}
}
return false
}