Add action economy split and cross-plugin chat level lookup (Adventure 2.5 steps 1-2)

Action economy: replace single daily action with 1 combat + 3 harvest
actions per day. Holidays grant +1 to each bucket. Rest consumes all
remaining actions. Arena remains outside both buckets.

- Add CombatActionsUsed/HarvestActionsUsed counters to AdventureCharacter
- Add CanDoCombat/CanDoHarvest/AllActionsUsed/HasActedToday helpers
- Update all 14 ActionTakenToday check sites across adventure, scheduler,
  babysit, twinbee, events, and render
- Morning DM shows action budget and grays out exhausted categories
- Activity resolution checks per-bucket availability before proceeding
- Midnight reset zeros both counters
- Post-action nudge shows remaining actions instead of holiday prompt

Cross-plugin lookup: export XPPlugin.GetLevel(), wire into AdventurePlugin
via p.chatLevel(userID) for upcoming chat level perks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-04-08 15:24:49 -07:00
parent f57dcd93a5
commit 7e4fbe5ec8
10 changed files with 203 additions and 126 deletions

View File

@@ -83,6 +83,8 @@ func runMigrations(d *sql.DB) error {
`ALTER TABLE adventure_characters ADD COLUMN robbie_visit_count INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE adventure_characters ADD COLUMN robbie_visit_count INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE wordle_stats ADD COLUMN total_earned INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE wordle_stats ADD COLUMN total_earned INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN last_death_date TEXT NOT NULL DEFAULT ''`, `ALTER TABLE adventure_characters ADD COLUMN last_death_date TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE adventure_characters ADD COLUMN combat_actions_used INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN harvest_actions_used INTEGER NOT NULL DEFAULT 0`,
} }
for _, stmt := range columnMigrations { for _, stmt := range columnMigrations {
if _, err := d.Exec(stmt); err != nil { if _, err := d.Exec(stmt); err != nil {

View File

@@ -18,6 +18,7 @@ import (
type AdventurePlugin struct { type AdventurePlugin struct {
Base Base
euro *EuroPlugin euro *EuroPlugin
xp *XPPlugin
achievements *AchievementsPlugin achievements *AchievementsPlugin
mu sync.Mutex mu sync.Mutex
dmToPlayer map[id.RoomID]id.UserID dmToPlayer map[id.RoomID]id.UserID
@@ -67,16 +68,25 @@ type advPendingTreasureDiscard struct {
Existing []AdvTreasureDef Existing []AdvTreasureDef
} }
func NewAdventurePlugin(client *mautrix.Client, euro *EuroPlugin) *AdventurePlugin { func NewAdventurePlugin(client *mautrix.Client, euro *EuroPlugin, xp *XPPlugin) *AdventurePlugin {
return &AdventurePlugin{ return &AdventurePlugin{
Base: NewBase(client), Base: NewBase(client),
euro: euro, euro: euro,
xp: xp,
dmToPlayer: make(map[id.RoomID]id.UserID), dmToPlayer: make(map[id.RoomID]id.UserID),
morningHour: envInt("ADVENTURE_MORNING_HOUR", 8), morningHour: envInt("ADVENTURE_MORNING_HOUR", 8),
summaryHour: envInt("ADVENTURE_SUMMARY_HOUR", 20), summaryHour: envInt("ADVENTURE_SUMMARY_HOUR", 20),
} }
} }
// chatLevel returns the user's chat level for perk calculations.
func (p *AdventurePlugin) chatLevel(userID id.UserID) int {
if p.xp == nil {
return 0
}
return p.xp.GetLevel(userID)
}
func (p *AdventurePlugin) Name() string { return "adventure" } func (p *AdventurePlugin) Name() string { return "adventure" }
// SetAchievements wires the achievements plugin after both are initialized. // SetAchievements wires the achievements plugin after both are initialized.
@@ -285,25 +295,15 @@ func (p *AdventurePlugin) handleMenu(ctx MessageContext) error {
} }
} }
if char.ActionTakenToday { isHol, holName := isHolidayToday()
// On holidays, allow second action if not yet taken if char.AllActionsUsed(isHol) {
isHol, _ := isHolidayToday()
if isHol && !char.HolidayActionTaken {
treasures, _ := loadAdvTreasureBonuses(char.UserID)
buffs, _ := loadAdvActiveBuffs(char.UserID)
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
text := renderAdvHolidaySecondPrompt(char, equip, bonuses)
p.advMarkMenuSent(ctx.Sender)
return p.SendDM(ctx.Sender, text)
}
now := time.Now().UTC() now := time.Now().UTC()
midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC) midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC)
remaining := midnight.Sub(now) remaining := midnight.Sub(now)
hours := int(remaining.Hours()) hours := int(remaining.Hours())
minutes := int(remaining.Minutes()) % 60 minutes := int(remaining.Minutes()) % 60
return p.SendDM(ctx.Sender, fmt.Sprintf( return p.SendDM(ctx.Sender, fmt.Sprintf(
"You've already taken your action today. Tomorrow awaits. Try to survive it.\n\n"+ "You've used all your actions today. Tomorrow awaits. Try to survive it.\n\n"+
"Next action: 00:00 UTC (%dh %dm from now)\n"+ "Next action: 00:00 UTC (%dh %dm from now)\n"+
"Morning DM: %02d:00 UTC\n\n"+ "Morning DM: %02d:00 UTC\n\n"+
"The Arena is always open: `!arena`", "The Arena is always open: `!arena`",
@@ -315,7 +315,6 @@ func (p *AdventurePlugin) handleMenu(ctx MessageContext) error {
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false) bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
balance := p.euro.GetBalance(char.UserID) balance := p.euro.GetBalance(char.UserID)
_, holName := isHolidayToday()
text := renderAdvMorningDM(char, equip, balance, bonuses, holName) text := renderAdvMorningDM(char, equip, balance, bonuses, holName)
p.advMarkMenuSent(ctx.Sender) p.advMarkMenuSent(ctx.Sender)
return p.SendDM(ctx.Sender, text) return p.SendDM(ctx.Sender, text)
@@ -613,30 +612,26 @@ func (p *AdventurePlugin) parseAndResolveChoice(ctx MessageContext, body string)
} }
} }
if char.ActionTakenToday { isHol, _ := isHolidayToday()
// On holidays, allow second action if not yet taken if char.AllActionsUsed(isHol) {
isHol, _ := isHolidayToday() // Only send the reminder once per day — subsequent DM messages
if !isHol || char.HolidayActionTaken { // are silently ignored so they can be handled by other plugins (e.g. UNO).
// Only send the reminder once per day — subsequent DM messages today := time.Now().UTC().Format("2006-01-02")
// are silently ignored so they can be handled by other plugins (e.g. UNO). if prev, ok := p.dmRemindedDate.Load(string(ctx.Sender)); ok && prev.(string) == today {
today := time.Now().UTC().Format("2006-01-02") return nil
if prev, ok := p.dmRemindedDate.Load(string(ctx.Sender)); ok && prev.(string) == today {
return nil
}
p.dmRemindedDate.Store(string(ctx.Sender), today)
now := time.Now().UTC()
midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC)
remaining := midnight.Sub(now)
hours := int(remaining.Hours())
minutes := int(remaining.Minutes()) % 60
return p.SendDM(ctx.Sender, fmt.Sprintf(
"You've already taken your action today. Rest now. Try again tomorrow.\n\n"+
"Next action: 00:00 UTC (%dh %dm from now)\n\n"+
"The Arena is always open: `!arena`",
hours, minutes))
} }
// Fall through for holiday second action p.dmRemindedDate.Store(string(ctx.Sender), today)
now := time.Now().UTC()
midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC)
remaining := midnight.Sub(now)
hours := int(remaining.Hours())
minutes := int(remaining.Minutes()) % 60
return p.SendDM(ctx.Sender, fmt.Sprintf(
"You've used all your actions today. Rest now. Try again tomorrow.\n\n"+
"Next action: 00:00 UTC (%dh %dm from now)\n\n"+
"The Arena is always open: `!arena`",
hours, minutes))
} }
lower := strings.ToLower(body) lower := strings.ToLower(body)
@@ -728,6 +723,14 @@ func (p *AdventurePlugin) parseActivityLocation(input string, char *AdventureCha
// ── Activity Resolution ────────────────────────────────────────────────────── // ── Activity Resolution ──────────────────────────────────────────────────────
func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCharacter, activity AdvActivityType, loc *AdvLocation) error { func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCharacter, activity AdvActivityType, loc *AdvLocation) error {
isHol, _ := isHolidayToday()
if isCombatActivity(activity) && !char.CanDoCombat(isHol) {
return p.SendDM(ctx.Sender, "You've used your combat action for the day. Try a harvest activity (mining, fishing, foraging) or rest.")
}
if isHarvestActivity(activity) && !char.CanDoHarvest(isHol) {
return p.SendDM(ctx.Sender, "You've used all your harvest actions for the day. Try combat (dungeon) or rest.")
}
equip, err := loadAdvEquipment(char.UserID) equip, err := loadAdvEquipment(char.UserID)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your equipment.") return p.SendDM(ctx.Sender, "Failed to load your equipment.")
@@ -818,22 +821,14 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
_ = addAdvInventoryItem(char.UserID, item) _ = addAdvInventoryItem(char.UserID, item)
} }
// Determine if this is the holiday second action // Mark action consumed in the correct bucket
isAction2 := char.ActionTakenToday // already taken = this is the second if isCombatActivity(activity) {
isHol, _ := isHolidayToday() char.CombatActionsUsed++
} else if isHarvestActivity(activity) {
// Mark action taken and record the date for streak tracking char.HarvestActionsUsed++
if !isAction2 {
char.ActionTakenToday = true
char.LastActionDate = time.Now().UTC().Format("2006-01-02")
}
// Holiday flags: mark second action done, or mark it done on death during action 1
if isAction2 {
char.HolidayActionTaken = true
} else if isHol && result.Outcome == AdvOutcomeDeath && !deathReprieved {
char.HolidayActionTaken = true // died on action 1 — no second action
} }
char.ActionTakenToday = true
char.LastActionDate = time.Now().UTC().Format("2006-01-02")
// Update streak info // Update streak info
result.StreakBonus = char.CurrentStreak result.StreakBonus = char.CurrentStreak
@@ -897,34 +892,39 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
p.checkMasterworkDrop(ctx.Sender, char, equip, loc, result.Outcome) p.checkMasterworkDrop(ctx.Sender, char, equip, loc, result.Outcome)
} }
// TODO: holiday achievement hooks // If the player still has actions remaining, nudge them
if !char.AllActionsUsed(isHol) && (result.Outcome != AdvOutcomeDeath || deathReprieved) {
// Holiday: offer second action if this was action 1 and player survived remaining := []string{}
if !isAction2 && isHol && (result.Outcome != AdvOutcomeDeath || deathReprieved) { if char.CanDoCombat(isHol) {
equip2, _ := loadAdvEquipment(char.UserID) remaining = append(remaining, "combat")
treasures2, _ := loadAdvTreasureBonuses(char.UserID)
buffs2, _ := loadAdvActiveBuffs(char.UserID)
bonuses2 := computeAdvBonuses(treasures2, buffs2, char.CurrentStreak, false)
prompt := renderAdvHolidaySecondPrompt(char, equip2, bonuses2)
if err := p.SendDM(ctx.Sender, prompt); err != nil {
slog.Error("adventure: failed to send holiday second prompt", "user", ctx.Sender, "err", err)
} }
if char.CanDoHarvest(isHol) {
harvestLeft := maxHarvestActions - char.HarvestActionsUsed
if isHol {
harvestLeft = maxHarvestActions + 1 - char.HarvestActionsUsed
}
remaining = append(remaining, fmt.Sprintf("%d harvest", harvestLeft))
}
p.SendDM(ctx.Sender, fmt.Sprintf("Actions remaining today: %s. Type `!adv` for the menu.", strings.Join(remaining, ", ")))
} }
return nil return nil
} }
func (p *AdventurePlugin) resolveRest(ctx MessageContext, char *AdventureCharacter) error { func (p *AdventurePlugin) resolveRest(ctx MessageContext, char *AdventureCharacter) error {
isAction2 := char.ActionTakenToday
isHol, _ := isHolidayToday() isHol, _ := isHolidayToday()
if !isAction2 { // Rest consumes all remaining actions
char.ActionTakenToday = true combatMax := maxCombatActions
char.LastActionDate = time.Now().UTC().Format("2006-01-02") harvestMax := maxHarvestActions
} if isHol {
if isAction2 { combatMax++
char.HolidayActionTaken = true harvestMax++
} }
char.CombatActionsUsed = combatMax
char.HarvestActionsUsed = harvestMax
char.ActionTakenToday = true
char.LastActionDate = time.Now().UTC().Format("2006-01-02")
if err := saveAdvCharacter(char); err != nil { if err := saveAdvCharacter(char); err != nil {
return p.SendDM(ctx.Sender, "Failed to save. Even resting is broken.") return p.SendDM(ctx.Sender, "Failed to save. Even resting is broken.")
@@ -954,18 +954,6 @@ func (p *AdventurePlugin) resolveRest(ctx MessageContext, char *AdventureCharact
slog.Error("adventure: failed to send rest DM", "user", ctx.Sender, "err", err) slog.Error("adventure: failed to send rest DM", "user", ctx.Sender, "err", err)
} }
// Holiday: offer second action if this was action 1
if !isAction2 && isHol {
equip, _ := loadAdvEquipment(char.UserID)
treasures, _ := loadAdvTreasureBonuses(char.UserID)
buffs, _ := loadAdvActiveBuffs(char.UserID)
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
prompt := renderAdvHolidaySecondPrompt(char, equip, bonuses)
if err := p.SendDM(ctx.Sender, prompt); err != nil {
slog.Error("adventure: failed to send holiday second prompt", "user", ctx.Sender, "err", err)
}
}
return nil return nil
} }

View File

@@ -276,7 +276,8 @@ func (p *AdventurePlugin) runBabysitDaily(char *AdventureCharacter) {
// No treasure drops during babysitting // No treasure drops during babysitting
result.TreasureFound = nil result.TreasureFound = nil
// Mark action taken // Mark action taken (babysit always uses a harvest action)
char.HarvestActionsUsed++
char.ActionTakenToday = true char.ActionTakenToday = true
char.LastActionDate = time.Now().UTC().Format("2006-01-02") char.LastActionDate = time.Now().UTC().Format("2006-01-02")

View File

@@ -39,6 +39,8 @@ type AdventureCharacter struct {
DeadUntil *time.Time DeadUntil *time.Time
ActionTakenToday bool ActionTakenToday bool
HolidayActionTaken bool HolidayActionTaken bool
CombatActionsUsed int
HarvestActionsUsed int
ArenaWins int // v2 ArenaWins int // v2
ArenaLosses int // v2 ArenaLosses int // v2
InvasionScore int // v2 InvasionScore int // v2
@@ -211,6 +213,43 @@ func (c *AdventureCharacter) Kill() {
c.LastDeathDate = time.Now().UTC().Format("2006-01-02") c.LastDeathDate = time.Now().UTC().Format("2006-01-02")
} }
// ── Action Economy ──────────────────────────────────────────────────────────
const maxCombatActions = 1
const maxHarvestActions = 3
func (c *AdventureCharacter) CanDoCombat(isHoliday bool) bool {
max := maxCombatActions
if isHoliday {
max++
}
return c.CombatActionsUsed < max
}
func (c *AdventureCharacter) CanDoHarvest(isHoliday bool) bool {
max := maxHarvestActions
if isHoliday {
max++
}
return c.HarvestActionsUsed < max
}
func (c *AdventureCharacter) HasActedToday() bool {
return c.CombatActionsUsed > 0 || c.HarvestActionsUsed > 0
}
func (c *AdventureCharacter) AllActionsUsed(isHoliday bool) bool {
return !c.CanDoCombat(isHoliday) && !c.CanDoHarvest(isHoliday)
}
func isCombatActivity(activity AdvActivityType) bool {
return activity == AdvActivityDungeon
}
func isHarvestActivity(activity AdvActivityType) bool {
return activity == AdvActivityMining || activity == AdvActivityForaging || activity == AdvActivityFishing
}
// ── Equipment Score ────────────────────────────────────────────────────────── // ── Equipment Score ──────────────────────────────────────────────────────────
func advEquipmentScore(equip map[EquipmentSlot]*AdvEquipment) float64 { func advEquipmentScore(equip map[EquipmentSlot]*AdvEquipment) float64 {
@@ -320,7 +359,8 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
masterwork_drops_received, masterwork_drops_received,
rival_pool, rival_unlocked_notified, rival_pool, rival_unlocked_notified,
babysit_active, babysit_expires_at, babysit_skill_focus, babysit_active, babysit_expires_at, babysit_skill_focus,
hospital_visits, robbie_visit_count, last_death_date hospital_visits, robbie_visit_count, last_death_date,
combat_actions_used, harvest_actions_used
FROM adventure_characters WHERE user_id = ?`, string(userID)).Scan( FROM adventure_characters WHERE user_id = ?`, string(userID)).Scan(
&c.UserID, &c.DisplayName, &c.UserID, &c.DisplayName,
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill, &c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
@@ -333,6 +373,7 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
&c.RivalPool, &rivalUnlocked, &c.RivalPool, &rivalUnlocked,
&babysitAct, &babysitExp, &c.BabysitSkillFocus, &babysitAct, &babysitExp, &c.BabysitSkillFocus,
&c.HospitalVisits, &c.RobbieVisitCount, &c.LastDeathDate, &c.HospitalVisits, &c.RobbieVisitCount, &c.LastDeathDate,
&c.CombatActionsUsed, &c.HarvestActionsUsed,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -417,7 +458,8 @@ func saveAdvCharacter(char *AdventureCharacter) error {
masterwork_drops_received = ?, masterwork_drops_received = ?,
rival_pool = ?, rival_unlocked_notified = ?, rival_pool = ?, rival_unlocked_notified = ?,
babysit_active = ?, babysit_expires_at = ?, babysit_skill_focus = ?, babysit_active = ?, babysit_expires_at = ?, babysit_skill_focus = ?,
hospital_visits = ?, robbie_visit_count = ?, last_death_date = ? hospital_visits = ?, robbie_visit_count = ?, last_death_date = ?,
combat_actions_used = ?, harvest_actions_used = ?
WHERE user_id = ?`, WHERE user_id = ?`,
char.DisplayName, char.CombatLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill, char.DisplayName, char.CombatLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill,
char.CombatXP, char.MiningXP, char.ForagingXP, char.FishingXP, char.CombatXP, char.MiningXP, char.ForagingXP, char.FishingXP,
@@ -428,6 +470,7 @@ func saveAdvCharacter(char *AdventureCharacter) error {
char.RivalPool, rivalUnlocked, char.RivalPool, rivalUnlocked,
babysitAct, char.BabysitExpiresAt, char.BabysitSkillFocus, babysitAct, char.BabysitExpiresAt, char.BabysitSkillFocus,
char.HospitalVisits, char.RobbieVisitCount, char.LastDeathDate, char.HospitalVisits, char.RobbieVisitCount, char.LastDeathDate,
char.CombatActionsUsed, char.HarvestActionsUsed,
string(char.UserID), string(char.UserID),
) )
return err return err
@@ -544,7 +587,8 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
created_at, last_active_at, death_reprieve_last, created_at, last_active_at, death_reprieve_last,
masterwork_drops_received, masterwork_drops_received,
rival_pool, rival_unlocked_notified, rival_pool, rival_unlocked_notified,
babysit_active, babysit_expires_at, babysit_skill_focus babysit_active, babysit_expires_at, babysit_skill_focus,
combat_actions_used, harvest_actions_used
FROM adventure_characters`) FROM adventure_characters`)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -567,6 +611,7 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
&c.MasterworkDropsReceived, &c.MasterworkDropsReceived,
&c.RivalPool, &rivalUnlocked, &c.RivalPool, &rivalUnlocked,
&babysitAct, &babysitExp, &c.BabysitSkillFocus, &babysitAct, &babysitExp, &c.BabysitSkillFocus,
&c.CombatActionsUsed, &c.HarvestActionsUsed,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -594,7 +639,7 @@ func resetAllAdvDailyActions() error {
// Only reset actions taken before today — protects against race if a player // Only reset actions taken before today — protects against race if a player
// resolves their action at exactly midnight. // resolves their action at exactly midnight.
today := time.Now().UTC().Format("2006-01-02") today := time.Now().UTC().Format("2006-01-02")
_, err := d.Exec(`UPDATE adventure_characters SET action_taken_today = 0, holiday_action_taken = 0 WHERE last_action_date < ? OR last_action_date IS NULL`, today) _, err := d.Exec(`UPDATE adventure_characters SET action_taken_today = 0, holiday_action_taken = 0, combat_actions_used = 0, harvest_actions_used = 0 WHERE last_action_date < ? OR last_action_date IS NULL`, today)
return err return err
} }

View File

@@ -95,7 +95,7 @@ func (p *AdventurePlugin) eventTicker() {
func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) { func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) {
// Load character — must be alive and have acted today // Load character — must be alive and have acted today
char, err := loadAdvCharacter(userID) char, err := loadAdvCharacter(userID)
if err != nil || char == nil || !char.Alive || !char.ActionTakenToday { if err != nil || char == nil || !char.Alive || !char.HasActedToday() {
return return
} }

View File

@@ -201,11 +201,19 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]*
sb.WriteString(" — `!adventure rivals` for details\n") sb.WriteString(" — `!adventure rivals` for details\n")
} }
// Today's action // Today's actions
if char.ActionTakenToday { combatRemaining := maxCombatActions - char.CombatActionsUsed
sb.WriteString("\n📅 Today: Action taken") if combatRemaining < 0 {
combatRemaining = 0
}
harvestRemaining := maxHarvestActions - char.HarvestActionsUsed
if harvestRemaining < 0 {
harvestRemaining = 0
}
if char.HasActedToday() {
sb.WriteString(fmt.Sprintf("\n📅 Today: %d combat, %d harvest actions remaining", combatRemaining, harvestRemaining))
} else { } else {
sb.WriteString("\n📅 Today: No action yet — reply to morning DM or type `!adventure`") sb.WriteString(fmt.Sprintf("\n📅 Today: %d combat, %d harvest actions available", combatRemaining, harvestRemaining))
} }
return sb.String() return sb.String()
@@ -218,7 +226,7 @@ func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEq
// Holiday notice (before greeting) // Holiday notice (before greeting)
if holidayName != "" { if holidayName != "" {
sb.WriteString(fmt.Sprintf("🎉 Happy %s! In recognition of %s, you are able to take **two actions** today.\n\n", holidayName, holidayName)) sb.WriteString(fmt.Sprintf("🎉 Happy %s! In recognition of %s, you get an extra combat and harvest action today.\n\n", holidayName, holidayName))
} }
// Pick a morning greeting // Pick a morning greeting
@@ -259,41 +267,63 @@ func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEq
sb.WriteString("\n\n") sb.WriteString("\n\n")
} }
// Action budget
isHol := holidayName != ""
combatMax := maxCombatActions
harvestMax := maxHarvestActions
if isHol {
combatMax++
harvestMax++
}
combatLeft := combatMax - char.CombatActionsUsed
harvestLeft := harvestMax - char.HarvestActionsUsed
sb.WriteString(fmt.Sprintf("📋 **Actions:** %d/%d combat · %d/%d harvest\n\n", combatLeft, combatMax, harvestLeft, harvestMax))
// Location choices // Location choices
sb.WriteString("**1⃣ Dungeon:**\n") if char.CanDoCombat(isHol) {
for _, el := range advEligibleLocations(char, equip, AdvActivityDungeon, bonuses) { sb.WriteString("**1⃣ Dungeon:**\n")
warn := "" for _, el := range advEligibleLocations(char, equip, AdvActivityDungeon, bonuses) {
if el.InPenaltyZone { warn := ""
warn = " ⚠️" if el.InPenaltyZone {
warn = " ⚠️"
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
} }
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn)) } else {
sb.WriteString("**1⃣ Dungeon:** _(no combat actions remaining)_\n")
} }
sb.WriteString("**2⃣ Mine:**\n") if char.CanDoHarvest(isHol) {
for _, el := range advEligibleLocations(char, equip, AdvActivityMining, bonuses) { sb.WriteString("**2⃣ Mine:**\n")
warn := "" for _, el := range advEligibleLocations(char, equip, AdvActivityMining, bonuses) {
if el.InPenaltyZone { warn := ""
warn = " ⚠️" if el.InPenaltyZone {
warn = " ⚠️"
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
} }
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
}
sb.WriteString("**3⃣ Forage:**\n") sb.WriteString("**3⃣ Forage:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityForaging, bonuses) { for _, el := range advEligibleLocations(char, equip, AdvActivityForaging, bonuses) {
warn := "" warn := ""
if el.InPenaltyZone { if el.InPenaltyZone {
warn = " ⚠️" warn = " ⚠️"
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
} }
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
}
sb.WriteString("**4⃣ Fish:**\n") sb.WriteString("**4⃣ Fish:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityFishing, bonuses) { for _, el := range advEligibleLocations(char, equip, AdvActivityFishing, bonuses) {
warn := "" warn := ""
if el.InPenaltyZone { if el.InPenaltyZone {
warn = " ⚠️" warn = " ⚠️"
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
} }
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn)) } else {
sb.WriteString("**2⃣ Mine:** _(no harvest actions remaining)_\n")
sb.WriteString("**3⃣ Forage:** _(no harvest actions remaining)_\n")
sb.WriteString("**4⃣ Fish:** _(no harvest actions remaining)_\n")
} }
sb.WriteString("**5⃣ Shop** — buy/sell gear and loot\n") sb.WriteString("**5⃣ Shop** — buy/sell gear and loot\n")

