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:
prosolis
2026-04-08 22:41:21 -07:00
parent 1b825498bd
commit 2ef7a29f02
12 changed files with 672 additions and 69 deletions

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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:

View File

@@ -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)

View File

@@ -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()
}

View File

@@ -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 ────────────────────────────────────────────────────────────

View File

@@ -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
}

View 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.",
}

View 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) // 510
char.ArinaRollTarget = 5 + rand.IntN(6) // 510
}
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)
}
}

View File

@@ -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

View File

@@ -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()

View File

@@ -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{