Add market plugin, arena, holidays, UNO stacking fix, and multiple UX improvements

- Market plugin (#49): daily index snapshots (Yahoo Finance + Finnhub fallback),
  Ollama-generated summaries, !howsthemarket, !marketstatus, !marketreport commands,
  exchange hours with DST support, 30-min room cooldown
- Adventure arena: 5-tier combat gauntlet with 20 monsters, risk-reward cashout,
  death lockout changed from 24h to midnight UTC across all code and flavor text
- Adventure holidays: double daily actions on holidays
- Adventure DM fix: 15-minute response window prevents bare numbers from triggering
  adventure during UNO games
- Adventure scheduler: jitter between morning DMs to avoid Matrix rate limits
- Adventure revive: wire up adv_revived achievement on admin revive
- Column migration system for existing databases (holiday_action_taken)
- Fix italic markdown rendering after newlines
- Fix holdem DMs broken by space groups including DM rooms
- UNO No Mercy: remove stacking escalation rule (any draw card stacks on any other)
- UNO multiplayer: add turn announcements after stack absorption and turn passes,
  jitter between rapid-fire messages with safe mutex handling
- Birthday: add €1,000 gift and 10 XP + €100 community celebration bonus
- Market: guard against division by zero, nil pointer, and timezone errors
- README updates: plugin count 48→49, all new commands, feature flags, architecture

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-03-27 21:26:19 -07:00
parent 81e6cecad9
commit 0d3485c7c2
22 changed files with 4327 additions and 116 deletions

View File

@@ -177,9 +177,14 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]*
// ── Morning DM ───────────────────────────────────────────────────────────────
func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, balance float64, bonuses *AdvBonusSummary) string {
func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, balance float64, bonuses *AdvBonusSummary, holidayName string) string {
var sb strings.Builder
// Holiday notice (before greeting)
if holidayName != "" {
sb.WriteString(fmt.Sprintf("🎉 Happy %s! In recognition of %s, you are able to take **two actions** today.\n\n", holidayName, holidayName))
}
// Pick a morning greeting
greeting, _ := advPickFlavor(MorningDM, char.UserID, "morning_dm")
vars := map[string]string{
@@ -255,6 +260,47 @@ func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEq
return sb.String()
}
// ── Holiday Second Action Prompt ──────────────────────────────────────────────
func renderAdvHolidaySecondPrompt(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, bonuses *AdvBonusSummary) string {
var sb strings.Builder
sb.WriteString("✅ Action 1 complete.\n\nNow choose your **second action**:\n\n")
sb.WriteString("**1⃣ Dungeon:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityDungeon, bonuses) {
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("**2⃣ Mine:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityMining, bonuses) {
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("**3⃣ Forage:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityForaging, bonuses) {
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("**4⃣ Shop** — buy/sell gear and loot\n")
sb.WriteString("**5⃣ Rest** — skip the second action\n\n")
sb.WriteString("Reply with the number and location, e.g: `1 Soggy Cellar`")
return sb.String()
}
// ── Resolution DM ────────────────────────────────────────────────────────────
func renderAdvResolutionDM(result *AdvActionResult, char *AdventureCharacter) string {
@@ -418,25 +464,30 @@ func renderAdvOnboardingDM(char *AdventureCharacter) string {
// ── Daily Summary ────────────────────────────────────────────────────────────
type AdvPlayerDaySummary struct {
DisplayName string
CombatLevel int
MiningSkill int
ForagingSkill int
Activity string
Location string
Outcome string
LootValue int64
IsDead bool
DeadUntil string
IsResting bool
SummaryLine string
DisplayName string
CombatLevel int
MiningSkill int
ForagingSkill int
Activity string
Location string
Outcome string
LootValue int64
IsDead bool
DeadUntil string
IsResting bool
SummaryLine string
HolidayActions int // 0 = not holiday or no action; 1 = took one; 2 = took both
}
func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewardSummary, players []AdvPlayerDaySummary) string {
func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewardSummary, players []AdvPlayerDaySummary, holidayName string) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("📜 **ADVENTURER DAILY REPORT**\n%s\n\n", date))
if holidayName != "" {
sb.WriteString(fmt.Sprintf("🎉 In recognition of **%s**, adventurers had two actions today.\n\n", holidayName))
}
// TwinBee section
if tb != nil {
sb.WriteString("🐝 **TwinBee's Daily Report**\n")
@@ -530,6 +581,48 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
sb.WriteString("\n")
}
// Holiday stats
if holidayName != "" {
tookBoth := 0
totalActive := 0
for _, p := range players {
if p.IsDead || p.IsResting {
if p.HolidayActions > 0 {
totalActive++
}
if p.HolidayActions >= 2 {
tookBoth++
}
continue
}
if p.Activity != "" {
totalActive++
}
if p.HolidayActions >= 2 {
tookBoth++
}
}
if totalActive > 0 {
sb.WriteString(fmt.Sprintf("🎉 %s double-action day — %d of %d adventurers took both actions.\n\n", holidayName, tookBoth, totalActive))
}
// Note players who died before their second action
for _, d := range dead {
if d.HolidayActions == 1 {
sb.WriteString(fmt.Sprintf("• %s — died in %s before their second action. Rough holiday.\n", d.DisplayName, d.Location))
}
}
if len(dead) > 0 {
// Check if any had HolidayActions == 1
for _, d := range dead {
if d.HolidayActions == 1 {
sb.WriteString("\n")
break
}
}
}
}
// Standout
if bestPlayer != nil && bestPlayer.LootValue > 0 {
pool := SummaryStandoutGood