View File

@@ -96,8 +96,9 @@ func (p *AdventurePlugin) sendMorningDMs() {
continue continue
} }
// If already acted today, skip // If all actions used today, skip
if char.ActionTakenToday { isHol, _ := isHolidayToday()
if char.AllActionsUsed(isHol) {
continue continue
} }
@@ -225,7 +226,7 @@ func (p *AdventurePlugin) postDailySummary() {
continue continue
} }
if !c.ActionTakenToday { if !c.HasActedToday() {
ps.IsResting = true ps.IsResting = true
if len(SummaryResting) > 0 { if len(SummaryResting) > 0 {
ps.SummaryLine = SummaryResting[time.Now().Nanosecond()%len(SummaryResting)] ps.SummaryLine = SummaryResting[time.Now().Nanosecond()%len(SummaryResting)]
@@ -313,7 +314,7 @@ func (p *AdventurePlugin) midnightReset() error {
continue continue
} }
if !char.ActionTakenToday { if !char.HasActedToday() {
// If the player died today (or yesterday — covering late-night deaths // If the player died today (or yesterday — covering late-night deaths
// that span midnight), grant a grace period: no shame, no streak reset. // that span midnight), grant a grace period: no shame, no streak reset.
if char.LastDeathDate == today || if char.LastDeathDate == today ||

View File

@@ -236,7 +236,7 @@ func (p *AdventurePlugin) distributeTwinBeeRewards(result *TwinBeeResult) TwinBe
var eligible []id.UserID var eligible []id.UserID
for _, c := range chars { for _, c := range chars {
if c.ActionTakenToday { if c.HasActedToday() {
eligible = append(eligible, c.UserID) eligible = append(eligible, c.UserID)
} }
} }

View File

@@ -106,6 +106,16 @@ func (p *XPPlugin) OnMessage(ctx MessageContext) error {
} }
// GrantXP awards XP to a user from an external plugin and returns (newXP, leveledUp, newLevel). // GrantXP awards XP to a user from an external plugin and returns (newXP, leveledUp, newLevel).
// GetLevel returns the chat level for the given user, or 0 if unknown.
func (p *XPPlugin) GetLevel(userID id.UserID) int {
d := db.Get()
var level int
if err := d.QueryRow(`SELECT level FROM users WHERE user_id = ?`, string(userID)).Scan(&level); err != nil {
return 0
}
return level
}
func (p *XPPlugin) GrantXP(userID id.UserID, amount int, reason string) (int, bool, int) { func (p *XPPlugin) GrantXP(userID id.UserID, amount int, reason string) (int, bool, int) {
return p.grantXPInternal(userID, amount, reason) return p.grantXPInternal(userID, amount, reason)
} }

View File

@@ -132,7 +132,7 @@ func main() {
registry.Register(plugin.NewBlackjackPlugin(client, euroPlugin)) registry.Register(plugin.NewBlackjackPlugin(client, euroPlugin))
registry.Register(plugin.NewUnoPlugin(client, euroPlugin)) registry.Register(plugin.NewUnoPlugin(client, euroPlugin))
registry.Register(plugin.NewHoldemPlugin(client, euroPlugin)) registry.Register(plugin.NewHoldemPlugin(client, euroPlugin))
adventurePlugin := plugin.NewAdventurePlugin(client, euroPlugin) adventurePlugin := plugin.NewAdventurePlugin(client, euroPlugin, xpPlugin)
registry.Register(adventurePlugin) registry.Register(adventurePlugin)
wordlePlugin := plugin.NewWordlePlugin(client, euroPlugin, dictClient) wordlePlugin := plugin.NewWordlePlugin(client, euroPlugin, dictClient)
registry.Register(wordlePlugin) registry.Register(wordlePlugin)