mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Add Misty & Arina NPCs, fix tier gating, babysit actions, Robbie filter, streak/scheduler bugs
Misty & Arina: random encounter NPCs triggered by chat message counting (5-10 msg threshold, 7-day cooldown, 7.5% roll). Misty asks for €100 — accept gives 7-day arena buff (20% gourmet food heals gear + 5 enemy dmg), decline gives 7-day debuff (20% crowd revenge damage). Arina asks for €5000 — accept gives 7-day sniper buff (8% instant kill in arena). Effects integrate into arena round resolution after combat roll; sniper overrides death. Other fixes: - Tier gating now uses base skill only — buffs improve success, not access - Babysitter uses all harvest actions (3/day, 4 on holidays) instead of 1 - Robbie collects all non-equipped items, not just slotted gear - Robbie timing: fire on first tick >= target hour, not exact minute match - Streak: dead players who acted still get streak credit - Streak: dead players skip shame DM (use LastDeathDate, not Alive flag) - Midnight ticker: in-memory date cache prevents 1439 wasted DB checks/day - T1 loot values ~3x across all activities to close T1/T2 gap Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -100,6 +100,15 @@ func runMigrations(d *sql.DB) error {
|
|||||||
`ALTER TABLE adventure_characters ADD COLUMN last_pardon_used DATETIME`,
|
`ALTER TABLE adventure_characters ADD COLUMN last_pardon_used DATETIME`,
|
||||||
`ALTER TABLE arena_runs ADD COLUMN tier_earnings INTEGER NOT NULL DEFAULT 0`,
|
`ALTER TABLE arena_runs ADD COLUMN tier_earnings INTEGER NOT NULL DEFAULT 0`,
|
||||||
`ALTER TABLE arena_runs ADD COLUMN xp_accumulated INTEGER NOT NULL DEFAULT 0`,
|
`ALTER TABLE arena_runs ADD COLUMN xp_accumulated INTEGER NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE adventure_characters ADD COLUMN misty_last_seen DATETIME`,
|
||||||
|
`ALTER TABLE adventure_characters ADD COLUMN arina_last_seen DATETIME`,
|
||||||
|
`ALTER TABLE adventure_characters ADD COLUMN misty_buff_expires DATETIME`,
|
||||||
|
`ALTER TABLE adventure_characters ADD COLUMN misty_debuff_expires DATETIME`,
|
||||||
|
`ALTER TABLE adventure_characters ADD COLUMN arina_buff_expires DATETIME`,
|
||||||
|
`ALTER TABLE adventure_characters ADD COLUMN npc_msg_count INTEGER NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE adventure_characters ADD COLUMN npc_msg_count_date TEXT NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE adventure_characters ADD COLUMN misty_roll_target INTEGER NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE adventure_characters ADD COLUMN arina_roll_target INTEGER NOT NULL DEFAULT 0`,
|
||||||
}
|
}
|
||||||
for _, stmt := range columnMigrations {
|
for _, stmt := range columnMigrations {
|
||||||
if _, err := d.Exec(stmt); err != nil {
|
if _, err := d.Exec(stmt); err != nil {
|
||||||
|
|||||||
@@ -186,7 +186,14 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
|||||||
return p.handleDMReply(ctx)
|
return p.handleDMReply(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Command dispatch
|
// 3. NPC encounter tracking — count room messages from adventure players
|
||||||
|
if !isDM && !strings.HasPrefix(ctx.Body, "!") {
|
||||||
|
safeGo("npc-track", func() {
|
||||||
|
p.npcTrackMessage(ctx.Sender)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Command dispatch
|
||||||
if !p.IsCommand(ctx.Body, "adventure") && !p.IsCommand(ctx.Body, "adv") {
|
if !p.IsCommand(ctx.Body, "adventure") && !p.IsCommand(ctx.Body, "adv") {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -559,6 +566,8 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact
|
|||||||
return p.resolveShopConfirm(ctx, interaction)
|
return p.resolveShopConfirm(ctx, interaction)
|
||||||
case "hospital_pay":
|
case "hospital_pay":
|
||||||
return p.resolveHospitalPay(ctx, interaction)
|
return p.resolveHospitalPay(ctx, interaction)
|
||||||
|
case "npc_encounter":
|
||||||
|
return p.resolveNPCEncounter(ctx, interaction)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ func findAdvLocationByTier(activity AdvActivityType, tier int) *AdvLocation {
|
|||||||
// ── Loot Tables ──────────────────────────────────────────────────────────────
|
// ── Loot Tables ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
var advDungeonLoot = map[int][]AdvLootDef{
|
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}},
|
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}},
|
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}},
|
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}},
|
4: {{"Gold Ingot", "ore", 500, 1000}, {"Enchanted Fragment", "treasure", 800, 1500}, {"Rare Gem", "gem", 1000, 2000}},
|
||||||
@@ -138,7 +138,7 @@ var advDungeonLoot = map[int][]AdvLootDef{
|
|||||||
}
|
}
|
||||||
|
|
||||||
var advMiningLoot = map[int][]AdvLootDef{
|
var advMiningLoot = map[int][]AdvLootDef{
|
||||||
1: {{"Copper Ore", "ore", 2, 5}, {"Tin Ore", "ore", 3, 6}, {"Coal", "ore", 2, 4}},
|
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}},
|
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}},
|
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}},
|
4: {{"Gold Ore", "ore", 200, 400}, {"Sapphire", "gem", 300, 500}, {"Titanium Ore", "ore", 250, 450}},
|
||||||
@@ -146,7 +146,7 @@ var advMiningLoot = map[int][]AdvLootDef{
|
|||||||
}
|
}
|
||||||
|
|
||||||
var advForagingLoot = map[int][]AdvLootDef{
|
var advForagingLoot = map[int][]AdvLootDef{
|
||||||
1: {{"Berries", "fruit", 1, 4}, {"Twigs", "wood", 2, 5}, {"Common Herbs", "fruit", 3, 8}},
|
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}},
|
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}},
|
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}},
|
4: {{"Exotic Wood", "wood", 150, 300}, {"Tropical Fruits", "fruit", 180, 400}, {"Spores", "fruit", 200, 500}},
|
||||||
@@ -154,7 +154,7 @@ var advForagingLoot = map[int][]AdvLootDef{
|
|||||||
}
|
}
|
||||||
|
|
||||||
var advFishingLoot = map[int][]AdvLootDef{
|
var advFishingLoot = map[int][]AdvLootDef{
|
||||||
1: {{"Sad Fish", "fish", 1, 4}, {"Old Boot", "junk", 2, 5}, {"Tin Can", "junk", 1, 3}},
|
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}},
|
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}},
|
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}},
|
4: {{"Deep Eel", "fish", 180, 350}, {"River Serpent Scale", "treasure", 250, 500}, {"Abyssal Pearl", "gem", 300, 600}},
|
||||||
@@ -376,10 +376,9 @@ func advLocationCooldown(userID id.UserID, location string) time.Duration {
|
|||||||
// advIsEligible checks if a character can enter a location.
|
// advIsEligible checks if a character can enter a location.
|
||||||
// Returns (eligible, inPenaltyZone).
|
// Returns (eligible, inPenaltyZone).
|
||||||
func advIsEligible(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, loc *AdvLocation, bonuses *AdvBonusSummary) (bool, bool) {
|
func advIsEligible(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, loc *AdvLocation, bonuses *AdvBonusSummary) (bool, bool) {
|
||||||
// Get effective skill level
|
// Tier gating uses base skill only — buffs improve success chances, not access.
|
||||||
skillLevel := advEffectiveSkill(char, loc.Activity, bonuses)
|
baseLevel := advBaseSkill(char, loc.Activity)
|
||||||
|
if baseLevel < loc.MinLevel {
|
||||||
if skillLevel < loc.MinLevel {
|
|
||||||
return false, false
|
return false, false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,11 +396,25 @@ func advIsEligible(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipme
|
|||||||
return false, false
|
return false, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Penalty zone: within 3 levels of minimum
|
// Penalty zone: within 3 levels of minimum (base skill only)
|
||||||
penalty := skillLevel-loc.MinLevel < 3
|
penalty := baseLevel-loc.MinLevel < 3
|
||||||
return true, penalty
|
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 {
|
func advEffectiveSkill(char *AdventureCharacter, activity AdvActivityType, bonuses *AdvBonusSummary) int {
|
||||||
switch activity {
|
switch activity {
|
||||||
case AdvActivityDungeon:
|
case AdvActivityDungeon:
|
||||||
|
|||||||
@@ -148,11 +148,22 @@ func (p *AdventurePlugin) handleArenaFight(ctx MessageContext) error {
|
|||||||
closeness := 1.0 - math.Abs(roll-deathChance)/math.Max(deathChance, 1-deathChance)
|
closeness := 1.0 - math.Abs(roll-deathChance)/math.Max(deathChance, 1-deathChance)
|
||||||
combatLog := generateArenaCombatLog(!died, closeness)
|
combatLog := generateArenaCombatLog(!died, closeness)
|
||||||
|
|
||||||
|
// NPC arena effects (Misty/Arina) — checked after combat roll
|
||||||
|
npcResult := npcCheckArenaEffects(char, monster.Name)
|
||||||
|
if npcResult != nil && npcResult.SniperKill {
|
||||||
|
// Sniper overrides death — enemy is killed instantly
|
||||||
|
died = false
|
||||||
|
}
|
||||||
|
|
||||||
if died {
|
if died {
|
||||||
|
// Apply crowd revenge text to death message if applicable
|
||||||
|
if npcResult != nil && npcResult.Text != "" {
|
||||||
|
combatLog.NPCText = npcResult.Text
|
||||||
|
}
|
||||||
return p.resolveArenaDeath(ctx, run, char, tier, monster, combatLog)
|
return p.resolveArenaDeath(ctx, run, char, tier, monster, combatLog)
|
||||||
}
|
}
|
||||||
|
|
||||||
return p.resolveArenaSurvival(ctx, run, char, tier, monster, combatLog)
|
return p.resolveArenaSurvival(ctx, run, char, tier, monster, combatLog, npcResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *AdventurePlugin) confirmAndStartArenaRun(ctx MessageContext) error {
|
func (p *AdventurePlugin) confirmAndStartArenaRun(ctx MessageContext) error {
|
||||||
@@ -201,11 +212,19 @@ func (p *AdventurePlugin) confirmAndStartArenaRun(ctx MessageContext) error {
|
|||||||
closeness := 1.0 - math.Abs(roll-deathChance)/math.Max(deathChance, 1-deathChance)
|
closeness := 1.0 - math.Abs(roll-deathChance)/math.Max(deathChance, 1-deathChance)
|
||||||
combatLog := generateArenaCombatLog(!died, closeness)
|
combatLog := generateArenaCombatLog(!died, closeness)
|
||||||
|
|
||||||
|
npcResult := npcCheckArenaEffects(char, monster.Name)
|
||||||
|
if npcResult != nil && npcResult.SniperKill {
|
||||||
|
died = false
|
||||||
|
}
|
||||||
|
|
||||||
if died {
|
if died {
|
||||||
|
if npcResult != nil && npcResult.Text != "" {
|
||||||
|
combatLog.NPCText = npcResult.Text
|
||||||
|
}
|
||||||
return p.resolveArenaDeath(ctx, run, char, tier, monster, combatLog)
|
return p.resolveArenaDeath(ctx, run, char, tier, monster, combatLog)
|
||||||
}
|
}
|
||||||
|
|
||||||
return p.resolveArenaSurvival(ctx, run, char, tier, monster, combatLog)
|
return p.resolveArenaSurvival(ctx, run, char, tier, monster, combatLog, npcResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *AdventurePlugin) handleArenaCancel(ctx MessageContext) error {
|
func (p *AdventurePlugin) handleArenaCancel(ctx MessageContext) error {
|
||||||
@@ -284,7 +303,7 @@ func (p *AdventurePlugin) handleArenaLeaderboard(ctx MessageContext) error {
|
|||||||
var arenaStreakEuroMultiplier = [6]float64{0, 1.0, 1.5, 2.0, 2.75, 4.0} // index = tier
|
var arenaStreakEuroMultiplier = [6]float64{0, 1.0, 1.5, 2.0, 2.75, 4.0} // index = tier
|
||||||
var arenaStreakXPMultiplier = [6]float64{0, 1.0, 1.2, 1.5, 1.85, 2.5} // index = tiers won
|
var arenaStreakXPMultiplier = [6]float64{0, 1.0, 1.2, 1.5, 1.85, 2.5} // index = tiers won
|
||||||
|
|
||||||
func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, tier *ArenaTier, monster *ArenaMonster, combatLog *ArenaCombatLog) error {
|
func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, tier *ArenaTier, monster *ArenaMonster, combatLog *ArenaCombatLog, npcResult *npcArenaResult) error {
|
||||||
// Calculate reward — accumulate in tier earnings, not credited yet
|
// Calculate reward — accumulate in tier earnings, not credited yet
|
||||||
reward := arenaRoundReward(tier, run.Round, char.CombatLevel)
|
reward := arenaRoundReward(tier, run.Round, char.CombatLevel)
|
||||||
run.TierEarnings += reward
|
run.TierEarnings += reward
|
||||||
@@ -305,6 +324,16 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun
|
|||||||
// Build survival message with combat log
|
// Build survival message with combat log
|
||||||
closer := arenaWinCloser(monster.Name, len(combatLog.Rounds))
|
closer := arenaWinCloser(monster.Name, len(combatLog.Rounds))
|
||||||
text := renderArenaCombatLog(combatLog, monster, true, reward, battleXP, closer)
|
text := renderArenaCombatLog(combatLog, monster, true, reward, battleXP, closer)
|
||||||
|
|
||||||
|
// Append NPC effects (Misty food/crowd, Arina sniper)
|
||||||
|
if npcResult != nil && npcResult.Text != "" {
|
||||||
|
text += npcResult.Text
|
||||||
|
// Apply equipment condition repair from gourmet food
|
||||||
|
if npcResult.CondRepair > 0 {
|
||||||
|
npcRepairMostDamaged(ctx.Sender, equip, npcResult.CondRepair)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
text += fmt.Sprintf("\nTier earnings: €%d | Session total: €%d (at risk)\n",
|
text += fmt.Sprintf("\nTier earnings: €%d | Session total: €%d (at risk)\n",
|
||||||
run.TierEarnings, run.Earnings+run.TierEarnings)
|
run.TierEarnings, run.Earnings+run.TierEarnings)
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type ArenaCombatLog struct {
|
|||||||
PlayerHP int
|
PlayerHP int
|
||||||
EnemyHP int
|
EnemyHP int
|
||||||
PlayerWon bool
|
PlayerWon bool
|
||||||
|
NPCText string // Misty/Arina effect text, appended to round log
|
||||||
}
|
}
|
||||||
|
|
||||||
type ArenaCombatRound struct {
|
type ArenaCombatRound struct {
|
||||||
@@ -377,6 +378,11 @@ func renderArenaCombatLog(log *ArenaCombatLog, monster *ArenaMonster, won bool,
|
|||||||
sb.WriteString(fmt.Sprintf("+%d XP (participation) | Back tomorrow.\n", arenaParticipationXP))
|
sb.WriteString(fmt.Sprintf("+%d XP (participation) | Back tomorrow.\n", arenaParticipationXP))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if log.NPCText != "" {
|
||||||
|
sb.WriteString(log.NPCText)
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -220,29 +220,62 @@ func (p *AdventurePlugin) handleBabysitCancel(ctx MessageContext) error {
|
|||||||
// ── Daily Auto-Resolution ───────────────────────────────────────────────────
|
// ── Daily Auto-Resolution ───────────────────────────────────────────────────
|
||||||
|
|
||||||
func (p *AdventurePlugin) runBabysitDaily(char *AdventureCharacter) {
|
func (p *AdventurePlugin) runBabysitDaily(char *AdventureCharacter) {
|
||||||
activity := skillToActivity(char.BabysitSkillFocus)
|
|
||||||
|
|
||||||
equip, err := loadAdvEquipment(char.UserID)
|
equip, err := loadAdvEquipment(char.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("babysit: failed to load equipment", "user", char.UserID, "err", err)
|
slog.Error("babysit: failed to load equipment", "user", char.UserID, "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pick highest-tier eligible location for the focus skill
|
isHol, _ := isHolidayToday()
|
||||||
|
harvestMax := maxHarvestActions
|
||||||
|
if isHol {
|
||||||
|
harvestMax++
|
||||||
|
}
|
||||||
|
|
||||||
bonuses := &AdvBonusSummary{}
|
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)
|
eligible := advEligibleLocations(char, equip, activity, bonuses)
|
||||||
if len(eligible) == 0 {
|
if len(eligible) == 0 {
|
||||||
slog.Warn("babysit: no eligible locations", "user", char.UserID, "skill", char.BabysitSkillFocus)
|
return 0, 0, nil
|
||||||
return
|
|
||||||
}
|
}
|
||||||
// Pick the last one (highest tier since they're returned in order)
|
|
||||||
loc := eligible[len(eligible)-1].Location
|
loc := eligible[len(eligible)-1].Location
|
||||||
inPenalty := eligible[len(eligible)-1].InPenaltyZone
|
inPenalty := eligible[len(eligible)-1].InPenaltyZone
|
||||||
|
|
||||||
// Resolve action
|
|
||||||
result := resolveAdvAction(char, equip, loc, bonuses, inPenalty)
|
result := resolveAdvAction(char, equip, loc, bonuses, inPenalty)
|
||||||
|
|
||||||
// Babysitter never lets the adventurer die — reroll death to empty
|
// Babysitter never lets the adventurer die
|
||||||
if result.Outcome == AdvOutcomeDeath {
|
if result.Outcome == AdvOutcomeDeath {
|
||||||
result.Outcome = AdvOutcomeEmpty
|
result.Outcome = AdvOutcomeEmpty
|
||||||
result.LootItems = nil
|
result.LootItems = nil
|
||||||
@@ -253,6 +286,8 @@ func (p *AdventurePlugin) runBabysitDaily(char *AdventureCharacter) {
|
|||||||
|
|
||||||
// Apply XP
|
// Apply XP
|
||||||
switch result.XPSkill {
|
switch result.XPSkill {
|
||||||
|
case "combat":
|
||||||
|
char.CombatXP += result.XPGained
|
||||||
case "mining":
|
case "mining":
|
||||||
char.MiningXP += result.XPGained
|
char.MiningXP += result.XPGained
|
||||||
case "foraging":
|
case "foraging":
|
||||||
@@ -262,43 +297,19 @@ func (p *AdventurePlugin) runBabysitDaily(char *AdventureCharacter) {
|
|||||||
}
|
}
|
||||||
checkAdvLevelUp(char, result.XPSkill)
|
checkAdvLevelUp(char, result.XPSkill)
|
||||||
|
|
||||||
// Credit gold to player
|
// Credit gold
|
||||||
if result.TotalLootValue > 0 {
|
if result.TotalLootValue > 0 {
|
||||||
p.euro.Credit(char.UserID, float64(result.TotalLootValue), "babysit_haul")
|
p.euro.Credit(char.UserID, float64(result.TotalLootValue), "babysit_haul")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Items are claimed by the babysitter (not added to player inventory)
|
|
||||||
var itemNames []string
|
|
||||||
for _, item := range result.LootItems {
|
|
||||||
itemNames = append(itemNames, item.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// No treasure drops during babysitting
|
// No treasure drops during babysitting
|
||||||
result.TreasureFound = nil
|
result.TreasureFound = nil
|
||||||
|
|
||||||
// Mark action taken (babysit always uses a harvest action)
|
var items []string
|
||||||
isHol, _ := isHolidayToday()
|
for _, item := range result.LootItems {
|
||||||
harvestMax := maxHarvestActions
|
items = append(items, item.Name)
|
||||||
if isHol {
|
|
||||||
harvestMax++
|
|
||||||
}
|
}
|
||||||
if char.HarvestActionsUsed < harvestMax {
|
return int(result.TotalLootValue), result.XPGained, 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 to babysit table
|
|
||||||
itemsJSON := ""
|
|
||||||
if len(itemNames) > 0 {
|
|
||||||
itemsJSON = strings.Join(itemNames, ", ")
|
|
||||||
}
|
|
||||||
logBabysitActivity(char.UserID, string(activity), string(result.Outcome),
|
|
||||||
int(result.TotalLootValue), result.XPGained, itemsJSON)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Expiry Check ────────────────────────────────────────────────────────────
|
// ── Expiry Check ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -62,6 +62,15 @@ type AdventureCharacter struct {
|
|||||||
RobbieVisitCount int
|
RobbieVisitCount int
|
||||||
LastDeathDate string
|
LastDeathDate string
|
||||||
LastPardonUsed *time.Time
|
LastPardonUsed *time.Time
|
||||||
|
MistyLastSeen *time.Time
|
||||||
|
ArinaLastSeen *time.Time
|
||||||
|
MistyBuffExpires *time.Time
|
||||||
|
MistyDebuffExpires *time.Time
|
||||||
|
ArinaBuffExpires *time.Time
|
||||||
|
NPCMsgCount int
|
||||||
|
NPCMsgCountDate string
|
||||||
|
MistyRollTarget int
|
||||||
|
ArinaRollTarget int
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdvEquipment struct {
|
type AdvEquipment struct {
|
||||||
@@ -357,6 +366,7 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
|
|||||||
c := &AdventureCharacter{}
|
c := &AdventureCharacter{}
|
||||||
var alive, actionTaken, holidayTaken, rivalUnlocked, babysitAct int
|
var alive, actionTaken, holidayTaken, rivalUnlocked, babysitAct int
|
||||||
var deadUntil, reprieveLast, babysitExp, pardonUsed sql.NullTime
|
var deadUntil, reprieveLast, babysitExp, pardonUsed sql.NullTime
|
||||||
|
var mistyLastSeen, arinaLastSeen, mistyBuffExp, mistyDebuffExp, arinaBuffExp sql.NullTime
|
||||||
|
|
||||||
err := d.QueryRow(`
|
err := d.QueryRow(`
|
||||||
SELECT user_id, display_name,
|
SELECT user_id, display_name,
|
||||||
@@ -371,7 +381,11 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
|
|||||||
babysit_active, babysit_expires_at, babysit_skill_focus,
|
babysit_active, babysit_expires_at, babysit_skill_focus,
|
||||||
hospital_visits, robbie_visit_count, last_death_date,
|
hospital_visits, robbie_visit_count, last_death_date,
|
||||||
combat_actions_used, harvest_actions_used,
|
combat_actions_used, harvest_actions_used,
|
||||||
last_pardon_used
|
last_pardon_used,
|
||||||
|
misty_last_seen, arina_last_seen,
|
||||||
|
misty_buff_expires, misty_debuff_expires, arina_buff_expires,
|
||||||
|
npc_msg_count, npc_msg_count_date,
|
||||||
|
misty_roll_target, arina_roll_target
|
||||||
FROM adventure_characters WHERE user_id = ?`, string(userID)).Scan(
|
FROM adventure_characters WHERE user_id = ?`, string(userID)).Scan(
|
||||||
&c.UserID, &c.DisplayName,
|
&c.UserID, &c.DisplayName,
|
||||||
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
|
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
|
||||||
@@ -386,6 +400,10 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
|
|||||||
&c.HospitalVisits, &c.RobbieVisitCount, &c.LastDeathDate,
|
&c.HospitalVisits, &c.RobbieVisitCount, &c.LastDeathDate,
|
||||||
&c.CombatActionsUsed, &c.HarvestActionsUsed,
|
&c.CombatActionsUsed, &c.HarvestActionsUsed,
|
||||||
&pardonUsed,
|
&pardonUsed,
|
||||||
|
&mistyLastSeen, &arinaLastSeen,
|
||||||
|
&mistyBuffExp, &mistyDebuffExp, &arinaBuffExp,
|
||||||
|
&c.NPCMsgCount, &c.NPCMsgCountDate,
|
||||||
|
&c.MistyRollTarget, &c.ArinaRollTarget,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -407,6 +425,21 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
|
|||||||
if pardonUsed.Valid {
|
if pardonUsed.Valid {
|
||||||
c.LastPardonUsed = &pardonUsed.Time
|
c.LastPardonUsed = &pardonUsed.Time
|
||||||
}
|
}
|
||||||
|
if mistyLastSeen.Valid {
|
||||||
|
c.MistyLastSeen = &mistyLastSeen.Time
|
||||||
|
}
|
||||||
|
if arinaLastSeen.Valid {
|
||||||
|
c.ArinaLastSeen = &arinaLastSeen.Time
|
||||||
|
}
|
||||||
|
if mistyBuffExp.Valid {
|
||||||
|
c.MistyBuffExpires = &mistyBuffExp.Time
|
||||||
|
}
|
||||||
|
if mistyDebuffExp.Valid {
|
||||||
|
c.MistyDebuffExpires = &mistyDebuffExp.Time
|
||||||
|
}
|
||||||
|
if arinaBuffExp.Valid {
|
||||||
|
c.ArinaBuffExpires = &arinaBuffExp.Time
|
||||||
|
}
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,7 +508,11 @@ func saveAdvCharacter(char *AdventureCharacter) error {
|
|||||||
babysit_active = ?, babysit_expires_at = ?, babysit_skill_focus = ?,
|
babysit_active = ?, babysit_expires_at = ?, babysit_skill_focus = ?,
|
||||||
hospital_visits = ?, robbie_visit_count = ?, last_death_date = ?,
|
hospital_visits = ?, robbie_visit_count = ?, last_death_date = ?,
|
||||||
combat_actions_used = ?, harvest_actions_used = ?,
|
combat_actions_used = ?, harvest_actions_used = ?,
|
||||||
last_pardon_used = ?
|
last_pardon_used = ?,
|
||||||
|
misty_last_seen = ?, arina_last_seen = ?,
|
||||||
|
misty_buff_expires = ?, misty_debuff_expires = ?, arina_buff_expires = ?,
|
||||||
|
npc_msg_count = ?, npc_msg_count_date = ?,
|
||||||
|
misty_roll_target = ?, arina_roll_target = ?
|
||||||
WHERE user_id = ?`,
|
WHERE user_id = ?`,
|
||||||
char.DisplayName, char.CombatLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill,
|
char.DisplayName, char.CombatLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill,
|
||||||
char.CombatXP, char.MiningXP, char.ForagingXP, char.FishingXP,
|
char.CombatXP, char.MiningXP, char.ForagingXP, char.FishingXP,
|
||||||
@@ -488,6 +525,10 @@ func saveAdvCharacter(char *AdventureCharacter) error {
|
|||||||
char.HospitalVisits, char.RobbieVisitCount, char.LastDeathDate,
|
char.HospitalVisits, char.RobbieVisitCount, char.LastDeathDate,
|
||||||
char.CombatActionsUsed, char.HarvestActionsUsed,
|
char.CombatActionsUsed, char.HarvestActionsUsed,
|
||||||
char.LastPardonUsed,
|
char.LastPardonUsed,
|
||||||
|
char.MistyLastSeen, char.ArinaLastSeen,
|
||||||
|
char.MistyBuffExpires, char.MistyDebuffExpires, char.ArinaBuffExpires,
|
||||||
|
char.NPCMsgCount, char.NPCMsgCountDate,
|
||||||
|
char.MistyRollTarget, char.ArinaRollTarget,
|
||||||
string(char.UserID),
|
string(char.UserID),
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
@@ -605,7 +646,9 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
|
|||||||
masterwork_drops_received,
|
masterwork_drops_received,
|
||||||
rival_pool, rival_unlocked_notified,
|
rival_pool, rival_unlocked_notified,
|
||||||
babysit_active, babysit_expires_at, babysit_skill_focus,
|
babysit_active, babysit_expires_at, babysit_skill_focus,
|
||||||
combat_actions_used, harvest_actions_used
|
combat_actions_used, harvest_actions_used,
|
||||||
|
npc_msg_count, npc_msg_count_date,
|
||||||
|
misty_roll_target, arina_roll_target
|
||||||
FROM adventure_characters`)
|
FROM adventure_characters`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -629,6 +672,8 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
|
|||||||
&c.RivalPool, &rivalUnlocked,
|
&c.RivalPool, &rivalUnlocked,
|
||||||
&babysitAct, &babysitExp, &c.BabysitSkillFocus,
|
&babysitAct, &babysitExp, &c.BabysitSkillFocus,
|
||||||
&c.CombatActionsUsed, &c.HarvestActionsUsed,
|
&c.CombatActionsUsed, &c.HarvestActionsUsed,
|
||||||
|
&c.NPCMsgCount, &c.NPCMsgCountDate,
|
||||||
|
&c.MistyRollTarget, &c.ArinaRollTarget,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
85
internal/plugin/adventure_flavor_npcs.go
Normal file
85
internal/plugin/adventure_flavor_npcs.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
// ── Misty — Opening Lines ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var mistyOpenings = []string{
|
||||||
|
"Excuse me. I'm sorry to bother you. I don't usually ask but -- do you have any gold to spare? Even a little would help.",
|
||||||
|
"I hate to stop you. I can see you're busy. I just -- do you have any gold? It doesn't have to be much. Anything at all.",
|
||||||
|
"Sorry. I'm so sorry. I know this is awkward. Do you have 100 gold you could spare? I wouldn't ask if I didn't need to.",
|
||||||
|
"You look like a kind person. I hope you are. Do you have any gold? Just 100. I'll remember it.",
|
||||||
|
"I don't usually do this. I want you to know that. Do you have 100 gold? I'll be out of your way in a moment either way.",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Misty — Accept Lines ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var mistyAcceptLines = []string{
|
||||||
|
"Oh. Thank you. Really. You didn't have to do that and you did. That means more than you know. Good luck out there.",
|
||||||
|
"Thank you so much. I mean it. You're a good person. I hope the arena treats you well today.",
|
||||||
|
"You're very kind. I won't forget it. Thank you. Truly. Go on -- I've kept you long enough.",
|
||||||
|
"Oh, thank you. Thank you. I hope something wonderful happens to you today. I really do.",
|
||||||
|
"That's -- thank you. You didn't have to. I hope you know that mattered. Go show them what you've got.",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Misty — Decline ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const mistyDeclineLine = "She nods once and walks away. She doesn't look back."
|
||||||
|
|
||||||
|
// ── Arina — Opening Lines ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var arinaOpenings = []string{
|
||||||
|
"You. Yes, you. I've been watching your arena performances and I've decided -- against my better judgment -- to take an interest. 5,000 gold. I'll make it worth your while. Don't embarrass me.",
|
||||||
|
"I'll be brief because my time is valuable and yours demonstrably isn't. 5,000 gold. I have a sniper I'm not currently using. We could have an arrangement. Or not. It's entirely your loss.",
|
||||||
|
"I've decided you have potential. It pains me to admit it. 5,000 gold and I'll have someone watch your fights. Professionally. Do try to look capable when you answer.",
|
||||||
|
"You look like someone who could use help. Most people in your position do. 5,000 gold. I have resources. You have need. This is called an arrangement. Say yes and try not to gloat about it.",
|
||||||
|
"Don't make that face. I'm offering you something. 5,000 gold and a week of professional support in the arena. The alternative is continuing as you have been, which I think we both find embarrassing.",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Arina — Accept ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const arinaAcceptLine = "Where's my thank you? Someone of my stature is accepting your dirty peasant money."
|
||||||
|
|
||||||
|
// ── Arina — Decline Lines ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var arinaDeclineLines = []string{
|
||||||
|
"Remarkable. You've somehow managed to be both broke and stupid. A rare combination. Enjoy your mediocrity.",
|
||||||
|
"I see. You'd rather fail on your own terms. How quaint. How completely, utterly quaint.",
|
||||||
|
"Fine. Go back to whatever it is you do. I'll find someone with half a brain and twice the ambition. Shouldn't be hard.",
|
||||||
|
"I offered you a lifeline and you looked at it and said no. I want you to think about that. Later, when it's relevant. And it will be relevant.",
|
||||||
|
"No. You said no. To me. I'll remember that. Not out of spite -- I'm above spite -- but because it's funny, and I collect funny things.",
|
||||||
|
"You're turning down professional assistance because -- what exactly? Pride? You can't afford pride. You can barely afford that equipment.",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gourmet Food Pool (Misty buff — arena) ─────────────────────────────────
|
||||||
|
|
||||||
|
var mistyGourmetFoodLines = []string{
|
||||||
|
"The crowd has thrown a Seared Foie Gras with Fig Reduction at you. You catch it without thinking. It is perfect. You eat it in the arena. {enemy} stops moving for a moment. {enemy} takes 5 damage.",
|
||||||
|
"Someone in the upper tier has thrown a Wagyu Beef Tartare with Truffle Oil at you. You eat it immediately and without shame. {enemy} watches this happen. {enemy} takes 5 damage. {enemy} is reconsidering the fight.",
|
||||||
|
"A Lobster Bisque in a warmed ceramic bowl lands in your hands. You drink it. The crowd roars. {enemy} takes 5 damage from witnessing this.",
|
||||||
|
"The crowd has provided a Tasting Menu Amuse-Bouche. Three bites. All perfect. You finish it in four seconds. {enemy} takes 5 damage. {enemy} does not understand what is happening.",
|
||||||
|
"Someone throws a hand-rolled Truffle Pasta at you. You eat it like a feral animal. It heals you. {enemy} takes 5 damage and briefly forgets what they were doing.",
|
||||||
|
"A Deconstructed Beef Wellington lands nearby. You eat the components separately and in the wrong order. It is still extraordinary. {enemy} takes 5 damage.",
|
||||||
|
"The crowd has thrown a Michelin-starred Tuna Tataki at you. You catch it, eat it in one motion, and keep moving. {enemy} takes 5 damage. {enemy} will think about this later.",
|
||||||
|
"Someone in the crowd has thrown a perfectly tempered Chocolate Fondant with Salted Caramel at you mid-fight. You eat it immediately. {enemy} takes 5 damage from the indignity of the situation.",
|
||||||
|
"A Burrata with Heirloom Tomatoes and Aged Balsamic lands at your feet. You eat it off the arena floor without hesitation. The crowd erupts. {enemy} takes 5 damage.",
|
||||||
|
"The crowd throws a Saffron Risotto at you. It is warm. It is perfectly seasoned. You eat it with your hands. {enemy} takes 5 damage. {enemy} files this moment away somewhere dark.",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Crowd Revenge Pool (Misty debuff — arena) ──────────────────────────────
|
||||||
|
|
||||||
|
var mistyCrowdRevengeLines = []string{
|
||||||
|
"The crowd has remembered something. They're booing. Something has been thrown. It hits you. {damage} damage. The arena has a long memory.",
|
||||||
|
"Someone in the crowd throws something that is definitely not food. {damage} damage. The booing intensifies.",
|
||||||
|
"The arena crowd has opinions about you specifically today. Something lands. {damage} damage. You don't see where it came from. You don't need to.",
|
||||||
|
"The crowd is restless. An object arrives from the upper tier. {damage} damage. The booing is organized. That's somehow worse.",
|
||||||
|
"Something hits you from the stands. {damage} damage. The crowd is not finished. The crowd is never finished.",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sniper Log Lines (Arina buff — arena) ──────────────────────────────────
|
||||||
|
|
||||||
|
var arinaSniperLines = []string{
|
||||||
|
"Something arrives from the upper tier. {enemy} doesn't finish their round.",
|
||||||
|
"A bolt. One bolt. {enemy} is down. Nobody in the crowd reacts. They saw nothing.",
|
||||||
|
"The shot comes from somewhere in the stands. {enemy} had a bad week. It ended here.",
|
||||||
|
"From the upper tier: one shot. {enemy} goes down. The fight continues without them.",
|
||||||
|
"The arena lights caught it briefly. Just briefly. {enemy} is down.",
|
||||||
|
}
|
||||||
360
internal/plugin/adventure_npcs.go
Normal file
360
internal/plugin/adventure_npcs.go
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math/rand/v2"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── NPC Constants ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const (
|
||||||
|
mistyCost = 100
|
||||||
|
arinaCost = 5000
|
||||||
|
npcCooldownDays = 7
|
||||||
|
npcEncounterChance = 0.075 // 7.5%
|
||||||
|
npcBuffDuration = 7 * 24 * time.Hour
|
||||||
|
|
||||||
|
// Arena effect chances per round
|
||||||
|
mistyEffectChance = 0.20 // 20%
|
||||||
|
arinaEffectChance = 0.08 // 8%
|
||||||
|
|
||||||
|
// Misty crowd revenge damage range
|
||||||
|
crowdRevengeDmgMin = 3
|
||||||
|
crowdRevengeDmgMax = 8
|
||||||
|
|
||||||
|
// Misty gourmet food — enemy damage
|
||||||
|
gourmetEnemyDmg = 5
|
||||||
|
// Misty gourmet food — equipment condition repair
|
||||||
|
gourmetConditionRepair = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Message Count Tracking ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// npcTrackMessage is called on every room message from an adventure player.
|
||||||
|
// It increments their daily message count and fires NPC encounter rolls
|
||||||
|
// when thresholds are hit.
|
||||||
|
func (p *AdventurePlugin) npcTrackMessage(userID id.UserID) {
|
||||||
|
char, err := loadAdvCharacter(userID)
|
||||||
|
if err != nil || !char.Alive {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
|
||||||
|
// Reset count if it's a new day
|
||||||
|
if char.NPCMsgCountDate != today {
|
||||||
|
char.NPCMsgCount = 0
|
||||||
|
char.NPCMsgCountDate = today
|
||||||
|
char.MistyRollTarget = 5 + rand.IntN(6) // 5–10
|
||||||
|
char.ArinaRollTarget = 5 + rand.IntN(6) // 5–10
|
||||||
|
}
|
||||||
|
|
||||||
|
char.NPCMsgCount++
|
||||||
|
|
||||||
|
// Check Misty threshold
|
||||||
|
if char.MistyRollTarget > 0 && char.NPCMsgCount == char.MistyRollTarget {
|
||||||
|
if p.npcShouldEncounter(char, "misty") {
|
||||||
|
// Zero the target so we don't re-trigger
|
||||||
|
char.MistyRollTarget = 0
|
||||||
|
if err := saveAdvCharacter(char); err != nil {
|
||||||
|
slog.Error("npc: failed to save char after misty trigger", "user", userID, "err", err)
|
||||||
|
}
|
||||||
|
safeGo("npc-misty-encounter", func() {
|
||||||
|
p.npcFireEncounter(userID, "misty")
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Arina threshold
|
||||||
|
if char.ArinaRollTarget > 0 && char.NPCMsgCount == char.ArinaRollTarget {
|
||||||
|
if p.npcShouldEncounter(char, "arina") {
|
||||||
|
char.ArinaRollTarget = 0
|
||||||
|
if err := saveAdvCharacter(char); err != nil {
|
||||||
|
slog.Error("npc: failed to save char after arina trigger", "user", userID, "err", err)
|
||||||
|
}
|
||||||
|
safeGo("npc-arina-encounter", func() {
|
||||||
|
p.npcFireEncounter(userID, "arina")
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save updated count
|
||||||
|
if err := saveAdvCharacter(char); err != nil {
|
||||||
|
slog.Error("npc: failed to save msg count", "user", userID, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// npcShouldEncounter checks cooldown and rolls the 7.5% chance.
|
||||||
|
func (p *AdventurePlugin) npcShouldEncounter(char *AdventureCharacter, npc string) bool {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
cooldown := npcCooldownDays * 24 * time.Hour
|
||||||
|
|
||||||
|
switch npc {
|
||||||
|
case "misty":
|
||||||
|
if char.MistyLastSeen != nil && now.Sub(*char.MistyLastSeen) < cooldown {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
case "arina":
|
||||||
|
if char.ArinaLastSeen != nil && now.Sub(*char.ArinaLastSeen) < cooldown {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rand.Float64() < npcEncounterChance
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Encounter Fire ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) npcFireEncounter(userID id.UserID, npc string) {
|
||||||
|
userMu := p.advUserLock(userID)
|
||||||
|
userMu.Lock()
|
||||||
|
defer userMu.Unlock()
|
||||||
|
|
||||||
|
char, err := loadAdvCharacter(userID)
|
||||||
|
if err != nil || !char.Alive {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
var opening, prompt string
|
||||||
|
switch npc {
|
||||||
|
case "misty":
|
||||||
|
char.MistyLastSeen = &now
|
||||||
|
opening = mistyOpenings[rand.IntN(len(mistyOpenings))]
|
||||||
|
prompt = fmt.Sprintf("👤 A woman approaches you.\n\n_%s_\n\n"+
|
||||||
|
"Reply `yes` to give €%d, or `no` to walk away.", opening, mistyCost)
|
||||||
|
case "arina":
|
||||||
|
char.ArinaLastSeen = &now
|
||||||
|
opening = arinaOpenings[rand.IntN(len(arinaOpenings))]
|
||||||
|
prompt = fmt.Sprintf("💎 A woman in expensive clothing stops you.\n\n_%s_\n\n"+
|
||||||
|
"Reply `yes` to pay €%d, or `no` to decline.", opening, arinaCost)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := saveAdvCharacter(char); err != nil {
|
||||||
|
slog.Error("npc: failed to save last_seen", "user", userID, "npc", npc, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set pending interaction
|
||||||
|
p.pending.Store(string(userID), &advPendingInteraction{
|
||||||
|
Type: "npc_encounter",
|
||||||
|
Data: npc,
|
||||||
|
ExpiresAt: time.Now().Add(5 * time.Minute),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := p.SendDM(userID, prompt); err != nil {
|
||||||
|
slog.Error("npc: failed to send encounter DM", "user", userID, "npc", npc, "err", err)
|
||||||
|
p.pending.Delete(string(userID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Accept / Decline Resolution ────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) resolveNPCEncounter(ctx MessageContext, interaction *advPendingInteraction) error {
|
||||||
|
npc := interaction.Data.(string)
|
||||||
|
body := strings.ToLower(strings.TrimSpace(ctx.Body))
|
||||||
|
|
||||||
|
accepted := body == "yes" || body == "y" || body == "pay"
|
||||||
|
declined := body == "no" || body == "n" || body == "decline"
|
||||||
|
|
||||||
|
if !accepted && !declined {
|
||||||
|
// Re-store the pending interaction — they typed something else
|
||||||
|
p.pending.Store(string(ctx.Sender), interaction)
|
||||||
|
return p.SendDM(ctx.Sender, "Reply `yes` or `no`.")
|
||||||
|
}
|
||||||
|
|
||||||
|
userMu := p.advUserLock(ctx.Sender)
|
||||||
|
userMu.Lock()
|
||||||
|
defer userMu.Unlock()
|
||||||
|
|
||||||
|
char, err := loadAdvCharacter(ctx.Sender)
|
||||||
|
if err != nil {
|
||||||
|
return p.SendDM(ctx.Sender, "Something went wrong loading your character.")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
expires := now.Add(npcBuffDuration)
|
||||||
|
|
||||||
|
switch npc {
|
||||||
|
case "misty":
|
||||||
|
return p.resolveMisty(ctx, char, accepted, &expires)
|
||||||
|
case "arina":
|
||||||
|
return p.resolveArina(ctx, char, accepted, &expires)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) resolveMisty(ctx MessageContext, char *AdventureCharacter, accepted bool, expires *time.Time) error {
|
||||||
|
if accepted {
|
||||||
|
balance := p.euro.GetBalance(ctx.Sender)
|
||||||
|
if balance < float64(mistyCost) {
|
||||||
|
// Can't afford it — treat as decline but with a kinder message
|
||||||
|
char.MistyDebuffExpires = expires
|
||||||
|
if err := saveAdvCharacter(char); err != nil {
|
||||||
|
slog.Error("npc: failed to save misty debuff", "user", ctx.Sender, "err", err)
|
||||||
|
}
|
||||||
|
return p.SendDM(ctx.Sender, "You reach for your gold but there isn't enough. She sees this. She doesn't say anything.\n\n_"+mistyDeclineLine+"_")
|
||||||
|
}
|
||||||
|
|
||||||
|
p.euro.Debit(ctx.Sender, float64(mistyCost), "misty_donation")
|
||||||
|
char.MistyBuffExpires = expires
|
||||||
|
if err := saveAdvCharacter(char); err != nil {
|
||||||
|
slog.Error("npc: failed to save misty buff", "user", ctx.Sender, "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reply := mistyAcceptLines[rand.IntN(len(mistyAcceptLines))]
|
||||||
|
return p.SendDM(ctx.Sender, fmt.Sprintf("_%s_", reply))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declined
|
||||||
|
char.MistyDebuffExpires = expires
|
||||||
|
if err := saveAdvCharacter(char); err != nil {
|
||||||
|
slog.Error("npc: failed to save misty debuff", "user", ctx.Sender, "err", err)
|
||||||
|
}
|
||||||
|
return p.SendDM(ctx.Sender, fmt.Sprintf("_%s_", mistyDeclineLine))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) resolveArina(ctx MessageContext, char *AdventureCharacter, accepted bool, expires *time.Time) error {
|
||||||
|
if accepted {
|
||||||
|
balance := p.euro.GetBalance(ctx.Sender)
|
||||||
|
if balance < float64(arinaCost) {
|
||||||
|
reply := arinaDeclineLines[rand.IntN(len(arinaDeclineLines))]
|
||||||
|
return p.SendDM(ctx.Sender, fmt.Sprintf("You can't afford it. She noticed before you did.\n\n_%s_", reply))
|
||||||
|
}
|
||||||
|
|
||||||
|
p.euro.Debit(ctx.Sender, float64(arinaCost), "arina_investment")
|
||||||
|
char.ArinaBuffExpires = expires
|
||||||
|
if err := saveAdvCharacter(char); err != nil {
|
||||||
|
slog.Error("npc: failed to save arina buff", "user", ctx.Sender, "err", err)
|
||||||
|
}
|
||||||
|
return p.SendDM(ctx.Sender, fmt.Sprintf("_%s_", arinaAcceptLine))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declined — no mechanical effect, just insults
|
||||||
|
reply := arinaDeclineLines[rand.IntN(len(arinaDeclineLines))]
|
||||||
|
return p.SendDM(ctx.Sender, fmt.Sprintf("_%s_", reply))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Arena Effect Checks ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// npcArenaEffects is called during arena round resolution, after the normal
|
||||||
|
// combat roll but before the round log is written.
|
||||||
|
// Returns: extra text to append to round log, enemy HP modifier, player damage taken.
|
||||||
|
type npcArenaResult struct {
|
||||||
|
Text string
|
||||||
|
EnemyDmg int // damage dealt to enemy
|
||||||
|
PlayerDmg int // damage dealt to player
|
||||||
|
SniperKill bool // enemy instant kill
|
||||||
|
CondRepair int // equipment condition repair amount
|
||||||
|
}
|
||||||
|
|
||||||
|
func npcCheckArenaEffects(char *AdventureCharacter, monsterName string) *npcArenaResult {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
result := &npcArenaResult{}
|
||||||
|
fired := false
|
||||||
|
|
||||||
|
// Step 2: Misty debuff — crowd revenge (20%)
|
||||||
|
if char.MistyDebuffExpires != nil && now.Before(*char.MistyDebuffExpires) {
|
||||||
|
if rand.Float64() < mistyEffectChance {
|
||||||
|
dmg := crowdRevengeDmgMin + rand.IntN(crowdRevengeDmgMax-crowdRevengeDmgMin+1)
|
||||||
|
line := mistyCrowdRevengeLines[rand.IntN(len(mistyCrowdRevengeLines))]
|
||||||
|
line = strings.ReplaceAll(line, "{damage}", fmt.Sprintf("%d", dmg))
|
||||||
|
result.Text += "\n\n" + line
|
||||||
|
result.PlayerDmg = dmg
|
||||||
|
fired = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Misty buff — gourmet food (20%)
|
||||||
|
if char.MistyBuffExpires != nil && now.Before(*char.MistyBuffExpires) {
|
||||||
|
if rand.Float64() < mistyEffectChance {
|
||||||
|
line := mistyGourmetFoodLines[rand.IntN(len(mistyGourmetFoodLines))]
|
||||||
|
line = strings.ReplaceAll(line, "{enemy}", monsterName)
|
||||||
|
result.Text += "\n\n" + line
|
||||||
|
result.EnemyDmg = gourmetEnemyDmg
|
||||||
|
result.CondRepair = gourmetConditionRepair
|
||||||
|
fired = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Arina buff — sniper (8%)
|
||||||
|
if char.ArinaBuffExpires != nil && now.Before(*char.ArinaBuffExpires) {
|
||||||
|
if rand.Float64() < arinaEffectChance {
|
||||||
|
line := arinaSniperLines[rand.IntN(len(arinaSniperLines))]
|
||||||
|
line = strings.ReplaceAll(line, "{enemy}", monsterName)
|
||||||
|
result.Text += "\n\n" + line
|
||||||
|
result.SniperKill = true
|
||||||
|
fired = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !fired {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Equipment Repair (Gourmet Food) ────────────────────────────────────────
|
||||||
|
|
||||||
|
// npcRepairMostDamaged heals condition on the player's most damaged equipment slot.
|
||||||
|
func npcRepairMostDamaged(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment, amount int) {
|
||||||
|
if len(equip) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find most damaged slot
|
||||||
|
var worstSlot EquipmentSlot
|
||||||
|
worstCondition := 101
|
||||||
|
for slot, eq := range equip {
|
||||||
|
if eq.Condition < worstCondition {
|
||||||
|
worstCondition = eq.Condition
|
||||||
|
worstSlot = slot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if worstCondition >= 100 {
|
||||||
|
return // nothing to repair
|
||||||
|
}
|
||||||
|
|
||||||
|
newCond := worstCondition + amount
|
||||||
|
if newCond > 100 {
|
||||||
|
newCond = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
_, err := d.Exec(`UPDATE adventure_equipment SET condition = ? WHERE user_id = ? AND slot = ?`,
|
||||||
|
newCond, string(userID), string(worstSlot))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("npc: failed to repair equipment", "user", userID, "slot", worstSlot, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Midnight Reset ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// npcMidnightReset resets message counts and regenerates roll targets.
|
||||||
|
// Called from midnightReset. Uses a direct DB update to avoid loading/saving
|
||||||
|
// every character individually.
|
||||||
|
func npcMidnightReset() {
|
||||||
|
d := db.Get()
|
||||||
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
_, err := d.Exec(`
|
||||||
|
UPDATE adventure_characters
|
||||||
|
SET npc_msg_count = 0,
|
||||||
|
npc_msg_count_date = ?,
|
||||||
|
misty_roll_target = ABS(RANDOM()) % 6 + 5,
|
||||||
|
arina_roll_target = ABS(RANDOM()) % 6 + 5`,
|
||||||
|
today)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("npc: midnight reset failed", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ func (p *AdventurePlugin) robbieTicker() {
|
|||||||
slog.Info("adventure: robbie target hour set", "hour", robbieTargetHour, "date", dateKey)
|
slog.Info("adventure: robbie target hour set", "hour", robbieTargetHour, "date", dateKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
if now.Hour() != robbieTargetHour || now.Minute() != 0 {
|
if now.Hour() < robbieTargetHour {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,15 +180,18 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string
|
|||||||
func robbieQualifyingItems(inv []AdvItem, equip map[EquipmentSlot]*AdvEquipment) []AdvItem {
|
func robbieQualifyingItems(inv []AdvItem, equip map[EquipmentSlot]*AdvEquipment) []AdvItem {
|
||||||
var result []AdvItem
|
var result []AdvItem
|
||||||
for _, item := range inv {
|
for _, item := range inv {
|
||||||
// Only gear with a slot (skip ores, wood, fruit, gems, fish, etc.)
|
|
||||||
if item.Slot == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Never touch Arena gear or cards
|
// Never touch Arena gear or cards
|
||||||
if item.Type == "ArenaGear" || item.Type == "card" {
|
if item.Type == "ArenaGear" || item.Type == "card" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Non-gear items (ores, fish, junk, treasure, etc.) — always take
|
||||||
|
if item.Slot == "" {
|
||||||
|
result = append(result, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slotted gear — check against equipped piece
|
||||||
eq, hasSlot := equip[item.Slot]
|
eq, hasSlot := equip[item.Slot]
|
||||||
if !hasSlot {
|
if !hasSlot {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -277,15 +277,18 @@ func (p *AdventurePlugin) midnightTicker() {
|
|||||||
ticker := time.NewTicker(1 * time.Minute)
|
ticker := time.NewTicker(1 * time.Minute)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
lastRanDate := ""
|
||||||
|
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
now := time.Now().UTC()
|
dateKey := time.Now().UTC().Format("2006-01-02")
|
||||||
if now.Hour() != 0 || now.Minute() != 0 {
|
if dateKey == lastRanDate {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
dateKey := now.Format("2006-01-02")
|
// New UTC day — check DB in case we already ran (e.g. bot restart).
|
||||||
jobName := "adventure_midnight"
|
jobName := "adventure_midnight"
|
||||||
if db.JobCompleted(jobName, dateKey) {
|
if db.JobCompleted(jobName, dateKey) {
|
||||||
|
lastRanDate = dateKey
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,6 +298,7 @@ func (p *AdventurePlugin) midnightTicker() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
db.MarkJobCompleted(jobName, dateKey)
|
db.MarkJobCompleted(jobName, dateKey)
|
||||||
|
lastRanDate = dateKey
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,14 +313,11 @@ func (p *AdventurePlugin) midnightReset() error {
|
|||||||
|
|
||||||
dmsSent := 0
|
dmsSent := 0
|
||||||
for _, char := range chars {
|
for _, char := range chars {
|
||||||
// Dead players freeze their streak — death is involuntary, don't punish it.
|
|
||||||
if !char.Alive {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !char.HasActedToday() {
|
if !char.HasActedToday() {
|
||||||
// If the player died today (or yesterday — covering late-night deaths
|
// If the player died today or yesterday, they couldn't act — no shame,
|
||||||
// that span midnight), grant a grace period: no shame, no streak reset.
|
// no streak reset. This covers both currently-dead players and players
|
||||||
|
// who were just revived at midnight (Alive already flipped to true by
|
||||||
|
// the reminder loop before midnightReset runs).
|
||||||
if char.LastDeathDate == today ||
|
if char.LastDeathDate == today ||
|
||||||
char.LastDeathDate == time.Now().UTC().Add(-24*time.Hour).Format("2006-01-02") {
|
char.LastDeathDate == time.Now().UTC().Add(-24*time.Hour).Format("2006-01-02") {
|
||||||
continue
|
continue
|
||||||
@@ -374,6 +375,9 @@ func (p *AdventurePlugin) midnightReset() error {
|
|||||||
slog.Error("adventure: failed to prune expired buffs", "err", err)
|
slog.Error("adventure: failed to prune expired buffs", "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset NPC message counts and regenerate roll targets
|
||||||
|
npcMidnightReset()
|
||||||
|
|
||||||
// Clear flavor history to prevent unbounded memory growth.
|
// Clear flavor history to prevent unbounded memory growth.
|
||||||
// Entries are only used for dedup within a day, so clearing at midnight is fine.
|
// Entries are only used for dedup within a day, so clearing at midnight is fine.
|
||||||
advClearFlavorHistory()
|
advClearFlavorHistory()
|
||||||
|
|||||||
@@ -127,6 +127,35 @@ func TestAdvIsEligible_NoPenalty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdvIsEligible_BonusesDontUnlockTiers(t *testing.T) {
|
||||||
|
char := &AdventureCharacter{MiningSkill: 1}
|
||||||
|
equip := map[EquipmentSlot]*AdvEquipment{}
|
||||||
|
loc := &AdvLocation{Activity: AdvActivityMining, MinLevel: 8, MinEquipTier: 0}
|
||||||
|
bonuses := &AdvBonusSummary{MiningBonus: 10} // +10 would make effective 11, but base is 1
|
||||||
|
|
||||||
|
eligible, _ := advIsEligible(char, equip, loc, bonuses)
|
||||||
|
if eligible {
|
||||||
|
t.Error("bonuses should not unlock tiers — base mining 1 should not access min level 8")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdvIsEligible_BonusesDontAffectPenaltyZone(t *testing.T) {
|
||||||
|
char := &AdventureCharacter{CombatLevel: 21} // base 21, min 20 → penalty (21-20=1 < 3)
|
||||||
|
equip := map[EquipmentSlot]*AdvEquipment{
|
||||||
|
SlotWeapon: {Tier: 2, Condition: 100},
|
||||||
|
}
|
||||||
|
loc := &AdvLocation{Activity: AdvActivityDungeon, MinLevel: 20, MinEquipTier: 0}
|
||||||
|
bonuses := &AdvBonusSummary{CombatBonus: 10} // +10 would make effective 31, but penalty uses base
|
||||||
|
|
||||||
|
eligible, penalty := advIsEligible(char, equip, loc, bonuses)
|
||||||
|
if !eligible {
|
||||||
|
t.Error("should be eligible at base combat 21 for min 20")
|
||||||
|
}
|
||||||
|
if !penalty {
|
||||||
|
t.Error("should still be in penalty zone — bonuses don't affect penalty calculation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCalculateAdvProbabilities_SumsTo100(t *testing.T) {
|
func TestCalculateAdvProbabilities_SumsTo100(t *testing.T) {
|
||||||
char := &AdventureCharacter{CombatLevel: 10}
|
char := &AdventureCharacter{CombatLevel: 10}
|
||||||
equip := map[EquipmentSlot]*AdvEquipment{
|
equip := map[EquipmentSlot]*AdvEquipment{
|
||||||
|
|||||||
Reference in New Issue
Block a user