mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 00:52:40 +00:00
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:
322
internal/plugin/adventure_holidays.go
Normal file
322
internal/plugin/adventure_holidays.go
Normal file
@@ -0,0 +1,322 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── Holiday Definitions ─────────────────────────────────────────────────────
|
||||
//
|
||||
// On major holidays, active adventurers get two actions instead of one.
|
||||
// isHolidayToday() checks both fixed and floating holidays against UTC date.
|
||||
//
|
||||
// All floating holidays are computed algorithmically — no hardcoded dates.
|
||||
// Easter: Meeus-Jones-Butcher computus
|
||||
// Hebrew calendar: molad + dehiyyot (Rosh Hashanah, Yom Kippur, Passover, Hanukkah)
|
||||
// Islamic calendar: Tabular Islamic calendar (Eid al-Fitr, Eid al-Adha, ±1 day)
|
||||
// Nth-Monday: Canadian statutory holidays
|
||||
//
|
||||
// Holidays we can't reliably compute (Diwali, Vesak, Lunar New Year) are
|
||||
// intentionally omitted rather than requiring yearly hardcoded updates.
|
||||
|
||||
type adventureHoliday struct {
|
||||
Month int
|
||||
Day int
|
||||
Name string
|
||||
}
|
||||
|
||||
type adventureFloatingHoliday struct {
|
||||
Date time.Time
|
||||
Name string
|
||||
}
|
||||
|
||||
// Fixed-date holidays (month/day pairs).
|
||||
var majorHolidays = []adventureHoliday{
|
||||
// Global / US
|
||||
{Month: 1, Day: 1, Name: "New Year's Day"},
|
||||
{Month: 2, Day: 14, Name: "Valentine's Day"},
|
||||
{Month: 3, Day: 17, Name: "St. Patrick's Day"},
|
||||
{Month: 4, Day: 1, Name: "April Fools' Day"},
|
||||
{Month: 5, Day: 5, Name: "Cinco de Mayo"},
|
||||
{Month: 7, Day: 4, Name: "Independence Day"},
|
||||
{Month: 10, Day: 31, Name: "Halloween"},
|
||||
{Month: 11, Day: 1, Name: "Día de los Muertos / All Saints' Day"},
|
||||
{Month: 12, Day: 25, Name: "Christmas"},
|
||||
{Month: 12, Day: 31, Name: "New Year's Eve"},
|
||||
|
||||
// Canadian
|
||||
{Month: 7, Day: 1, Name: "Canada Day"},
|
||||
{Month: 9, Day: 30, Name: "National Day for Truth and Reconciliation"},
|
||||
{Month: 11, Day: 11, Name: "Remembrance Day"},
|
||||
{Month: 12, Day: 26, Name: "Boxing Day"},
|
||||
|
||||
// Portuguese
|
||||
{Month: 4, Day: 25, Name: "Dia da Liberdade"},
|
||||
{Month: 5, Day: 1, Name: "Dia do Trabalhador"},
|
||||
{Month: 6, Day: 10, Name: "Dia de Portugal"},
|
||||
{Month: 8, Day: 15, Name: "Assunção de Nossa Senhora"},
|
||||
{Month: 10, Day: 5, Name: "Implantação da República"},
|
||||
{Month: 12, Day: 1, Name: "Restauração da Independência"},
|
||||
{Month: 12, Day: 8, Name: "Imaculada Conceição"},
|
||||
}
|
||||
|
||||
// floatingHolidays returns all computed floating holidays for the given year.
|
||||
func floatingHolidays(year int) []adventureFloatingHoliday {
|
||||
easter := easterDate(year)
|
||||
|
||||
holidays := []adventureFloatingHoliday{
|
||||
// Easter-derived (Gregorian computus)
|
||||
{Date: easter.AddDate(0, 0, -47), Name: "Mardi Gras / Carnaval"},
|
||||
{Date: easter.AddDate(0, 0, -2), Name: "Good Friday"},
|
||||
{Date: easter, Name: "Easter Sunday"},
|
||||
{Date: easter.AddDate(0, 0, 60), Name: "Corpo de Deus"}, // Corpus Christi PT
|
||||
|
||||
// Nowruz — tied to spring equinox, March 20 in most years
|
||||
{Date: time.Date(year, time.March, 20, 0, 0, 0, 0, time.UTC), Name: "Nowruz"},
|
||||
|
||||
// Canadian Nth-Monday holidays
|
||||
{Date: nthMonday(year, time.February, 3), Name: "Family Day"},
|
||||
{Date: mondayBeforeMay25(year), Name: "Victoria Day"},
|
||||
{Date: nthMonday(year, time.September, 1), Name: "Labour Day"},
|
||||
{Date: nthMonday(year, time.October, 2), Name: "Thanksgiving"},
|
||||
|
||||
// Hebrew calendar (molad + dehiyyot)
|
||||
{Date: hebrewPassover(year), Name: "Passover"},
|
||||
{Date: hebrewRoshHashanah(year), Name: "Rosh Hashanah"},
|
||||
{Date: hebrewYomKippur(year), Name: "Yom Kippur"},
|
||||
{Date: hebrewHanukkah(year), Name: "Hanukkah"},
|
||||
}
|
||||
|
||||
// Tabular Islamic calendar (±1 day from observed)
|
||||
// These can occur twice in a single Gregorian year since the Islamic
|
||||
// calendar is ~11 days shorter, so we append all occurrences.
|
||||
for _, d := range tabularIslamicDates(year, 10, 1) {
|
||||
holidays = append(holidays, adventureFloatingHoliday{Date: d, Name: "Eid al-Fitr"})
|
||||
}
|
||||
for _, d := range tabularIslamicDates(year, 12, 10) {
|
||||
holidays = append(holidays, adventureFloatingHoliday{Date: d, Name: "Eid al-Adha"})
|
||||
}
|
||||
|
||||
return holidays
|
||||
}
|
||||
|
||||
// isHolidayToday checks both fixed and floating holidays against the current UTC date.
|
||||
// Returns (true, displayName) if today is a holiday, (false, "") otherwise.
|
||||
// When multiple holidays fall on the same date, all names are joined with " / ".
|
||||
func isHolidayToday() (bool, string) {
|
||||
now := time.Now().UTC()
|
||||
month := int(now.Month())
|
||||
day := now.Day()
|
||||
year := now.Year()
|
||||
|
||||
var names []string
|
||||
|
||||
// Check fixed holidays
|
||||
for _, h := range majorHolidays {
|
||||
if h.Month == month && h.Day == day {
|
||||
names = append(names, h.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Check floating holidays
|
||||
todayDate := time.Date(year, now.Month(), day, 0, 0, 0, 0, time.UTC)
|
||||
for _, fh := range floatingHolidays(year) {
|
||||
if fh.Date.Equal(todayDate) {
|
||||
names = append(names, fh.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(names) == 0 {
|
||||
return false, ""
|
||||
}
|
||||
return true, strings.Join(names, " / ")
|
||||
}
|
||||
|
||||
// ── Easter Computation (Anonymous Gregorian / Meeus-Jones-Butcher) ──────────
|
||||
|
||||
func easterDate(year int) time.Time {
|
||||
a := year % 19
|
||||
b := year / 100
|
||||
c := year % 100
|
||||
d := b / 4
|
||||
e := b % 4
|
||||
f := (b + 8) / 25
|
||||
g := (b - f + 1) / 3
|
||||
h := (19*a + b - d - g + 15) % 30
|
||||
i := c / 4
|
||||
k := c % 4
|
||||
l := (32 + 2*e + 2*i - h - k) % 7
|
||||
m := (a + 11*h + 22*l) / 451
|
||||
month := (h + l - 7*m + 114) / 31
|
||||
day := ((h + l - 7*m + 114) % 31) + 1
|
||||
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
// ── Nth Monday / Monday Before ──────────────────────────────────────────────
|
||||
|
||||
// nthMonday returns the nth Monday of the given month/year (1-indexed).
|
||||
func nthMonday(year int, month time.Month, n int) time.Time {
|
||||
first := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC)
|
||||
offset := (int(time.Monday) - int(first.Weekday()) + 7) % 7
|
||||
return first.AddDate(0, 0, offset+(n-1)*7)
|
||||
}
|
||||
|
||||
// mondayBeforeMay25 returns the last Monday on or before May 24 (Victoria Day).
|
||||
func mondayBeforeMay25(year int) time.Time {
|
||||
may24 := time.Date(year, time.May, 24, 0, 0, 0, 0, time.UTC)
|
||||
offset := (int(may24.Weekday()) - int(time.Monday) + 7) % 7
|
||||
return may24.AddDate(0, 0, -offset)
|
||||
}
|
||||
|
||||
// ── Hebrew Calendar (Molad + Dehiyyot) ──────────────────────────────────────
|
||||
//
|
||||
// Computes Rosh Hashanah from the mean lunation (molad) with the four
|
||||
// postponement rules (dehiyyot). All other Hebrew holidays derive from it.
|
||||
//
|
||||
// Day-of-week convention: 0=Mon, 1=Tue, 2=Wed, 3=Thu, 4=Fri, 5=Sat, 6=Sun.
|
||||
// Time is measured in halakim (parts): 1 hour = 1080 parts.
|
||||
//
|
||||
// Reference: Dershowitz & Reingold, "Calendrical Calculations"
|
||||
|
||||
func hebrewLeapYear(hYear int) bool {
|
||||
return (7*hYear+1)%19 < 7
|
||||
}
|
||||
|
||||
// hebrewNewYearDay returns the day number (from the Hebrew epoch) of 1 Tishrei
|
||||
// for the given Hebrew year, after applying the four dehiyyot.
|
||||
func hebrewNewYearDay(hYear int) int {
|
||||
y := hYear - 1
|
||||
months := 235*(y/19) + 12*(y%19) + (7*(y%19)+1)/19
|
||||
|
||||
// Molad BaHaRaD: day 2, 5 hours, 204 parts (Hebrew time, hours from 6 PM)
|
||||
// Lunar month: 29 days, 12 hours, 793 parts
|
||||
p := 204 + 793*(months%1080)
|
||||
h := 5 + 12*months + 793*(months/1080) + p/1080
|
||||
parts := p%1080 + 1080*(h%24) // time-of-day in parts (Hebrew hours from 6 PM)
|
||||
day := 2 + 29*months + h/24 // +2 for BaHaRaD day offset (day 2 = Monday)
|
||||
|
||||
// Weekday: 0=Sat, 1=Sun, 2=Mon, 3=Tue, 4=Wed, 5=Thu, 6=Fri
|
||||
dow := day % 7
|
||||
|
||||
// Apply dehiyyot (postponement rules)
|
||||
alt := parts >= 19440 || // Molad Zaken: molad at or after 18h (= noon civil)
|
||||
(dow == 3 && parts >= 9924 && !hebrewLeapYear(hYear)) || // GaTaRaD: Tue >= 9h204p
|
||||
(dow == 2 && parts >= 16789 && hebrewLeapYear(hYear-1)) // BeTuTaKPaT: Mon >= 15h589p
|
||||
if alt {
|
||||
day++
|
||||
dow = day % 7
|
||||
}
|
||||
|
||||
// Lo ADU: Rosh Hashanah cannot fall on Sun(1), Wed(4), or Fri(6)
|
||||
if dow == 1 || dow == 4 || dow == 6 {
|
||||
day++
|
||||
}
|
||||
|
||||
return day
|
||||
}
|
||||
|
||||
// hebrewEpochJDN: day 2 in the Hebrew count = JDN 347998 (Monday Oct 7, 3761 BCE Julian),
|
||||
// so day N maps to JDN = N + 347996.
|
||||
const hebrewEpochJDN = 347996
|
||||
|
||||
// hebrewRoshHashanah returns the Gregorian date of 1 Tishrei for the
|
||||
// Hebrew year starting in the given Gregorian year's autumn.
|
||||
func hebrewRoshHashanah(gregYear int) time.Time {
|
||||
hYear := gregYear + 3761
|
||||
return gregorianFromJDN(hebrewNewYearDay(hYear) + hebrewEpochJDN)
|
||||
}
|
||||
|
||||
// hebrewYomKippur returns the Gregorian date of 10 Tishrei (Rosh Hashanah + 9 days).
|
||||
func hebrewYomKippur(gregYear int) time.Time {
|
||||
return hebrewRoshHashanah(gregYear).AddDate(0, 0, 9)
|
||||
}
|
||||
|
||||
// hebrewPassover returns the Gregorian date of 15 Nisan for the given year.
|
||||
// 15 Nisan is always exactly 163 days before the next Rosh Hashanah.
|
||||
func hebrewPassover(gregYear int) time.Time {
|
||||
hYear := gregYear + 3761 // RH of this Hebrew year falls in gregYear's autumn
|
||||
nextRHDay := hebrewNewYearDay(hYear)
|
||||
return gregorianFromJDN(nextRHDay - 163 + hebrewEpochJDN)
|
||||
}
|
||||
|
||||
// hebrewHanukkah returns the Gregorian date of 25 Kislev for the Hebrew year
|
||||
// starting in the given Gregorian year's autumn.
|
||||
// Offset from RH depends on whether Cheshvan has 29 or 30 days (year type).
|
||||
func hebrewHanukkah(gregYear int) time.Time {
|
||||
hYear := gregYear + 3761
|
||||
rhDay := hebrewNewYearDay(hYear)
|
||||
yearLen := hebrewNewYearDay(hYear+1) - rhDay
|
||||
|
||||
// Cheshvan has 30 days in "complete" years (355 or 385 days)
|
||||
cheshvan := 29
|
||||
if yearLen == 355 || yearLen == 385 {
|
||||
cheshvan = 30
|
||||
}
|
||||
|
||||
// 25 Kislev = Tishrei(30) + Cheshvan + 24 days into Kislev
|
||||
offset := 30 + cheshvan + 24
|
||||
return gregorianFromJDN(rhDay + offset + hebrewEpochJDN)
|
||||
}
|
||||
|
||||
// ── Tabular Islamic Calendar ────────────────────────────────────────────────
|
||||
//
|
||||
// Approximates Islamic dates using the standard 30-year intercalation cycle.
|
||||
// Accuracy: ±1 day from observed dates (which depend on moon sighting).
|
||||
//
|
||||
// Reference: Dershowitz & Reingold, "Calendrical Calculations"
|
||||
|
||||
// islamicEpochRD is the Rata Die of 1 Muharram 1 AH (July 19, 622 CE Gregorian).
|
||||
const islamicEpochRD = 227015
|
||||
|
||||
// islamicToRD converts an Islamic date to Rata Die (days since Jan 1, 1 CE).
|
||||
func islamicToRD(year, month, day int) int {
|
||||
return islamicEpochRD - 1 +
|
||||
(year-1)*354 +
|
||||
(3+11*year)/30 +
|
||||
29*(month-1) +
|
||||
month/2 +
|
||||
day
|
||||
}
|
||||
|
||||
// tabularIslamicDates returns all Gregorian dates in the given Gregorian year
|
||||
// that correspond to the specified Islamic month/day. Usually returns one date,
|
||||
// but can return two since the Islamic calendar is ~11 days shorter per year.
|
||||
func tabularIslamicDates(gregYear, iMonth, iDay int) []time.Time {
|
||||
// Approximate Islamic year from Gregorian year
|
||||
approxYear := int(float64(gregYear-622) * 33.0 / 32.0)
|
||||
|
||||
var dates []time.Time
|
||||
for y := approxYear - 1; y <= approxYear+1; y++ {
|
||||
rd := islamicToRD(y, iMonth, iDay)
|
||||
d := gregorianFromRD(rd)
|
||||
if d.Year() == gregYear {
|
||||
dates = append(dates, d)
|
||||
}
|
||||
}
|
||||
return dates
|
||||
}
|
||||
|
||||
// gregorianFromRD converts a Rata Die to a Gregorian date.
|
||||
// R.D. 1 = January 1, 1 CE = JDN 1721426.
|
||||
func gregorianFromRD(rd int) time.Time {
|
||||
return gregorianFromJDN(rd + 1721425)
|
||||
}
|
||||
|
||||
// ── Julian Day Number ↔ Gregorian ──────────────────────────────────────────
|
||||
//
|
||||
// Meeus, "Astronomical Algorithms" — standard JDN conversion.
|
||||
|
||||
func gregorianFromJDN(jdn int) time.Time {
|
||||
a := jdn + 32044
|
||||
b := (4*a + 3) / 146097
|
||||
c := a - 146097*b/4
|
||||
d := (4*c + 3) / 1461
|
||||
e := c - 1461*d/4
|
||||
m := (5*e + 2) / 153
|
||||
|
||||
day := e - (153*m+2)/5 + 1
|
||||
month := m + 3 - 12*(m/10)
|
||||
year := 100*b + d - 4800 + m/10
|
||||
|
||||
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
Reference in New Issue
Block a user