mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Add adventure plugin, holdem CFR fixes, and wordle plugin
Adventure: Complete v1 daily idle RPG with DM-driven gameplay, equipment shop, treasure system, TwinBee NPC, streak/grudge/party mechanics, flavor text, and scheduled morning/evening/midnight tickers. Holdem CFR: Fix three critical training bugs (fold not forfeiting pot, free calls after raises, training/runtime key mismatch). Add performance optimizations (preflop lookup table, zero-alloc equity, integer keys, raise cap, regret pruning). Enrich abstraction with 12 equity buckets, board texture dimension, and 6-char action history. Replace validation with proper multi-street simulation. Also includes wordle plugin, holdem seed tooling, and schema additions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
726
internal/plugin/adventure_activities.go
Normal file
726
internal/plugin/adventure_activities.go
Normal file
@@ -0,0 +1,726 @@
|
||||
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
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user