mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
- Add forex plugin (Frankfurter v2 API) with rate lookups, analysis, DM-based alerts, and daily cron poll. Backfills 1 year of history on startup for moving averages and buy signal scoring. - Fix bot hang caused by SQLite lock contention in reminder polling: rows cursor was held open while writing to the same DB. Collect results first, close cursor, then process. Same fix in milkcarton. - Add sync retry loop so the bot reconnects after network drops instead of silently exiting. StopSync() for clean Ctrl+C shutdown. - Add panic recovery to all dispatch, syncer, and cron paths. - Make all HTTP-calling plugin commands async (goroutines) so a slow or dead external API cannot block the message dispatch pipeline. Affects: lookup, stocks, forex, anime, movies, concerts, gaming, retro, wotd, urls, howami. - Extract DisplayName to Base, add db.Exec helper, convert silent error discards across the codebase. - Fix UNO mercy-kill bug (eliminated bot continues playing), adventure DM nag spam, stats column mismatch, per-call regex/replacer allocs. - Update README: forex commands, Finance section, 47 plugins. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
730 lines
21 KiB
Go
730 lines
21 KiB
Go
package plugin
|
|
|
|
import (
|
|
"math"
|
|
"math/rand/v2"
|
|
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// ── Activity & Outcome Types ─────────────────────────────────────────────────
|
|
|
|
type AdvActivityType string
|
|
|
|
const (
|
|
AdvActivityDungeon AdvActivityType = "dungeon"
|
|
AdvActivityMining AdvActivityType = "mining"
|
|
AdvActivityForaging AdvActivityType = "foraging"
|
|
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},
|
|
}
|
|
|
|
// 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
|
|
}
|
|
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", 1, 5}, {"Rat Pelt", "treasure", 3, 8}, {"Mouldy Bread", "treasure", 1, 3}, {"Bent Nail", "treasure", 1, 2}},
|
|
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", 2, 5}, {"Tin Ore", "ore", 3, 6}, {"Coal", "ore", 2, 4}},
|
|
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", 1, 4}, {"Twigs", "wood", 2, 5}, {"Common Herbs", "fruit", 3, 8}},
|
|
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}},
|
|
}
|
|
|
|
func advLootTable(activity AdvActivityType) map[int][]AdvLootDef {
|
|
switch activity {
|
|
case AdvActivityDungeon:
|
|
return advDungeonLoot
|
|
case AdvActivityMining:
|
|
return advMiningLoot
|
|
case AdvActivityForaging:
|
|
return advForagingLoot
|
|
}
|
|
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},
|
|
},
|
|
}
|
|
|
|
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"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ── Bonus Summary ────────────────────────────────────────────────────────────
|
|
|
|
type AdvBonusSummary struct {
|
|
CombatBonus int
|
|
MiningBonus int
|
|
ForagingBonus 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 "all_skills":
|
|
b.CombatBonus += int(t.BonusValue)
|
|
b.MiningBonus += int(t.BonusValue)
|
|
b.ForagingBonus += 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
|
|
}
|
|
|
|
// ── 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) {
|
|
// Get effective skill level
|
|
skillLevel := advEffectiveSkill(char, loc.Activity, bonuses)
|
|
|
|
if skillLevel < 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
|
|
penalty := skillLevel-loc.MinLevel < 3
|
|
return true, penalty
|
|
}
|
|
|
|
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
|
|
}
|
|
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 := float64(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
|
|
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:
|
|
// All slots -20, weapon and armor -30 (additional)
|
|
for _, slot := range allSlots {
|
|
damage[slot] = 20
|
|
}
|
|
damage[SlotWeapon] = 30
|
|
damage[SlotArmor] = 30
|
|
|
|
case AdvOutcomeCaveIn:
|
|
damage[SlotTool] = 25
|
|
damage[SlotArmor] = 10
|
|
|
|
case AdvOutcomeEmpty:
|
|
// Failed dungeon run
|
|
damage[SlotWeapon] = 15
|
|
damage[SlotArmor] = 10
|
|
|
|
case AdvOutcomeBear:
|
|
damage[SlotArmor] = 20
|
|
damage[SlotBoots] = 15
|
|
|
|
case AdvOutcomeRiver:
|
|
damage[SlotBoots] = 20
|
|
|
|
case AdvOutcomeHornets:
|
|
// No equipment damage — they don't care about your sword
|
|
}
|
|
|
|
// Apply damage and check for breaks
|
|
for slot, dmg := range damage {
|
|
eq, ok := equip[slot]
|
|
if !ok {
|
|
continue
|
|
}
|
|
// Equipment mastery: well-used gear degrades slower
|
|
if eq.ActionsUsed >= 20 {
|
|
dmg = int(float64(dmg) * 0.8)
|
|
}
|
|
eq.Condition -= dmg
|
|
if eq.Condition < 0 {
|
|
eq.Condition = 0
|
|
}
|
|
}
|
|
|
|
return damage
|
|
}
|
|
|
|
// 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
|
|
broken = append(broken, slot)
|
|
}
|
|
return broken
|
|
}
|
|
|
|
// ── Outcome Resolution ───────────────────────────────────────────────────────
|
|
|
|
type AdvActionResult struct {
|
|
Outcome AdvOutcomeType
|
|
Location *AdvLocation
|
|
LootItems []AdvItem
|
|
TotalLootValue int64
|
|
XPGained int
|
|
XPSkill string
|
|
EquipDamage map[EquipmentSlot]int
|
|
LeveledUp bool
|
|
NewLevel int
|
|
TreasureFound *AdvTreasureDrop
|
|
FlavorText string
|
|
FlavorKey string
|
|
EquipBroken []EquipmentSlot
|
|
NearDeath bool
|
|
StreakBonus int
|
|
}
|
|
|
|
func resolveAdvAction(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, loc *AdvLocation, bonuses *AdvBonusSummary, inPenaltyZone bool) *AdvActionResult {
|
|
result := &AdvActionResult{
|
|
Location: loc,
|
|
XPSkill: advXPSkill(loc.Activity),
|
|
}
|
|
|
|
probs := calculateAdvProbabilities(char, equip, loc, bonuses, inPenaltyZone)
|
|
|
|
// 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)
|
|
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
|
|
}
|
|
}
|
|
|
|
// Near-death XP bonus
|
|
if result.NearDeath {
|
|
xp = int(float64(xp) * 1.15)
|
|
}
|
|
|
|
// XP multiplier from bonuses
|
|
if bonuses.XPMultiplier != 0 {
|
|
xp = int(float64(xp) * (1 + bonuses.XPMultiplier/100))
|
|
}
|
|
result.XPGained = xp
|
|
|
|
// 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
|
|
for _, eq := range equip {
|
|
eq.ActionsUsed++
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
default:
|
|
return AdvOutcomeEmpty
|
|
}
|
|
}
|
|
|
|
// ── Eligible Locations for DM Menu ───────────────────────────────────────────
|
|
|
|
type AdvEligibleLocation struct {
|
|
Location *AdvLocation
|
|
InPenaltyZone bool
|
|
DeathPct 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,
|
|
})
|
|
}
|
|
return eligible
|
|
}
|
|
|
|
// ── Party Bonus Check ────────────────────────────────────────────────────────
|
|
|
|
// advCheckPartyBonus checks if other players visited the same location today.
|
|
func advCheckPartyBonus(userID id.UserID, location string) bool {
|
|
logs, err := loadAdvTodayLogs()
|
|
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
|
|
}
|