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 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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user