diff --git a/internal/db/db.go b/internal/db/db.go index ec6bf47..3323071 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -100,6 +100,15 @@ func runMigrations(d *sql.DB) error { `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 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 { if _, err := d.Exec(stmt); err != nil { diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index b187099..e2fb776 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -186,7 +186,14 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error { 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") { return nil } @@ -559,6 +566,8 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact return p.resolveShopConfirm(ctx, interaction) case "hospital_pay": return p.resolveHospitalPay(ctx, interaction) + case "npc_encounter": + return p.resolveNPCEncounter(ctx, interaction) } return nil } diff --git a/internal/plugin/adventure_activities.go b/internal/plugin/adventure_activities.go index 1013d21..c478d18 100644 --- a/internal/plugin/adventure_activities.go +++ b/internal/plugin/adventure_activities.go @@ -130,7 +130,7 @@ func findAdvLocationByTier(activity AdvActivityType, tier int) *AdvLocation { // ── 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}}, + 1: {{"Copper Coins", "treasure", 5, 12}, {"Rat Pelt", "treasure", 8, 18}, {"Mouldy Bread", "treasure", 3, 8}, {"Bent Nail", "treasure", 2, 6}}, 2: {{"Iron Scraps", "ore", 20, 40}, {"Goblin Trinket", "treasure", 25, 50}, {"Small Gem", "gem", 40, 80}}, 3: {{"Silver Bar", "ore", 100, 200}, {"Ancient Artifact", "treasure", 150, 300}, {"Quality Gem", "gem", 200, 400}}, 4: {{"Gold Ingot", "ore", 500, 1000}, {"Enchanted Fragment", "treasure", 800, 1500}, {"Rare Gem", "gem", 1000, 2000}}, @@ -138,7 +138,7 @@ var advDungeonLoot = 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}}, 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}}, @@ -146,7 +146,7 @@ var advMiningLoot = 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}}, 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}}, @@ -154,7 +154,7 @@ var advForagingLoot = 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}}, 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}}, @@ -376,10 +376,9 @@ func advLocationCooldown(userID id.UserID, location string) time.Duration { // 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 { + // Tier gating uses base skill only — buffs improve success chances, not access. + baseLevel := advBaseSkill(char, loc.Activity) + if baseLevel < loc.MinLevel { return false, false } @@ -397,11 +396,25 @@ func advIsEligible(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipme return false, false } - // Penalty zone: within 3 levels of minimum - penalty := skillLevel-loc.MinLevel < 3 + // Penalty zone: within 3 levels of minimum (base skill only) + penalty := baseLevel-loc.MinLevel < 3 return true, penalty } +func advBaseSkill(char *AdventureCharacter, activity AdvActivityType) int { + switch activity { + case AdvActivityDungeon: + return char.CombatLevel + case AdvActivityMining: + return char.MiningSkill + case AdvActivityForaging: + return char.ForagingSkill + case AdvActivityFishing: + return char.FishingSkill + } + return 1 +} + func advEffectiveSkill(char *AdventureCharacter, activity AdvActivityType, bonuses *AdvBonusSummary) int { switch activity { case AdvActivityDungeon: diff --git a/internal/plugin/adventure_arena.go b/internal/plugin/adventure_arena.go index 249a7f1..0513ae1 100644 --- a/internal/plugin/adventure_arena.go +++ b/internal/plugin/adventure_arena.go @@ -148,11 +148,22 @@ func (p *AdventurePlugin) handleArenaFight(ctx MessageContext) error { closeness := 1.0 - math.Abs(roll-deathChance)/math.Max(deathChance, 1-deathChance) 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 { + // 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.resolveArenaSurvival(ctx, run, char, tier, monster, combatLog) + return p.resolveArenaSurvival(ctx, run, char, tier, monster, combatLog, npcResult) } 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) combatLog := generateArenaCombatLog(!died, closeness) + npcResult := npcCheckArenaEffects(char, monster.Name) + if npcResult != nil && npcResult.SniperKill { + died = false + } + if died { + if npcResult != nil && npcResult.Text != "" { + combatLog.NPCText = npcResult.Text + } 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 { @@ -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 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 reward := arenaRoundReward(tier, run.Round, char.CombatLevel) run.TierEarnings += reward @@ -305,6 +324,16 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun // Build survival message with combat log closer := arenaWinCloser(monster.Name, len(combatLog.Rounds)) 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", run.TierEarnings, run.Earnings+run.TierEarnings) diff --git a/internal/plugin/adventure_arena_combat.go b/internal/plugin/adventure_arena_combat.go index 479bfec..f1fadcd 100644 --- a/internal/plugin/adventure_arena_combat.go +++ b/internal/plugin/adventure_arena_combat.go @@ -13,6 +13,7 @@ type ArenaCombatLog struct { PlayerHP int EnemyHP int PlayerWon bool + NPCText string // Misty/Arina effect text, appended to round log } 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)) } + if log.NPCText != "" { + sb.WriteString(log.NPCText) + sb.WriteString("\n") + } + return sb.String() } diff --git a/internal/plugin/adventure_babysit.go b/internal/plugin/adventure_babysit.go index 501bce2..f811f31 100644 --- a/internal/plugin/adventure_babysit.go +++ b/internal/plugin/adventure_babysit.go @@ -220,29 +220,62 @@ func (p *AdventurePlugin) handleBabysitCancel(ctx MessageContext) error { // ── Daily Auto-Resolution ─────────────────────────────────────────────────── func (p *AdventurePlugin) runBabysitDaily(char *AdventureCharacter) { - activity := skillToActivity(char.BabysitSkillFocus) - equip, err := loadAdvEquipment(char.UserID) if err != nil { slog.Error("babysit: failed to load equipment", "user", char.UserID, "err", err) return } - // Pick highest-tier eligible location for the focus skill + isHol, _ := isHolidayToday() + harvestMax := maxHarvestActions + if isHol { + harvestMax++ + } + bonuses := &AdvBonusSummary{} + focusActivity := skillToActivity(char.BabysitSkillFocus) + + var totalGold int + var totalXP int + var allItems []string + + // Use all harvest actions on the focused skill — no combat, too dangerous + for char.HarvestActionsUsed < harvestMax { + gold, xp, items := p.runBabysitAction(char, equip, focusActivity, bonuses) + totalGold += gold + totalXP += xp + allItems = append(allItems, items...) + char.HarvestActionsUsed++ + } + + char.ActionTakenToday = true + char.LastActionDate = time.Now().UTC().Format("2006-01-02") + + if err := saveAdvCharacter(char); err != nil { + slog.Error("babysit: failed to save character after daily", "user", char.UserID, "err", err) + } + + // Log combined daily totals + itemsJSON := "" + if len(allItems) > 0 { + itemsJSON = strings.Join(allItems, ", ") + } + logBabysitActivity(char.UserID, string(focusActivity), "babysit_daily", + totalGold, totalXP, itemsJSON) +} + +// runBabysitAction resolves a single action for the babysitter. Returns gold, xp, item names. +func (p *AdventurePlugin) runBabysitAction(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, activity AdvActivityType, bonuses *AdvBonusSummary) (int, int, []string) { eligible := advEligibleLocations(char, equip, activity, bonuses) if len(eligible) == 0 { - slog.Warn("babysit: no eligible locations", "user", char.UserID, "skill", char.BabysitSkillFocus) - return + return 0, 0, nil } - // Pick the last one (highest tier since they're returned in order) loc := eligible[len(eligible)-1].Location inPenalty := eligible[len(eligible)-1].InPenaltyZone - // Resolve action 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 { result.Outcome = AdvOutcomeEmpty result.LootItems = nil @@ -253,6 +286,8 @@ func (p *AdventurePlugin) runBabysitDaily(char *AdventureCharacter) { // Apply XP switch result.XPSkill { + case "combat": + char.CombatXP += result.XPGained case "mining": char.MiningXP += result.XPGained case "foraging": @@ -262,43 +297,19 @@ func (p *AdventurePlugin) runBabysitDaily(char *AdventureCharacter) { } checkAdvLevelUp(char, result.XPSkill) - // Credit gold to player + // Credit gold if result.TotalLootValue > 0 { 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 result.TreasureFound = nil - // Mark action taken (babysit always uses a harvest action) - isHol, _ := isHolidayToday() - harvestMax := maxHarvestActions - if isHol { - harvestMax++ + var items []string + for _, item := range result.LootItems { + items = append(items, item.Name) } - if char.HarvestActionsUsed < harvestMax { - 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) + return int(result.TotalLootValue), result.XPGained, items } // ── Expiry Check ──────────────────────────────────────────────────────────── diff --git a/internal/plugin/adventure_character.go b/internal/plugin/adventure_character.go index a827812..18966d7 100644 --- a/internal/plugin/adventure_character.go +++ b/internal/plugin/adventure_character.go @@ -62,6 +62,15 @@ type AdventureCharacter struct { RobbieVisitCount int LastDeathDate string 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 { @@ -357,6 +366,7 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) { c := &AdventureCharacter{} var alive, actionTaken, holidayTaken, rivalUnlocked, babysitAct int var deadUntil, reprieveLast, babysitExp, pardonUsed sql.NullTime + var mistyLastSeen, arinaLastSeen, mistyBuffExp, mistyDebuffExp, arinaBuffExp sql.NullTime err := d.QueryRow(` SELECT user_id, display_name, @@ -371,7 +381,11 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) { babysit_active, babysit_expires_at, babysit_skill_focus, hospital_visits, robbie_visit_count, last_death_date, 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( &c.UserID, &c.DisplayName, &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.CombatActionsUsed, &c.HarvestActionsUsed, &pardonUsed, + &mistyLastSeen, &arinaLastSeen, + &mistyBuffExp, &mistyDebuffExp, &arinaBuffExp, + &c.NPCMsgCount, &c.NPCMsgCountDate, + &c.MistyRollTarget, &c.ArinaRollTarget, ) if err != nil { return nil, err @@ -407,6 +425,21 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) { if pardonUsed.Valid { 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 } @@ -475,7 +508,11 @@ func saveAdvCharacter(char *AdventureCharacter) error { babysit_active = ?, babysit_expires_at = ?, babysit_skill_focus = ?, hospital_visits = ?, robbie_visit_count = ?, last_death_date = ?, 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 = ?`, char.DisplayName, char.CombatLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill, char.CombatXP, char.MiningXP, char.ForagingXP, char.FishingXP, @@ -488,6 +525,10 @@ func saveAdvCharacter(char *AdventureCharacter) error { char.HospitalVisits, char.RobbieVisitCount, char.LastDeathDate, char.CombatActionsUsed, char.HarvestActionsUsed, char.LastPardonUsed, + char.MistyLastSeen, char.ArinaLastSeen, + char.MistyBuffExpires, char.MistyDebuffExpires, char.ArinaBuffExpires, + char.NPCMsgCount, char.NPCMsgCountDate, + char.MistyRollTarget, char.ArinaRollTarget, string(char.UserID), ) return err @@ -605,7 +646,9 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) { masterwork_drops_received, rival_pool, rival_unlocked_notified, 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`) if err != nil { return nil, err @@ -629,6 +672,8 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) { &c.RivalPool, &rivalUnlocked, &babysitAct, &babysitExp, &c.BabysitSkillFocus, &c.CombatActionsUsed, &c.HarvestActionsUsed, + &c.NPCMsgCount, &c.NPCMsgCountDate, + &c.MistyRollTarget, &c.ArinaRollTarget, ); err != nil { return nil, err } diff --git a/internal/plugin/adventure_flavor_npcs.go b/internal/plugin/adventure_flavor_npcs.go new file mode 100644 index 0000000..2231f61 --- /dev/null +++ b/internal/plugin/adventure_flavor_npcs.go @@ -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.", +} diff --git a/internal/plugin/adventure_npcs.go b/internal/plugin/adventure_npcs.go new file mode 100644 index 0000000..2b7bfbe --- /dev/null +++ b/internal/plugin/adventure_npcs.go @@ -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) + } +} diff --git a/internal/plugin/adventure_robbie.go b/internal/plugin/adventure_robbie.go index e93478d..e38e692 100644 --- a/internal/plugin/adventure_robbie.go +++ b/internal/plugin/adventure_robbie.go @@ -42,7 +42,7 @@ func (p *AdventurePlugin) robbieTicker() { slog.Info("adventure: robbie target hour set", "hour", robbieTargetHour, "date", dateKey) } - if now.Hour() != robbieTargetHour || now.Minute() != 0 { + if now.Hour() < robbieTargetHour { continue } @@ -180,15 +180,18 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string func robbieQualifyingItems(inv []AdvItem, equip map[EquipmentSlot]*AdvEquipment) []AdvItem { var result []AdvItem 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 if item.Type == "ArenaGear" || item.Type == "card" { 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] if !hasSlot { continue diff --git a/internal/plugin/adventure_scheduler.go b/internal/plugin/adventure_scheduler.go index f86e7de..51f016e 100644 --- a/internal/plugin/adventure_scheduler.go +++ b/internal/plugin/adventure_scheduler.go @@ -277,15 +277,18 @@ func (p *AdventurePlugin) midnightTicker() { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() + lastRanDate := "" + for range ticker.C { - now := time.Now().UTC() - if now.Hour() != 0 || now.Minute() != 0 { + dateKey := time.Now().UTC().Format("2006-01-02") + if dateKey == lastRanDate { continue } - dateKey := now.Format("2006-01-02") + // New UTC day — check DB in case we already ran (e.g. bot restart). jobName := "adventure_midnight" if db.JobCompleted(jobName, dateKey) { + lastRanDate = dateKey continue } @@ -295,6 +298,7 @@ func (p *AdventurePlugin) midnightTicker() { continue } db.MarkJobCompleted(jobName, dateKey) + lastRanDate = dateKey } } @@ -309,14 +313,11 @@ func (p *AdventurePlugin) midnightReset() error { dmsSent := 0 for _, char := range chars { - // Dead players freeze their streak — death is involuntary, don't punish it. - if !char.Alive { - continue - } - if !char.HasActedToday() { - // If the player died today (or yesterday — covering late-night deaths - // that span midnight), grant a grace period: no shame, no streak reset. + // If the player died today or yesterday, they couldn't act — no shame, + // 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 || char.LastDeathDate == time.Now().UTC().Add(-24*time.Hour).Format("2006-01-02") { continue @@ -374,6 +375,9 @@ func (p *AdventurePlugin) midnightReset() error { 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. // Entries are only used for dedup within a day, so clearing at midnight is fine. advClearFlavorHistory() diff --git a/internal/plugin/adventure_test.go b/internal/plugin/adventure_test.go index bc3e48c..81c3527 100644 --- a/internal/plugin/adventure_test.go +++ b/internal/plugin/adventure_test.go @@ -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) { char := &AdventureCharacter{CombatLevel: 10} equip := map[EquipmentSlot]*AdvEquipment{