diff --git a/data/sql/updates/pending_db_world/rev_1767341430742537600.sql b/data/sql/updates/pending_db_world/rev_1767341430742537600.sql new file mode 100644 index 000000000..2a9e30553 --- /dev/null +++ b/data/sql/updates/pending_db_world/rev_1767341430742537600.sql @@ -0,0 +1,30 @@ +-- correctly link brewfest building event in ironforge to brewfest +UPDATE `game_event` SET `holiday` = 372, `holidayStage` = 1 WHERE `eventEntry` = 70; + +-- remove start and end times from automatically handled events +UPDATE `game_event` SET `start_time` = NULL, `end_time` = NULL WHERE `eventEntry` IN +( + 1, -- Midsummer Fire Festival + 2, -- Winter Veil + 3, -- Darkmoon Faire (Terokkar Forest) + 4, -- Darkmoon Faire (Elwynn Forest) + 5, -- Darkmoon Faire (Mulgore) + 7, -- Lunar Festival + 8, -- Love is in the Air + 9, -- Noblegarden + 10, -- Children's Week + 11, -- Harvest Festival + 12, -- Hallow's End + 23, -- Darkmoon Faire Building (Elwynn Forest) + 24, -- Brewfest + 26, -- Pilgrim's Bounty + 50, -- Pirates' Day + 51, -- Day of the Dead + 70, -- Brewfest Building (Iron Forge) + 71, -- Darkmoon Faire Building (Mulgore) + 72, -- Fireworks Spectacular, + 77 -- Darkmoon Faire Building (Terokkar Forest) +); + +-- drop unused table +DROP TABLE IF EXISTS `holiday_dates`; diff --git a/src/server/database/Database/Implementation/WorldDatabase.cpp b/src/server/database/Database/Implementation/WorldDatabase.cpp index b1f7c65de..ea97d9a8c 100644 --- a/src/server/database/Database/Implementation/WorldDatabase.cpp +++ b/src/server/database/Database/Implementation/WorldDatabase.cpp @@ -98,7 +98,6 @@ void WorldDatabaseConnection::DoPrepareStatements() PrepareStatement(WORLD_SEL_GAME_EVENT_BATTLEGROUND_DATA, "SELECT eventEntry, bgflag FROM game_event_battleground_holiday", CONNECTION_SYNCH); PrepareStatement(WORLD_SEL_GAME_EVENT_POOL_DATA, "SELECT pool_template.entry, game_event_pool.eventEntry FROM pool_template JOIN game_event_pool ON pool_template.entry = game_event_pool.pool_entry", CONNECTION_SYNCH); PrepareStatement(WORLD_SEL_GAME_EVENT_ARENA_SEASON, "SELECT eventEntry FROM game_event_arena_seasons WHERE season = ?", CONNECTION_SYNCH); - PrepareStatement(WORLD_SEL_GAME_EVENT_HOLIDAY_DATES, "SELECT id, date_id, date_value, holiday_duration FROM holiday_dates", CONNECTION_SYNCH); PrepareStatement(WORLD_DEL_GAME_EVENT_CREATURE, "DELETE FROM game_event_creature WHERE guid = ?", CONNECTION_ASYNC); PrepareStatement(WORLD_DEL_GAME_EVENT_MODEL_EQUIP, "DELETE FROM game_event_model_equip WHERE guid = ?", CONNECTION_ASYNC); PrepareStatement(WORLD_SEL_GAME_EVENT_NPC_VENDOR, "SELECT eventEntry, guid, item, maxcount, incrtime, ExtendedCost FROM game_event_npc_vendor ORDER BY guid, slot ASC", CONNECTION_SYNCH); diff --git a/src/server/database/Database/Implementation/WorldDatabase.h b/src/server/database/Database/Implementation/WorldDatabase.h index e6db156be..d80c854ea 100644 --- a/src/server/database/Database/Implementation/WorldDatabase.h +++ b/src/server/database/Database/Implementation/WorldDatabase.h @@ -104,7 +104,6 @@ enum WorldDatabaseStatements : uint32 WORLD_SEL_GAME_EVENT_BATTLEGROUND_DATA, WORLD_SEL_GAME_EVENT_POOL_DATA, WORLD_SEL_GAME_EVENT_ARENA_SEASON, - WORLD_SEL_GAME_EVENT_HOLIDAY_DATES, WORLD_DEL_GAME_EVENT_CREATURE, WORLD_DEL_GAME_EVENT_MODEL_EQUIP, WORLD_SEL_GAME_EVENT_NPC_VENDOR, diff --git a/src/server/game/Events/GameEventMgr.cpp b/src/server/game/Events/GameEventMgr.cpp index 509d5235e..b4e6919af 100644 --- a/src/server/game/Events/GameEventMgr.cpp +++ b/src/server/game/Events/GameEventMgr.cpp @@ -21,6 +21,7 @@ #include "DisableMgr.h" #include "GameObjectAI.h" #include "GameTime.h" +#include "HolidayDateCalculator.h" #include "Language.h" #include "Log.h" #include "MapMgr.h" @@ -34,7 +35,7 @@ #include "WorldSessionMgr.h" #include "WorldState.h" #include "WorldStatePackets.h" -#include +#include GameEventMgr* GameEventMgr::instance() { @@ -1070,51 +1071,126 @@ void GameEventMgr::LoadFromDB() void GameEventMgr::LoadHolidayDates() { - uint32 oldMSTime = getMSTime(); + uint32 const oldMSTime = getMSTime(); + uint32 dynamicCount = 0; + uint32 dbCount = 0; - WorldDatabasePreparedStatement* stmt = WorldDatabase.GetPreparedStatement(WORLD_SEL_GAME_EVENT_HOLIDAY_DATES); - PreparedQueryResult result = WorldDatabase.Query(stmt); + // Step 1: Generate dynamic holiday dates based on current year + std::chrono::system_clock::time_point const now = std::chrono::system_clock::now(); + std::time_t const nowTime = std::chrono::system_clock::to_time_t(now); + std::tm localTime = {}; +#ifdef _WIN32 + localtime_s(&localTime, &nowTime); +#else + localtime_r(&nowTime, &localTime); +#endif + int const currentYear = localTime.tm_year + 1900; - if (!result) + for (auto const& rule : HolidayDateCalculator::GetHolidayRules()) { - LOG_WARN("server.loading", ">> Loaded 0 holiday dates. DB table `holiday_dates` is empty."); - return; - } - - uint32 count = 0; - do - { - Field* fields = result->Fetch(); - - uint32 holidayId = fields[0].Get(); - HolidaysEntry* entry = const_cast(sHolidaysStore.LookupEntry(holidayId)); + HolidaysEntry* entry = const_cast(sHolidaysStore.LookupEntry(rule.holidayId)); if (!entry) { - LOG_ERROR("sql.sql", "holiday_dates entry has invalid holiday id {}.", holidayId); + LOG_INFO("server.loading", ">> Holiday {} not found in DBC - cannot set dynamic dates", rule.holidayId); continue; } - uint8 dateId = fields[1].Get(); - if (dateId >= MAX_HOLIDAY_DATES) + // Special handling for Darkmoon Faire - needs multiple dates per year (4 occurrences) + if (rule.type == HolidayCalculationType::DARKMOON_FAIRE) { - LOG_ERROR("sql.sql", "holiday_dates entry has out of range date_id {}.", dateId); + int const locationOffset = rule.month; + std::vector const dates = HolidayDateCalculator::GetDarkmoonFaireDates(locationOffset, currentYear - 1, 4, rule.offset); + + uint8 dateId = 0; + for (auto const& packedDate : dates) + { + if (dateId >= MAX_HOLIDAY_DATES) + break; + + entry->Date[dateId++] = packedDate; + ++dynamicCount; + } + + // Darkmoon Faire lasts 7 days (168 hours) - set Duration if not already set + if (!entry->Duration[0]) + entry->Duration[0] = 168; // 7 days in hours + + auto itr = std::lower_bound(ModifiedHolidays.begin(), ModifiedHolidays.end(), entry->Id); + if (itr == ModifiedHolidays.end() || *itr != entry->Id) + ModifiedHolidays.insert(itr, entry->Id); + continue; } - entry->Date[dateId] = fields[2].Get(); - if (uint32 duration = fields[3].Get()) - entry->Duration[0] = duration; + // Generate dates for current year + 2 ahead (year capped at 2030 due to 5-bit client limitation) + for (int yearOffset = -1; yearOffset <= 2; ++yearOffset) + { + int const year = currentYear + yearOffset; + if (year > 2030) + break; + + uint8 const dateId = static_cast(yearOffset + 1); + if (dateId >= MAX_HOLIDAY_DATES) + break; + + uint32_t const packedDate = HolidayDateCalculator::GetPackedHolidayDate(rule.holidayId, year); + entry->Date[dateId] = packedDate; + + // Debug: decode and log the date + std::tm const date = HolidayDateCalculator::UnpackDate(packedDate); + LOG_DEBUG("server.loading", ">> Holiday {} Date[{}] = {}-{:02d}-{:02d}", + rule.holidayId, dateId, date.tm_year + 1900, date.tm_mon + 1, date.tm_mday); + + ++dynamicCount; + } auto itr = std::lower_bound(ModifiedHolidays.begin(), ModifiedHolidays.end(), entry->Id); if (itr == ModifiedHolidays.end() || *itr != entry->Id) - { ModifiedHolidays.insert(itr, entry->Id); - } + } - ++count; - } while (result->NextRow()); + // Step 2: Check game_event.start_time for overrides (allows custom servers to override calculated dates) + // Only use as override if start_time year >= current year (ignore old static dates) + QueryResult result = WorldDatabase.Query("SELECT holiday, UNIX_TIMESTAMP(start_time) FROM game_event WHERE holiday != 0 AND start_time > '2000-12-31'"); - LOG_INFO("server.loading", ">> Loaded {} Holiday Dates in {} ms", count, GetMSTimeDiffToNow(oldMSTime)); + if (result) + { + do + { + Field* fields = result->Fetch(); + + uint32 const holidayId = fields[0].Get(); + HolidaysEntry* entry = const_cast(sHolidaysStore.LookupEntry(holidayId)); + if (!entry) + continue; + + if (fields[1].IsNull()) + continue; + + time_t const startTime = fields[1].Get(); + if (startTime == 0) + continue; + + std::tm const timeInfo = Acore::Time::TimeBreakdown(startTime); + + int const year = timeInfo.tm_year + 1900; + // Only override if start_time is current year or later (ignore old static dates) + if (year < currentYear || year > 2030) + continue; + + // Pack the date in WoW format and override Date[0] + uint32_t const yearOffset = static_cast(year - 2000); + uint32_t const month = static_cast(timeInfo.tm_mon); + uint32_t const day = static_cast(timeInfo.tm_mday - 1); + uint32_t const weekday = static_cast(timeInfo.tm_wday); + entry->Date[0] = (yearOffset << 24) | (month << 20) | (day << 14) | (weekday << 11); + + ++dbCount; + } while (result->NextRow()); + } + + LOG_INFO("server.loading", ">> Loaded {} Holiday Dates ({} dynamic, {} game_event overrides) in {} ms", + dynamicCount + dbCount, dynamicCount, dbCount, GetMSTimeDiffToNow(oldMSTime)); } uint32 GameEventMgr::GetNPCFlag(Creature* cr) @@ -1926,7 +2002,7 @@ void GameEventMgr::SetHolidayEventTime(GameEventData& event) } else { - // date is due and not a singleDate event, try with next DBC date (modified by holiday_dates) + // date is due and not a singleDate event, try with next DBC date (dynamically calculated or overridden by game_event.start_time) // if none is found we don't modify start date and use the one in game_event } } diff --git a/src/server/game/Events/HolidayDateCalculator.cpp b/src/server/game/Events/HolidayDateCalculator.cpp new file mode 100644 index 000000000..8bb7f39e8 --- /dev/null +++ b/src/server/game/Events/HolidayDateCalculator.cpp @@ -0,0 +1,582 @@ +/* + * This file is part of the AzerothCore Project. See AUTHORS file for Copyright information + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +#include "HolidayDateCalculator.h" +#include "SharedDefines.h" +#include + +// Constants for astronomical calculations +constexpr double PI = 3.14159265358979323846; +constexpr double DEG_TO_RAD = PI / 180.0; + +// Helper: sin/cos in degrees +inline double sind(double deg) { return std::sin(deg * DEG_TO_RAD); } + +// Static holiday rules configuration +static const std::vector HolidayRules = { + // Lunar Festival: Chinese New Year - 1 day (event starts day before CNY) + { HOLIDAY_LUNAR_FESTIVAL, HolidayCalculationType::LUNAR_NEW_YEAR, 0, 0, 0, -1 }, + + // Love is in the Air: First Monday on or after Feb 3 + { HOLIDAY_LOVE_IS_IN_THE_AIR, HolidayCalculationType::WEEKDAY_ON_OR_AFTER, 2, 3, static_cast(Weekday::MONDAY), 0 }, + + // Noblegarden: Day after Easter Sunday (Easter + 1 day) + { HOLIDAY_NOBLEGARDEN, HolidayCalculationType::EASTER_OFFSET, 0, 0, 0, 1 }, + + // Children's Week: First Monday on or after Apr 25 (Monday closest to May 1) + { HOLIDAY_CHILDRENS_WEEK, HolidayCalculationType::WEEKDAY_ON_OR_AFTER, 4, 25, static_cast(Weekday::MONDAY), 0 }, + + // Midsummer Fire Festival: Fixed Jun 21 + { HOLIDAY_FIRE_FESTIVAL, HolidayCalculationType::FIXED_DATE, 6, 21, 0, 0 }, + + // Fireworks Spectacular: Fixed Jul 4 + { HOLIDAY_FIREWORKS_SPECTACULAR, HolidayCalculationType::FIXED_DATE, 7, 4, 0, 0 }, + + // Pirates' Day: Fixed Sep 19 + { HOLIDAY_PIRATES_DAY, HolidayCalculationType::FIXED_DATE, 9, 19, 0, 0 }, + + // Brewfest: Oktoberfest rule - first Saturday on/after Sept 15, minus 7 for holidayStage offset + { HOLIDAY_BREWFEST, HolidayCalculationType::WEEKDAY_ON_OR_AFTER, 9, 15, static_cast(Weekday::SATURDAY), -7 }, + + // Harvest Festival: 2 days before autumn equinox (Sept 20-21) + { HOLIDAY_HARVEST_FESTIVAL, HolidayCalculationType::AUTUMN_EQUINOX, 0, 0, 0, -2 }, + + // Hallow's End: Fixed Oct 18 + { HOLIDAY_HALLOWS_END, HolidayCalculationType::FIXED_DATE, 10, 18, 0, 0 }, + + // Day of the Dead: Fixed Nov 1 + { HOLIDAY_DAY_OF_DEAD, HolidayCalculationType::FIXED_DATE, 11, 1, 0, 0 }, + + // Pilgrim's Bounty: Sunday before Thanksgiving (4th Thursday - 4 days) + { HOLIDAY_PILGRIMS_BOUNTY, HolidayCalculationType::NTH_WEEKDAY, 11, 4, static_cast(Weekday::THURSDAY), -4 }, + + // Winter Veil: 6 days before winter solstice (Dec 15-16) + { HOLIDAY_FEAST_OF_WINTER_VEIL, HolidayCalculationType::WINTER_SOLSTICE, 0, 0, 0, -6 }, + + // Darkmoon Faire: First Sunday of months matching (month % 3 == locationOffset) + // Rotates monthly: Mulgore (Jan) -> Terokkar (Feb) -> Elwynn (Mar) -> repeat + // rule.month stores the location offset + // rule.offset is -2 (building phase starts Friday, 2 days before faire opens on Sunday) + { HOLIDAY_DARKMOON_FAIRE_ELWYNN, HolidayCalculationType::DARKMOON_FAIRE, 0, 0, 0, -2 }, // Mar, Jun, Sep, Dec + { HOLIDAY_DARKMOON_FAIRE_THUNDER, HolidayCalculationType::DARKMOON_FAIRE, 1, 0, 0, -2 }, // Jan, Apr, Jul, Oct + { HOLIDAY_DARKMOON_FAIRE_SHATTRATH, HolidayCalculationType::DARKMOON_FAIRE, 2, 0, 0, -2 } // Feb, May, Aug, Nov +}; + +const std::vector& HolidayDateCalculator::GetHolidayRules() +{ + return HolidayRules; +} + +std::tm HolidayDateCalculator::CalculateEasterSunday(int year) +{ + // Anonymous Gregorian algorithm (Computus) + // Reference: https://en.wikipedia.org/wiki/Date_of_Easter#Anonymous_Gregorian_algorithm + int const a = year % 19; + int const b = year / 100; + int const c = year % 100; + int const d = b / 4; + int const e = b % 4; + int const f = (b + 8) / 25; + int const g = (b - f + 1) / 3; + int const h = (19 * a + b - d - g + 15) % 30; + int const i = c / 4; + int const k = c % 4; + int const l = (32 + 2 * e + 2 * i - h - k) % 7; + int const m = (a + 11 * h + 22 * l) / 451; + int const month = (h + l - 7 * m + 114) / 31; + int const day = ((h + l - 7 * m + 114) % 31) + 1; + + std::tm result = {}; + result.tm_year = year - 1900; + result.tm_mon = month - 1; + result.tm_mday = day; + mktime(&result); // Normalize and fill in other fields + + return result; +} + +std::tm HolidayDateCalculator::CalculateNthWeekday(int year, int month, Weekday weekday, int n) +{ + // Start with first day of the month + std::tm date = {}; + date.tm_year = year - 1900; + date.tm_mon = month - 1; + date.tm_mday = 1; + mktime(&date); + + // Find first occurrence of the target weekday + int const daysUntilWeekday = (static_cast(weekday) - date.tm_wday + 7) % 7; + date.tm_mday = 1 + daysUntilWeekday; + + // Move to nth occurrence + date.tm_mday += (n - 1) * 7; + + mktime(&date); // Normalize (handles month overflow) + return date; +} + +std::tm HolidayDateCalculator::CalculateWeekdayOnOrAfter(int year, int month, int day, Weekday weekday) +{ + // Start with the specified date + std::tm date = {}; + date.tm_year = year - 1900; + date.tm_mon = month - 1; + date.tm_mday = day; + mktime(&date); + + // Find days until the target weekday (0 if already on that day) + int const daysUntilWeekday = (static_cast(weekday) - date.tm_wday + 7) % 7; + date.tm_mday += daysUntilWeekday; + + mktime(&date); // Normalize + return date; +} + +// ============================================================================ +// LUNAR NEW YEAR CALCULATION +// Based on Jean Meeus "Astronomical Algorithms" (1991), Chapter 49 +// Reference: https://celestialprogramming.com/moonphases.html +// Chinese New Year = new moon falling between January 21 and February 20 +// ============================================================================ + +double HolidayDateCalculator::DateToJulianDay(int year, int month, double day) +{ + if (month <= 2) + { + year -= 1; + month += 12; + } + int const A = year / 100; + int const B = 2 - A + (A / 4); + return std::floor(365.25 * (year + 4716)) + std::floor(30.6001 * (month + 1)) + day + B - 1524.5; +} + +void HolidayDateCalculator::JulianDayToDate(double jd, int& year, int& month, int& day) +{ + jd += 0.5; + int const Z = static_cast(jd); + int A = Z; + if (Z >= 2299161) + { + int const alpha = static_cast((Z - 1867216.25) / 36524.25); + A = Z + 1 + alpha - (alpha / 4); + } + int const B = A + 1524; + int const C = static_cast((B - 122.1) / 365.25); + int const D = static_cast(365.25 * C); + int const E = static_cast((B - D) / 30.6001); + + day = B - D - static_cast(30.6001 * E); + month = (E < 14) ? E - 1 : E - 13; + year = (month > 2) ? C - 4716 : C - 4715; +} + +double HolidayDateCalculator::CalculateNewMoon(double k) +{ + // Meeus "Astronomical Algorithms" Chapter 49 + double const T = k / 1236.85; + double const T2 = T * T; + double const T3 = T2 * T; + double const T4 = T3 * T; + + // Mean phase (Eq 49.1) + double const JDE = 2451550.09766 + 29.530588861 * k + 0.00015437 * T2 + - 0.000000150 * T3 + 0.00000000073 * T4; + + // Eccentricity correction + double const E = 1.0 - 0.002516 * T - 0.0000074 * T2; + double const E2 = E * E; + + // Sun's mean anomaly (Eq 49.4) + double const M = 2.5534 + 29.10535670 * k - 0.0000014 * T2 - 0.00000011 * T3; + + // Moon's mean anomaly (Eq 49.5) + double const MPrime = 201.5643 + 385.81693528 * k + 0.0107582 * T2 + + 0.00001238 * T3 - 0.000000058 * T4; + + // Moon's argument of latitude (Eq 49.6) + double const F = 160.7108 + 390.67050284 * k - 0.0016118 * T2 + - 0.00000227 * T3 + 0.000000011 * T4; + + // Longitude of ascending node (Eq 49.7) + double const Omega = 124.7746 - 1.56375588 * k + 0.0020672 * T2 + 0.00000215 * T3; + + // New Moon corrections (Table 49.A) + double correction = + - 0.40720 * sind(MPrime) + + 0.17241 * E * sind(M) + + 0.01608 * sind(2 * MPrime) + + 0.01039 * sind(2 * F) + + 0.00739 * E * sind(MPrime - M) + - 0.00514 * E * sind(MPrime + M) + + 0.00208 * E2 * sind(2 * M) + - 0.00111 * sind(MPrime - 2 * F) + - 0.00057 * sind(MPrime + 2 * F) + + 0.00056 * E * sind(2 * MPrime + M) + - 0.00042 * sind(3 * MPrime) + + 0.00042 * E * sind(M + 2 * F) + + 0.00038 * E * sind(M - 2 * F) + - 0.00024 * E * sind(2 * MPrime - M) + - 0.00017 * sind(Omega); + + // Additional planetary corrections (Table 49.B) + double const A1 = 299.77 + 0.107408 * k - 0.009173 * T2; + double const A2 = 251.88 + 0.016321 * k; + double const A3 = 251.83 + 26.651886 * k; + double const A4 = 349.42 + 36.412478 * k; + double const A5 = 84.66 + 18.206239 * k; + double const A6 = 141.74 + 53.303771 * k; + double const A7 = 207.14 + 2.453732 * k; + double const A8 = 154.84 + 7.306860 * k; + double const A9 = 34.52 + 27.261239 * k; + double const A10 = 207.19 + 0.121824 * k; + double const A11 = 291.34 + 1.844379 * k; + double const A12 = 161.72 + 24.198154 * k; + double const A13 = 239.56 + 25.513099 * k; + double const A14 = 331.55 + 3.592518 * k; + + correction += 0.000325 * sind(A1) + 0.000165 * sind(A2) + 0.000164 * sind(A3) + + 0.000126 * sind(A4) + 0.000110 * sind(A5) + 0.000062 * sind(A6) + + 0.000060 * sind(A7) + 0.000056 * sind(A8) + 0.000047 * sind(A9) + + 0.000042 * sind(A10) + 0.000040 * sind(A11) + 0.000037 * sind(A12) + + 0.000035 * sind(A13) + 0.000023 * sind(A14); + + return JDE + correction; +} + +std::tm HolidayDateCalculator::CalculateLunarNewYear(int year) +{ + // Chinese New Year always falls on the new moon between Jan 21 and Feb 20 + double const jan21JD = DateToJulianDay(year, 1, 21.0); + double const feb21JD = DateToJulianDay(year, 2, 21.0); + + // Approximate lunation number k for January of target year + double const approxK = (year - 2000.0) * 12.3685; + double const k = std::floor(approxK); + + // Search for the new moon in the valid range + for (int i = -2; i <= 2; ++i) + { + double const nmJDE = CalculateNewMoon(k + i); + + // Convert TT (Terrestrial Time) to UT (approximate DeltaT ~70s for 2020s) + double nmJD = nmJDE - 70.0 / 86400.0; + + // Add 8 hours for China Standard Time (UTC+8) + nmJD += 8.0 / 24.0; + + if (nmJD >= jan21JD && nmJD < feb21JD) + { + int cnyYear, cnyMonth, cnyDay; + JulianDayToDate(nmJD, cnyYear, cnyMonth, cnyDay); + + std::tm result = {}; + result.tm_year = cnyYear - 1900; + result.tm_mon = cnyMonth - 1; + result.tm_mday = cnyDay; + mktime(&result); + return result; + } + } + + // Fallback (should never happen for years 2000-2031) + std::tm fallback = {}; + fallback.tm_year = year - 1900; + fallback.tm_mon = 0; // January + fallback.tm_mday = 25; + mktime(&fallback); + return fallback; +} + +// ============================================================================ +// AUTUMN EQUINOX CALCULATION +// Based on Jean Meeus "Astronomical Algorithms" (1991), Chapter 27 +// Reference: https://en.wikipedia.org/wiki/Equinox#Calculation +// ============================================================================ + +std::tm HolidayDateCalculator::CalculateAutumnEquinox(int year) +{ + // Meeus algorithm for mean September equinox (Table 27.C) + // Valid for years 2000-3000 + double const Y = (year - 2000.0) / 1000.0; + double const Y2 = Y * Y; + double const Y3 = Y2 * Y; + double const Y4 = Y3 * Y; + + // Mean equinox JDE0 (Eq 27.1 for September equinox after 2000) + double const JDE0 = 2451810.21715 + 365242.01767 * Y - 0.11575 * Y2 + + 0.00337 * Y3 + 0.00078 * Y4; + + // Periodic terms for correction (Table 27.B) + double const T = (JDE0 - 2451545.0) / 36525.0; + double const W = 35999.373 * T - 2.47; + double const deltaLambda = 1.0 + 0.0334 * std::cos(W * DEG_TO_RAD) + + 0.0007 * std::cos(2.0 * W * DEG_TO_RAD); + + // Simplified correction (sum of periodic terms from Table 27.C) + // Using first few significant terms + double const S = 485 * std::cos((324.96 + 1934.136 * T) * DEG_TO_RAD) + + 203 * std::cos((337.23 + 32964.467 * T) * DEG_TO_RAD) + + 199 * std::cos((342.08 + 20.186 * T) * DEG_TO_RAD) + + 182 * std::cos((27.85 + 445267.112 * T) * DEG_TO_RAD) + + 156 * std::cos((73.14 + 45036.886 * T) * DEG_TO_RAD) + + 136 * std::cos((171.52 + 22518.443 * T) * DEG_TO_RAD) + + 77 * std::cos((222.54 + 65928.934 * T) * DEG_TO_RAD) + + 74 * std::cos((296.72 + 3034.906 * T) * DEG_TO_RAD) + + 70 * std::cos((243.58 + 9037.513 * T) * DEG_TO_RAD) + + 58 * std::cos((119.81 + 33718.147 * T) * DEG_TO_RAD) + + 52 * std::cos((297.17 + 150.678 * T) * DEG_TO_RAD) + + 50 * std::cos((21.02 + 2281.226 * T) * DEG_TO_RAD); + + double const JDE = JDE0 + (0.00001 * S) / deltaLambda; + + // Convert JDE to calendar date + int eqYear; + int eqMonth; + int eqDay; + JulianDayToDate(JDE, eqYear, eqMonth, eqDay); + + std::tm result = {}; + result.tm_year = eqYear - 1900; + result.tm_mon = eqMonth - 1; + result.tm_mday = eqDay; + mktime(&result); + return result; +} + +// ============================================================================ +// WINTER SOLSTICE CALCULATION +// Based on Jean Meeus "Astronomical Algorithms" (1991), Chapter 27 +// ============================================================================ + +std::tm HolidayDateCalculator::CalculateWinterSolstice(int year) +{ + // Meeus algorithm for mean December solstice (Table 27.C) + // Valid for years 2000-3000 + double const Y = (year - 2000.0) / 1000.0; + double const Y2 = Y * Y; + double const Y3 = Y2 * Y; + double const Y4 = Y3 * Y; + + // Mean solstice JDE0 (Eq 27.1 for December solstice after 2000) + double const JDE0 = 2451900.05952 + 365242.74049 * Y - 0.06223 * Y2 + - 0.00823 * Y3 + 0.00032 * Y4; + + // Periodic terms for correction (Table 27.B) + double const T = (JDE0 - 2451545.0) / 36525.0; + double const W = 35999.373 * T - 2.47; + double const deltaLambda = 1.0 + 0.0334 * std::cos(W * DEG_TO_RAD) + + 0.0007 * std::cos(2.0 * W * DEG_TO_RAD); + + // Simplified correction (sum of periodic terms from Table 27.C) + double const S = 485 * std::cos((324.96 + 1934.136 * T) * DEG_TO_RAD) + + 203 * std::cos((337.23 + 32964.467 * T) * DEG_TO_RAD) + + 199 * std::cos((342.08 + 20.186 * T) * DEG_TO_RAD) + + 182 * std::cos((27.85 + 445267.112 * T) * DEG_TO_RAD) + + 156 * std::cos((73.14 + 45036.886 * T) * DEG_TO_RAD) + + 136 * std::cos((171.52 + 22518.443 * T) * DEG_TO_RAD) + + 77 * std::cos((222.54 + 65928.934 * T) * DEG_TO_RAD) + + 74 * std::cos((296.72 + 3034.906 * T) * DEG_TO_RAD) + + 70 * std::cos((243.58 + 9037.513 * T) * DEG_TO_RAD) + + 58 * std::cos((119.81 + 33718.147 * T) * DEG_TO_RAD) + + 52 * std::cos((297.17 + 150.678 * T) * DEG_TO_RAD) + + 50 * std::cos((21.02 + 2281.226 * T) * DEG_TO_RAD); + + double const JDE = JDE0 + (0.00001 * S) / deltaLambda; + + // Convert JDE to calendar date + int solYear; + int solMonth; + int solDay; + JulianDayToDate(JDE, solYear, solMonth, solDay); + + std::tm result = {}; + result.tm_year = solYear - 1900; + result.tm_mon = solMonth - 1; + result.tm_mday = solDay; + mktime(&result); + return result; +} + +std::tm HolidayDateCalculator::CalculateHolidayDate(const HolidayRule& rule, int year) +{ + std::tm result = {}; + + switch (rule.type) + { + case HolidayCalculationType::FIXED_DATE: + { + result.tm_year = year - 1900; + result.tm_mon = rule.month - 1; + result.tm_mday = rule.day; + mktime(&result); + break; + } + case HolidayCalculationType::NTH_WEEKDAY: + { + result = CalculateNthWeekday(year, rule.month, static_cast(rule.weekday), rule.day); + if (rule.offset != 0) + { + result.tm_mday += rule.offset; + mktime(&result); // Normalize + } + break; + } + case HolidayCalculationType::EASTER_OFFSET: + { + result = CalculateEasterSunday(year); + result.tm_mday += rule.offset; + mktime(&result); // Normalize + break; + } + case HolidayCalculationType::LUNAR_NEW_YEAR: + { + result = CalculateLunarNewYear(year); + if (rule.offset != 0) + { + result.tm_mday += rule.offset; + mktime(&result); // Normalize + } + break; + } + case HolidayCalculationType::WEEKDAY_ON_OR_AFTER: + { + result = CalculateWeekdayOnOrAfter(year, rule.month, rule.day, static_cast(rule.weekday)); + if (rule.offset != 0) + { + result.tm_mday += rule.offset; + mktime(&result); // Normalize + } + break; + } + case HolidayCalculationType::AUTUMN_EQUINOX: + { + result = CalculateAutumnEquinox(year); + if (rule.offset != 0) + { + result.tm_mday += rule.offset; + mktime(&result); // Normalize + } + break; + } + case HolidayCalculationType::WINTER_SOLSTICE: + { + result = CalculateWinterSolstice(year); + if (rule.offset != 0) + { + result.tm_mday += rule.offset; + mktime(&result); // Normalize + } + break; + } + case HolidayCalculationType::DARKMOON_FAIRE: + { + // Return first occurrence for the year + // rule.month contains the location offset (0, 1, or 2) + int const locationOffset = rule.month; + + // Find first month in the year where month % 3 == locationOffset + for (int month = 1; month <= 12; ++month) + { + if (month % 3 == locationOffset) + { + result = CalculateNthWeekday(year, month, Weekday::SUNDAY, 1); + break; + } + } + break; + } + } + + return result; +} + +uint32_t HolidayDateCalculator::PackDate(const std::tm& date) +{ + // WoW packed date format (same as ByteBuffer::AppendPackedTime): + // bits 24-28: year offset from 2000 (5 bits = 0-31, valid years 2000-2031) + // bits 20-23: month (0-indexed) + // bits 14-19: day (0-indexed) + // bits 11-13: weekday (0=Sunday, 6=Saturday - POSIX tm_wday) + // bits 6-10: hour + // bits 0-5: minute + int const year = date.tm_year + 1900; + // Client uses 5-bit year offset from 2000, so years before 2000 clamp to 0. + // If client is patched to support earlier years, update this logic. + uint32_t const yearOffset = (year < 2000) ? 0 : static_cast(year - 2000); + uint32_t const month = static_cast(date.tm_mon); // Already 0-indexed + uint32_t const day = static_cast(date.tm_mday - 1); // Convert to 0-indexed + uint32_t const weekday = static_cast(date.tm_wday); // 0=Sunday, 6=Saturday + + return (yearOffset << 24) | (month << 20) | (day << 14) | (weekday << 11); +} + +std::tm HolidayDateCalculator::UnpackDate(uint32_t packed) +{ + std::tm result = {}; + result.tm_year = static_cast(((packed >> 24) & 0x1F) + 2000 - 1900); + result.tm_mon = static_cast((packed >> 20) & 0xF); + result.tm_mday = static_cast(((packed >> 14) & 0x3F) + 1); + result.tm_wday = static_cast((packed >> 11) & 0x7); + result.tm_hour = static_cast((packed >> 6) & 0x1F); + result.tm_min = static_cast(packed & 0x3F); + mktime(&result); // Normalize and fill in tm_yday, tm_isdst + return result; +} + +uint32_t HolidayDateCalculator::GetPackedHolidayDate(uint32_t holidayId, int year) +{ + for (auto const& rule : HolidayRules) + { + if (rule.holidayId == holidayId) + { + std::tm const date = CalculateHolidayDate(rule, year); + return PackDate(date); + } + } + return 0; // Holiday not found +} + +std::vector HolidayDateCalculator::GetDarkmoonFaireDates(int locationOffset, int startYear, int numYears, int dayOffset) +{ + std::vector dates; + + // Darkmoon Faire is first Sunday of months where (month % 3) == locationOffset + // locationOffset 0: Mar, Jun, Sep, Dec - Elwynn (Alliance) + // locationOffset 1: Jan, Apr, Jul, Oct - Mulgore (Horde) + // locationOffset 2: Feb, May, Aug, Nov - Terokkar (Outland) + + for (int year = startYear; year < startYear + numYears && year <= 2030; ++year) + { + for (int month = 1; month <= 12; ++month) + { + if (month % 3 == locationOffset) + { + // Calculate first Sunday of this month, then apply day offset + std::tm date = CalculateNthWeekday(year, month, Weekday::SUNDAY, 1); + if (dayOffset != 0) + { + date.tm_mday += dayOffset; + mktime(&date); // Normalize + } + dates.push_back(PackDate(date)); + } + } + } + + return dates; +} diff --git a/src/server/game/Events/HolidayDateCalculator.h b/src/server/game/Events/HolidayDateCalculator.h new file mode 100644 index 000000000..7ba3eae4f --- /dev/null +++ b/src/server/game/Events/HolidayDateCalculator.h @@ -0,0 +1,112 @@ +/* + * This file is part of the AzerothCore Project. See AUTHORS file for Copyright information + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +#ifndef ACORE_HOLIDAY_DATE_CALCULATOR_H +#define ACORE_HOLIDAY_DATE_CALCULATOR_H + +#include +#include +#include + +enum class HolidayCalculationType +{ + FIXED_DATE, // Same month/day every year (e.g., Dec 25) + NTH_WEEKDAY, // Nth weekday of month (e.g., 4th Thursday of Nov) + EASTER_OFFSET, // Days relative to Easter Sunday + LUNAR_NEW_YEAR, // Chinese New Year (new moon between Jan 21 - Feb 20) + WEEKDAY_ON_OR_AFTER, // First weekday on or after a date (e.g., first Monday on or after Feb 3) + AUTUMN_EQUINOX, // Days relative to autumn equinox (offset in days) + WINTER_SOLSTICE, // Days relative to winter solstice (offset in days) + DARKMOON_FAIRE // First Sunday of months matching (month % 3 == locationOffset) +}; + +enum class Weekday +{ + SUNDAY = 0, + MONDAY, + TUESDAY, + WEDNESDAY, + THURSDAY, + FRIDAY, + SATURDAY +}; + +struct HolidayRule +{ + uint32_t holidayId; + HolidayCalculationType type; + int month; // 1-12 + int day; // For FIXED_DATE: day of month. For NTH_WEEKDAY: which occurrence (1-5) + int weekday; // For NTH_WEEKDAY: 0=Sunday through 6=Saturday + int offset; // For EASTER_OFFSET: days after Easter (can be negative) +}; + +class HolidayDateCalculator +{ +public: + // Calculate Easter Sunday for a given year (Computus algorithm) + static std::tm CalculateEasterSunday(int year); + + // Calculate Nth weekday of a month (e.g., 4th Thursday of November) + static std::tm CalculateNthWeekday(int year, int month, Weekday weekday, int n); + + // Calculate first weekday on or after a specific date (e.g., first Monday on or after Feb 3) + static std::tm CalculateWeekdayOnOrAfter(int year, int month, int day, Weekday weekday); + + // Calculate Chinese New Year (Lunar New Year) using astronomical algorithm + // Based on Jean Meeus "Astronomical Algorithms" - finds new moon between Jan 21 - Feb 20 + static std::tm CalculateLunarNewYear(int year); + + // Calculate Autumn Equinox using astronomical algorithm + // Based on Jean Meeus "Astronomical Algorithms" Chapter 27 + static std::tm CalculateAutumnEquinox(int year); + + // Calculate Winter Solstice using astronomical algorithm + // Based on Jean Meeus "Astronomical Algorithms" Chapter 27 + static std::tm CalculateWinterSolstice(int year); + + // Calculate holiday start date for a given year + static std::tm CalculateHolidayDate(const HolidayRule& rule, int year); + + // Convert std::tm to WoW's packed date format + static uint32_t PackDate(const std::tm& date); + + // Convert WoW's packed date format to std::tm + static std::tm UnpackDate(uint32_t packed); + + // Get all holiday rules + static const std::vector& GetHolidayRules(); + + // Calculate date for a specific holiday ID and year + static uint32_t GetPackedHolidayDate(uint32_t holidayId, int year); + + // Calculate Darkmoon Faire dates for a location over a year range + // locationOffset: 0=Elwynn (months 3,6,9,12), 1=Mulgore (months 1,4,7,10), 2=Terokkar (months 2,5,8,11) + // dayOffset: days to offset from first Sunday (e.g., -2 for Friday start) + // Returns packed dates for all occurrences in the year range + static std::vector GetDarkmoonFaireDates(int locationOffset, int startYear, int numYears, int dayOffset = 0); + +private: + // Julian Date conversions for lunar calculations + static double DateToJulianDay(int year, int month, double day); + static void JulianDayToDate(double jd, int& year, int& month, int& day); + + // Calculate new moon Julian Date for lunation k (Meeus algorithm) + static double CalculateNewMoon(double k); +}; + +#endif // ACORE_HOLIDAY_DATE_CALCULATOR_H diff --git a/src/test/server/game/Events/HolidayDateCalculatorTest.cpp b/src/test/server/game/Events/HolidayDateCalculatorTest.cpp new file mode 100644 index 000000000..d6d907e8a --- /dev/null +++ b/src/test/server/game/Events/HolidayDateCalculatorTest.cpp @@ -0,0 +1,1253 @@ +/* + * This file is part of the AzerothCore Project. See AUTHORS file for Copyright information + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +#include "HolidayDateCalculator.h" +#include "gtest/gtest.h" + +class HolidayDateCalculatorTest : public ::testing::Test +{ +protected: + void ExpectDate(const std::tm& date, int year, int month, int day) + { + EXPECT_EQ(date.tm_year + 1900, year); + EXPECT_EQ(date.tm_mon + 1, month); + EXPECT_EQ(date.tm_mday, day); + } + + // Helper to verify a date is a valid calendar date + bool IsValidDate(int year, int month, int day) + { + if (month < 1 || month > 12) return false; + if (day < 1 || day > 31) return false; + + // Check days in month + int daysInMonth[] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; + + // Leap year check for February + if (month == 2) + { + bool isLeap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); + if (isLeap) daysInMonth[2] = 29; + } + + return day <= daysInMonth[month]; + } + + // Helper to check if a year is a leap year + bool IsLeapYear(int year) + { + return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); + } +}; + +// ============================================================ +// Easter Calculation Tests (1900-2200) +// ============================================================ + +TEST_F(HolidayDateCalculatorTest, EasterSunday_KnownDates) +{ + // Verify against known Easter dates + // Source: https://www.census.gov/data/software/x13as/genhol/easter-dates.html + struct EasterTestCase { int year; int month; int day; }; + std::vector testCases = { + // Historical dates + { 1900, 4, 15 }, + { 1901, 4, 7 }, + { 1950, 4, 9 }, + { 1999, 4, 4 }, + // Recent dates + { 2000, 4, 23 }, + { 2010, 4, 4 }, + { 2020, 4, 12 }, + { 2021, 4, 4 }, + { 2022, 4, 17 }, + { 2023, 4, 9 }, + { 2024, 3, 31 }, + { 2025, 4, 20 }, + { 2026, 4, 5 }, + { 2027, 3, 28 }, + { 2028, 4, 16 }, + { 2029, 4, 1 }, + { 2030, 4, 21 }, + // Future dates + { 2050, 4, 10 }, + { 2100, 3, 28 }, + { 2150, 4, 12 }, + { 2200, 4, 6 }, + }; + + for (auto const& tc : testCases) + { + std::tm easter = HolidayDateCalculator::CalculateEasterSunday(tc.year); + SCOPED_TRACE("Year: " + std::to_string(tc.year)); + ExpectDate(easter, tc.year, tc.month, tc.day); + } +} + +TEST_F(HolidayDateCalculatorTest, EasterSunday_ValidDateRange_1900_2200) +{ + // Easter must always fall between March 22 and April 25 (inclusive) + for (int year = 1900; year <= 2200; ++year) + { + std::tm easter = HolidayDateCalculator::CalculateEasterSunday(year); + + SCOPED_TRACE("Year: " + std::to_string(year)); + + // Verify year is correct + EXPECT_EQ(easter.tm_year + 1900, year); + + // Easter must be in March or April + EXPECT_TRUE(easter.tm_mon == 2 || easter.tm_mon == 3) + << "Easter must be in March (2) or April (3), got month " << easter.tm_mon; + + // Easter range: March 22 - April 25 + if (easter.tm_mon == 2) // March + { + EXPECT_GE(easter.tm_mday, 22) << "Easter in March must be >= 22"; + EXPECT_LE(easter.tm_mday, 31) << "Easter in March must be <= 31"; + } + else // April + { + EXPECT_GE(easter.tm_mday, 1) << "Easter in April must be >= 1"; + EXPECT_LE(easter.tm_mday, 25) << "Easter in April must be <= 25"; + } + + // Easter must be a Sunday + EXPECT_EQ(easter.tm_wday, 0) << "Easter must be a Sunday"; + } +} + +TEST_F(HolidayDateCalculatorTest, EasterSunday_AlwaysSunday_1900_2200) +{ + // Verify Easter is always on Sunday for entire range + for (int year = 1900; year <= 2200; ++year) + { + std::tm easter = HolidayDateCalculator::CalculateEasterSunday(year); + EXPECT_EQ(easter.tm_wday, 0) << "Easter " << year << " should be Sunday"; + } +} + +// ============================================================ +// Nth Weekday Calculation Tests (1900-2200) +// ============================================================ + +TEST_F(HolidayDateCalculatorTest, NthWeekday_Thanksgiving_1900_2200) +{ + // Verify 4th Thursday of November for all years + for (int year = 1900; year <= 2200; ++year) + { + std::tm date = HolidayDateCalculator::CalculateNthWeekday(year, 11, Weekday::THURSDAY, 4); + + SCOPED_TRACE("Year: " + std::to_string(year)); + + // Must be in November + EXPECT_EQ(date.tm_mon + 1, 11); + + // Must be a Thursday + EXPECT_EQ(date.tm_wday, static_cast(Weekday::THURSDAY)); + + // 4th Thursday must be between 22nd and 28th + EXPECT_GE(date.tm_mday, 22); + EXPECT_LE(date.tm_mday, 28); + + // Verify it's actually the 4th occurrence + // First Thursday can be 1-7, so 4th is (first + 21) which gives range 22-28 + int firstThursday = date.tm_mday - 21; + EXPECT_GE(firstThursday, 1); + EXPECT_LE(firstThursday, 7); + } +} + +TEST_F(HolidayDateCalculatorTest, NthWeekday_AllWeekdays_1900_2200) +{ + // Test first occurrence of each weekday in January for all years + for (int year = 1900; year <= 2200; ++year) + { + for (int weekday = 0; weekday <= 6; ++weekday) + { + std::tm date = HolidayDateCalculator::CalculateNthWeekday(year, 1, static_cast(weekday), 1); + + SCOPED_TRACE("Year: " + std::to_string(year) + " Weekday: " + std::to_string(weekday)); + + // Must be in January + EXPECT_EQ(date.tm_mon + 1, 1); + + // Must be the correct weekday + EXPECT_EQ(date.tm_wday, weekday); + + // First occurrence must be within first 7 days + EXPECT_GE(date.tm_mday, 1); + EXPECT_LE(date.tm_mday, 7); + } + } +} + +TEST_F(HolidayDateCalculatorTest, NthWeekday_SecondThirdFourth_Validation) +{ + // Verify 2nd, 3rd, 4th occurrences are exactly 7 days apart + for (int year = 2000; year <= 2100; ++year) + { + for (int month = 1; month <= 12; ++month) + { + std::tm first = HolidayDateCalculator::CalculateNthWeekday(year, month, Weekday::MONDAY, 1); + std::tm second = HolidayDateCalculator::CalculateNthWeekday(year, month, Weekday::MONDAY, 2); + std::tm third = HolidayDateCalculator::CalculateNthWeekday(year, month, Weekday::MONDAY, 3); + std::tm fourth = HolidayDateCalculator::CalculateNthWeekday(year, month, Weekday::MONDAY, 4); + + SCOPED_TRACE("Year: " + std::to_string(year) + " Month: " + std::to_string(month)); + + EXPECT_EQ(second.tm_mday - first.tm_mday, 7); + EXPECT_EQ(third.tm_mday - second.tm_mday, 7); + EXPECT_EQ(fourth.tm_mday - third.tm_mday, 7); + } + } +} + +// ============================================================ +// Date Packing/Unpacking Tests +// Note: Pack format uses 5 bits for year offset from 2000, so range is 2000-2031 +// ============================================================ + +TEST_F(HolidayDateCalculatorTest, PackDate_RoundTrip) +{ + // Test that pack/unpack preserves date information correctly + struct PackTestCase { int year; int month; int day; }; + std::vector testCases = { + { 2000, 1, 1 }, // Min year in pack range + { 2000, 12, 31 }, // End of min year + { 2015, 6, 15 }, // Mid range + { 2020, 1, 24 }, // Lunar Festival 2020 + { 2025, 11, 27 }, // Thanksgiving 2025 + { 2030, 4, 28 }, // Noblegarden 2030 + { 2031, 12, 31 }, // Max year in pack range + }; + + for (auto const& tc : testCases) + { + std::tm date = {}; + date.tm_year = tc.year - 1900; + date.tm_mon = tc.month - 1; + date.tm_mday = tc.day; + mktime(&date); + + uint32_t packed = HolidayDateCalculator::PackDate(date); + std::tm unpacked = HolidayDateCalculator::UnpackDate(packed); + + SCOPED_TRACE("Date: " + std::to_string(tc.year) + "-" + + std::to_string(tc.month) + "-" + std::to_string(tc.day)); + ExpectDate(unpacked, tc.year, tc.month, tc.day); + } +} + +TEST_F(HolidayDateCalculatorTest, PackUnpack_Roundtrip_FullRange) +{ + // Test pack/unpack for entire valid range (2000-2031) + for (int year = 2000; year <= 2031; ++year) + { + for (int month = 1; month <= 12; ++month) + { + for (int day = 1; day <= 28; ++day) // Safe range for all months + { + std::tm original = {}; + original.tm_year = year - 1900; + original.tm_mon = month - 1; + original.tm_mday = day; + mktime(&original); + + uint32_t packed = HolidayDateCalculator::PackDate(original); + std::tm unpacked = HolidayDateCalculator::UnpackDate(packed); + + EXPECT_EQ(original.tm_year, unpacked.tm_year); + EXPECT_EQ(original.tm_mon, unpacked.tm_mon); + EXPECT_EQ(original.tm_mday, unpacked.tm_mday); + } + } + } +} + +TEST_F(HolidayDateCalculatorTest, UnpackDate_KnownValues) +{ + struct UnpackTestCase { uint32_t packed; int year; int month; int day; }; + std::vector testCases = { + { 335921152, 2020, 1, 24 }, + { 352681984, 2021, 1, 23 }, + { 336707584, 2020, 2, 8 }, + { 346390592, 2020, 11, 23 }, + }; + + for (auto const& tc : testCases) + { + std::tm date = HolidayDateCalculator::UnpackDate(tc.packed); + SCOPED_TRACE("Packed: " + std::to_string(tc.packed)); + ExpectDate(date, tc.year, tc.month, tc.day); + } +} + +// ============================================================ +// Noblegarden (Easter-based) Tests - Extended Range +// ============================================================ + +TEST_F(HolidayDateCalculatorTest, Noblegarden_DayAfterEaster_1900_2200) +{ + // Noblegarden should be Easter + 1 day (Monday after Easter) for all years + for (int year = 1900; year <= 2200; ++year) + { + std::tm easter = HolidayDateCalculator::CalculateEasterSunday(year); + + // Calculate expected Noblegarden date (Easter + 1) + std::tm expectedNoblegarden = easter; + expectedNoblegarden.tm_mday += 1; + mktime(&expectedNoblegarden); // Normalize (handles month rollover) + + // Get calculated Noblegarden from holiday rule + HolidayRule noblegarden = { 181, HolidayCalculationType::EASTER_OFFSET, 0, 0, 0, 1 }; + std::tm calculated = HolidayDateCalculator::CalculateHolidayDate(noblegarden, year); + + SCOPED_TRACE("Year: " + std::to_string(year)); + + EXPECT_EQ(calculated.tm_year, expectedNoblegarden.tm_year); + EXPECT_EQ(calculated.tm_mon, expectedNoblegarden.tm_mon); + EXPECT_EQ(calculated.tm_mday, expectedNoblegarden.tm_mday); + + // Noblegarden should be Monday (1 day after Easter Sunday) + EXPECT_EQ(calculated.tm_wday, 1) << "Noblegarden should be Monday"; + } +} + +// ============================================================ +// Pilgrim's Bounty (Thanksgiving) Tests - Extended Range +// ============================================================ + +TEST_F(HolidayDateCalculatorTest, PilgrimsBounty_SundayBeforeThanksgiving_1900_2200) +{ + // Pilgrim's Bounty = Sunday before Thanksgiving (4th Thursday - 4 days) + for (int year = 1900; year <= 2200; ++year) + { + // Calculate 4th Thursday of November + std::tm thanksgiving = HolidayDateCalculator::CalculateNthWeekday(year, 11, Weekday::THURSDAY, 4); + + // Pilgrim's Bounty starts on Sunday before (4 days earlier) + std::tm expectedPilgrims = thanksgiving; + expectedPilgrims.tm_mday -= 4; + mktime(&expectedPilgrims); + + // Get calculated date using rule with -4 offset + HolidayRule pilgrimsBounty = { 404, HolidayCalculationType::NTH_WEEKDAY, 11, 4, static_cast(Weekday::THURSDAY), -4 }; + std::tm date = HolidayDateCalculator::CalculateHolidayDate(pilgrimsBounty, year); + + SCOPED_TRACE("Year: " + std::to_string(year)); + + EXPECT_EQ(date.tm_year + 1900, year); + EXPECT_EQ(date.tm_mon + 1, 11); // November + EXPECT_EQ(date.tm_wday, 0); // Sunday + EXPECT_EQ(date.tm_mday, expectedPilgrims.tm_mday); + + // Sunday before 4th Thursday should be between 18th and 24th + EXPECT_GE(date.tm_mday, 18); + EXPECT_LE(date.tm_mday, 24); + } +} + +// ============================================================ +// Fixed Date Holidays Tests - Extended Range +// ============================================================ + +TEST_F(HolidayDateCalculatorTest, FixedDateHolidays_ConsistentAcrossYears_1900_2200) +{ + // Fixed date holidays should have same month/day every year + // Note: Brewfest, Harvest Festival, and Winter Veil are now dynamic (not fixed date) + struct FixedHolidayTestCase { uint32_t holidayId; int month; int day; const char* name; }; + std::vector testCases = { + { 341, 6, 21, "Midsummer Fire Festival" }, + { 62, 7, 4, "Fireworks Spectacular" }, + { 398, 9, 19, "Pirates' Day" }, + { 324, 10, 18, "Hallow's End" }, + { 409, 11, 1, "Day of the Dead" }, + }; + + for (auto const& tc : testCases) + { + HolidayRule rule = { tc.holidayId, HolidayCalculationType::FIXED_DATE, tc.month, tc.day, 0, 0 }; + + for (int year = 1900; year <= 2200; ++year) + { + std::tm date = HolidayDateCalculator::CalculateHolidayDate(rule, year); + + SCOPED_TRACE(std::string(tc.name) + " Year: " + std::to_string(year)); + + EXPECT_EQ(date.tm_year + 1900, year); + EXPECT_EQ(date.tm_mon + 1, tc.month); + EXPECT_EQ(date.tm_mday, tc.day); + } + } +} + +// ============================================================ +// Brewfest Tests (Oktoberfest rule) +// First Saturday on or after Sept 15, minus 7 days for holidayStage offset +// ============================================================ + +TEST_F(HolidayDateCalculatorTest, Brewfest_OktoberfestRule) +{ + // Brewfest follows the Oktoberfest rule: + // Oktoberfest starts the Saturday after Sept 15 (or on Sept 15 if it's Saturday) + // Brewfest holidayStage 1 starts 7 days before that + HolidayRule brewfest = { 372, HolidayCalculationType::WEEKDAY_ON_OR_AFTER, 9, 15, static_cast(Weekday::SATURDAY), -7 }; + + struct BrewfestTestCase { int year; int expectedMonth; int expectedDay; }; + std::vector testCases = { + // Sept 15, 2024 is Sunday, first Sat after is Sept 21, minus 7 = Sept 14 + { 2024, 9, 14 }, + // Sept 15, 2025 is Monday, first Sat after is Sept 20, minus 7 = Sept 13 + { 2025, 9, 13 }, + // Sept 15, 2026 is Tuesday, first Sat after is Sept 19, minus 7 = Sept 12 + { 2026, 9, 12 }, + // Sept 15, 2027 is Wednesday, first Sat after is Sept 18, minus 7 = Sept 11 + { 2027, 9, 11 }, + // Sept 15, 2028 is Friday, first Sat after is Sept 16, minus 7 = Sept 9 + { 2028, 9, 9 }, + // Sept 15, 2029 is Saturday, so Sept 15, minus 7 = Sept 8 + { 2029, 9, 8 }, + }; + + for (auto const& tc : testCases) + { + std::tm date = HolidayDateCalculator::CalculateHolidayDate(brewfest, tc.year); + + SCOPED_TRACE("Year: " + std::to_string(tc.year)); + + EXPECT_EQ(date.tm_year + 1900, tc.year); + EXPECT_EQ(date.tm_mon + 1, tc.expectedMonth); + EXPECT_EQ(date.tm_mday, tc.expectedDay); + } +} + +TEST_F(HolidayDateCalculatorTest, Brewfest_AlwaysInSeptember_1900_2200) +{ + HolidayRule brewfest = { 372, HolidayCalculationType::WEEKDAY_ON_OR_AFTER, 9, 15, static_cast(Weekday::SATURDAY), -7 }; + + for (int year = 1900; year <= 2200; ++year) + { + std::tm date = HolidayDateCalculator::CalculateHolidayDate(brewfest, year); + + SCOPED_TRACE("Year: " + std::to_string(year)); + + // Brewfest should always be in September (after -7 offset from Sept 15-21) + EXPECT_EQ(date.tm_mon + 1, 9) << "Brewfest should be in September"; + // Should be between Sept 8 and Sept 14 (7 days before Sept 15-21) + EXPECT_GE(date.tm_mday, 8) << "Brewfest should be >= Sept 8"; + EXPECT_LE(date.tm_mday, 14) << "Brewfest should be <= Sept 14"; + } +} + +// ============================================================ +// Harvest Festival Tests (Autumn Equinox based) +// 2 days before autumn equinox +// ============================================================ + +TEST_F(HolidayDateCalculatorTest, HarvestFestival_AutumnEquinoxBased) +{ + HolidayRule harvestFestival = { 321, HolidayCalculationType::AUTUMN_EQUINOX, 0, 0, 0, -2 }; + + // Autumn equinox is typically Sept 22-23, so Harvest Festival is Sept 20-21 + for (int year = 2000; year <= 2030; ++year) + { + std::tm equinox = HolidayDateCalculator::CalculateAutumnEquinox(year); + std::tm harvest = HolidayDateCalculator::CalculateHolidayDate(harvestFestival, year); + + SCOPED_TRACE("Year: " + std::to_string(year)); + + // Harvest should be exactly 2 days before equinox + // Convert to time_t to handle month boundaries correctly + time_t equinoxTime = mktime(&equinox); + time_t harvestTime = mktime(&harvest); + + double diffDays = difftime(equinoxTime, harvestTime) / (60 * 60 * 24); + EXPECT_NEAR(diffDays, 2.0, 0.1) << "Harvest Festival should be 2 days before equinox"; + } +} + +TEST_F(HolidayDateCalculatorTest, HarvestFestival_AlwaysInSeptember_1900_2200) +{ + HolidayRule harvestFestival = { 321, HolidayCalculationType::AUTUMN_EQUINOX, 0, 0, 0, -2 }; + + for (int year = 1900; year <= 2200; ++year) + { + std::tm date = HolidayDateCalculator::CalculateHolidayDate(harvestFestival, year); + + SCOPED_TRACE("Year: " + std::to_string(year)); + + // Harvest Festival should always be in September (2 days before Sept 22-23 equinox) + EXPECT_EQ(date.tm_mon + 1, 9) << "Harvest Festival should be in September"; + // Should be around Sept 20-21 + EXPECT_GE(date.tm_mday, 18) << "Harvest Festival should be >= Sept 18"; + EXPECT_LE(date.tm_mday, 22) << "Harvest Festival should be <= Sept 22"; + } +} + +// ============================================================ +// Winter Veil Tests (Winter Solstice based) +// 6 days before winter solstice +// ============================================================ + +TEST_F(HolidayDateCalculatorTest, WinterVeil_WinterSolsticeBased) +{ + HolidayRule winterVeil = { 141, HolidayCalculationType::WINTER_SOLSTICE, 0, 0, 0, -6 }; + + // Winter solstice is typically Dec 21-22, so Winter Veil starts Dec 15-16 + for (int year = 2000; year <= 2030; ++year) + { + std::tm solstice = HolidayDateCalculator::CalculateWinterSolstice(year); + std::tm winterVeilDate = HolidayDateCalculator::CalculateHolidayDate(winterVeil, year); + + SCOPED_TRACE("Year: " + std::to_string(year)); + + // Winter Veil should be exactly 6 days before solstice + time_t solsticeTime = mktime(&solstice); + time_t winterVeilTime = mktime(&winterVeilDate); + + double diffDays = difftime(solsticeTime, winterVeilTime) / (60 * 60 * 24); + EXPECT_NEAR(diffDays, 6.0, 0.1) << "Winter Veil should be 6 days before solstice"; + } +} + +TEST_F(HolidayDateCalculatorTest, WinterVeil_AlwaysInDecember_1900_2200) +{ + HolidayRule winterVeil = { 141, HolidayCalculationType::WINTER_SOLSTICE, 0, 0, 0, -6 }; + + for (int year = 1900; year <= 2200; ++year) + { + std::tm date = HolidayDateCalculator::CalculateHolidayDate(winterVeil, year); + + SCOPED_TRACE("Year: " + std::to_string(year)); + + // Winter Veil should always be in December + EXPECT_EQ(date.tm_mon + 1, 12) << "Winter Veil should be in December"; + // Should be around Dec 15-16 (6 days before Dec 21-22) + EXPECT_GE(date.tm_mday, 14) << "Winter Veil should be >= Dec 14"; + EXPECT_LE(date.tm_mday, 17) << "Winter Veil should be <= Dec 17"; + } +} + +// ============================================================ +// Love is in the Air (First Monday on or after Feb 3) +// ============================================================ + +TEST_F(HolidayDateCalculatorTest, LoveIsInTheAir_FirstMondayOnOrAfterFeb3) +{ + // Verify "first Monday on or after Feb 3" calculation + struct LoveTestCase { int year; int expectedDay; }; + std::vector testCases = { + { 2024, 5 }, // Feb 3 is Sat, first Mon after is Feb 5 + { 2025, 3 }, // Feb 3 is Mon, so Feb 3 + { 2026, 9 }, // Feb 3 is Tue, first Mon after is Feb 9 + { 2027, 8 }, // Feb 3 is Wed, first Mon after is Feb 8 + { 2028, 7 }, // Feb 3 is Thu, first Mon after is Feb 7 + { 2029, 5 }, // Feb 3 is Sat, first Mon after is Feb 5 + { 2030, 4 }, // Feb 3 is Sun, first Mon after is Feb 4 + }; + + for (auto const& tc : testCases) + { + std::tm date = HolidayDateCalculator::CalculateWeekdayOnOrAfter(tc.year, 2, 3, Weekday::MONDAY); + + SCOPED_TRACE("Year: " + std::to_string(tc.year)); + + EXPECT_EQ(date.tm_year + 1900, tc.year); + EXPECT_EQ(date.tm_mon + 1, 2); // February + EXPECT_EQ(date.tm_mday, tc.expectedDay); + EXPECT_EQ(date.tm_wday, 1); // Monday + } +} + +TEST_F(HolidayDateCalculatorTest, ChildrensWeek_FirstMondayOnOrAfterApr25) +{ + // Verify "first Monday on or after Apr 25" calculation (Monday closest to May 1) + struct ChildrensWeekTestCase { int year; int expectedMonth; int expectedDay; }; + std::vector testCases = { + { 2023, 5, 1 }, // Apr 25 is Tue, first Mon after is May 1 + { 2024, 4, 29 }, // Apr 25 is Thu, first Mon after is Apr 29 + { 2025, 4, 28 }, // Apr 25 is Fri, first Mon after is Apr 28 + { 2026, 4, 27 }, // Apr 25 is Sat, first Mon after is Apr 27 + { 2027, 4, 26 }, // Apr 25 is Sun, first Mon after is Apr 26 + }; + + for (auto const& tc : testCases) + { + std::tm date = HolidayDateCalculator::CalculateWeekdayOnOrAfter(tc.year, 4, 25, Weekday::MONDAY); + + SCOPED_TRACE("Year: " + std::to_string(tc.year)); + + EXPECT_EQ(date.tm_year + 1900, tc.year); + EXPECT_EQ(date.tm_mon + 1, tc.expectedMonth); + EXPECT_EQ(date.tm_mday, tc.expectedDay); + EXPECT_EQ(date.tm_wday, 1); // Monday + } +} + +TEST_F(HolidayDateCalculatorTest, WeekdayOnOrAfter_AlwaysCorrectWeekday_1900_2200) +{ + // Verify the result is always the correct weekday for entire range + for (int year = 1900; year <= 2200; ++year) + { + for (int weekday = 0; weekday <= 6; ++weekday) + { + std::tm date = HolidayDateCalculator::CalculateWeekdayOnOrAfter(year, 2, 3, static_cast(weekday)); + + SCOPED_TRACE("Year: " + std::to_string(year) + " Weekday: " + std::to_string(weekday)); + + EXPECT_EQ(date.tm_wday, weekday); + EXPECT_EQ(date.tm_mon + 1, 2); // Should stay in February + EXPECT_GE(date.tm_mday, 3); // Should be on or after Feb 3 + EXPECT_LE(date.tm_mday, 9); // At most 6 days later + } + } +} + +TEST_F(HolidayDateCalculatorTest, WeekdayOnOrAfter_MonthBoundary_RollsIntoNextMonth) +{ + // Test dates near month-end that may roll into the next month + // Apr 25 looking for Monday can roll into May (e.g., if Apr 25 is Sunday, Monday is May 1) + + for (int year = 1900; year <= 2200; ++year) + { + // Test Apr 25 (Children's Week reference date) + std::tm apr25 = HolidayDateCalculator::CalculateWeekdayOnOrAfter(year, 4, 25, Weekday::MONDAY); + SCOPED_TRACE("Apr 25, Year: " + std::to_string(year)); + EXPECT_EQ(apr25.tm_wday, 1); // Monday + EXPECT_TRUE(apr25.tm_mon == 3 || apr25.tm_mon == 4); // April or May (0-indexed: 3 or 4) + // If still in April, must be >= 25. If in May, can be 1-6. + if (apr25.tm_mon == 3) + EXPECT_GE(apr25.tm_mday, 25); + else + EXPECT_LE(apr25.tm_mday, 6); + + // Test Apr 30 - more likely to roll into May + std::tm apr30 = HolidayDateCalculator::CalculateWeekdayOnOrAfter(year, 4, 30, Weekday::MONDAY); + SCOPED_TRACE("Apr 30, Year: " + std::to_string(year)); + EXPECT_EQ(apr30.tm_wday, 1); // Monday + EXPECT_TRUE(apr30.tm_mon == 3 || apr30.tm_mon == 4); // April or May + if (apr30.tm_mon == 3) + EXPECT_EQ(apr30.tm_mday, 30); // Apr 30 must be the Monday + else + EXPECT_LE(apr30.tm_mday, 6); // May 1-6 + + // Test Dec 31 - can roll into January of next year + std::tm dec31 = HolidayDateCalculator::CalculateWeekdayOnOrAfter(year, 12, 31, Weekday::MONDAY); + SCOPED_TRACE("Dec 31, Year: " + std::to_string(year)); + EXPECT_EQ(dec31.tm_wday, 1); // Monday + // Could be Dec 31 or Jan 1-6 of next year + EXPECT_TRUE((dec31.tm_mon == 11 && dec31.tm_mday == 31) || + (dec31.tm_mon == 0 && dec31.tm_mday <= 6)); + } +} + +// ============================================================ +// Stress Tests - Verify No Crashes or Invalid Dates +// ============================================================ + +TEST_F(HolidayDateCalculatorTest, StressTest_AllCalculations_1900_2200) +{ + // Run all holiday calculations for entire range to ensure no crashes + const std::vector& rules = HolidayDateCalculator::GetHolidayRules(); + + int totalCalculations = 0; + + for (int year = 1900; year <= 2200; ++year) + { + // Test Easter + std::tm easter = HolidayDateCalculator::CalculateEasterSunday(year); + EXPECT_TRUE(IsValidDate(year, easter.tm_mon + 1, easter.tm_mday)) + << "Invalid Easter date for year " << year; + totalCalculations++; + + // Test all weekday calculations + for (int month = 1; month <= 12; ++month) + { + for (int n = 1; n <= 4; ++n) + { + std::tm date = HolidayDateCalculator::CalculateNthWeekday(year, month, Weekday::THURSDAY, n); + EXPECT_TRUE(IsValidDate(year, date.tm_mon + 1, date.tm_mday)) + << "Invalid NthWeekday date for year " << year << " month " << month << " n=" << n; + totalCalculations++; + } + } + + // Test all holiday rules + for (auto const& rule : rules) + { + std::tm date = HolidayDateCalculator::CalculateHolidayDate(rule, year); + EXPECT_TRUE(IsValidDate(year, date.tm_mon + 1, date.tm_mday)) + << "Invalid holiday date for holiday " << rule.holidayId << " year " << year; + totalCalculations++; + } + } + + // Verify we ran a significant number of calculations + // 301 years * (1 Easter + 48 NthWeekday + 6 holidays) = 301 * 55 = 16555 + EXPECT_GT(totalCalculations, 15000) << "Should have run many calculations"; +} + +// ============================================================ +// Edge Case Tests +// ============================================================ + +TEST_F(HolidayDateCalculatorTest, LeapYear_AllYears_1900_2200) +{ + // Verify calculations work correctly for all years, checking leap year logic + for (int year = 1900; year <= 2200; ++year) + { + bool expectedLeap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); + + SCOPED_TRACE("Year: " + std::to_string(year)); + EXPECT_EQ(IsLeapYear(year), expectedLeap); + + // Easter calculation should always work regardless of leap year + std::tm easter = HolidayDateCalculator::CalculateEasterSunday(year); + EXPECT_EQ(easter.tm_wday, 0) << "Easter should be Sunday"; + + // All holiday calculations should produce valid dates + for (auto const& rule : HolidayDateCalculator::GetHolidayRules()) + { + std::tm date = HolidayDateCalculator::CalculateHolidayDate(rule, year); + EXPECT_TRUE(IsValidDate(year, date.tm_mon + 1, date.tm_mday)) + << "Invalid date for holiday " << rule.holidayId; + } + } +} + +TEST_F(HolidayDateCalculatorTest, GetPackedHolidayDate_UnknownHoliday) +{ + // Unknown holiday ID should return 0 + uint32_t result = HolidayDateCalculator::GetPackedHolidayDate(99999, 2025); + EXPECT_EQ(result, 0u); +} + +TEST_F(HolidayDateCalculatorTest, PackDate_YearBeyond2031) +{ + // WoW's packed date format uses 5 bits for year offset from 2000 + // - Offsets 0-30 represent specific years 2000-2030 + // - Offset 31 is a special marker meaning "repeats every year" (used for fixed-date holidays) + // This is a Blizzard client limitation, not an emulator design choice + // + // Years beyond 2031 will overflow when unpacked due to the 5-bit mask + + // Test year 2032 (offset 32) - demonstrates the overflow behavior + { + std::tm date = {}; + date.tm_year = 2032 - 1900; + date.tm_mon = 5; // June + date.tm_mday = 15; + mktime(&date); + + uint32_t packed = HolidayDateCalculator::PackDate(date); + std::tm unpacked = HolidayDateCalculator::UnpackDate(packed); + + // Year offset 32 masked with 0x1F (5 bits) = 0, so unpacked year = 2000 + // This documents the WoW client's inherent year 2031 limitation + EXPECT_EQ(unpacked.tm_year + 1900, 2000) << "Year 2032 wraps to 2000 due to 5-bit WoW client format"; + EXPECT_EQ(unpacked.tm_mon + 1, 6); + EXPECT_EQ(unpacked.tm_mday, 15); + } + + // Test year 2035 (offset 35) + { + std::tm date = {}; + date.tm_year = 2035 - 1900; + date.tm_mon = 0; // January + date.tm_mday = 1; + mktime(&date); + + uint32_t packed = HolidayDateCalculator::PackDate(date); + std::tm unpacked = HolidayDateCalculator::UnpackDate(packed); + + // Year offset 35 masked with 0x1F = 3, so unpacked year = 2003 + EXPECT_EQ(unpacked.tm_year + 1900, 2003) << "Year 2035 wraps to 2003 due to 5-bit WoW client format"; + } + + // Test boundary: year 2030 is the last fully usable year (offset 30) + // (offset 31 is reserved for "repeating yearly" holidays) + { + std::tm date = {}; + date.tm_year = 2030 - 1900; + date.tm_mon = 11; // December + date.tm_mday = 31; + mktime(&date); + + uint32_t packed = HolidayDateCalculator::PackDate(date); + std::tm unpacked = HolidayDateCalculator::UnpackDate(packed); + + EXPECT_EQ(unpacked.tm_year + 1900, 2030) << "Year 2030 should pack/unpack correctly"; + EXPECT_EQ(unpacked.tm_mon + 1, 12); + EXPECT_EQ(unpacked.tm_mday, 31); + } +} + +TEST_F(HolidayDateCalculatorTest, CenturyBoundaries) +{ + // Test calculations around century boundaries (which affect leap year rules) + std::vector centuryYears = { 1900, 2000, 2100, 2200 }; + + for (int year : centuryYears) + { + SCOPED_TRACE("Century year: " + std::to_string(year)); + + // Easter + std::tm easter = HolidayDateCalculator::CalculateEasterSunday(year); + EXPECT_EQ(easter.tm_wday, 0) << "Easter should be Sunday"; + EXPECT_TRUE(easter.tm_mon == 2 || easter.tm_mon == 3) << "Easter should be in March or April"; + + // Thanksgiving + std::tm thanksgiving = HolidayDateCalculator::CalculateNthWeekday(year, 11, Weekday::THURSDAY, 4); + EXPECT_EQ(thanksgiving.tm_wday, static_cast(Weekday::THURSDAY)); + EXPECT_EQ(thanksgiving.tm_mon + 1, 11); + } +} + +// ============================================================ +// Lunar New Year (Chinese New Year) Tests - Extended Range +// Based on Jean Meeus "Astronomical Algorithms" (1991) +// ============================================================ + +TEST_F(HolidayDateCalculatorTest, LunarNewYear_KnownDates) +{ + // Verify against known Chinese New Year dates + // Source: Official astronomical calculations and historical records + struct LunarNewYearTestCase { int year; int month; int day; }; + std::vector testCases = { + // Historical dates (2000-2010) + { 2000, 2, 5 }, + { 2001, 1, 24 }, + { 2002, 2, 12 }, + { 2003, 2, 1 }, + { 2004, 1, 22 }, + { 2005, 2, 9 }, + { 2006, 1, 29 }, + { 2007, 2, 18 }, + { 2008, 2, 7 }, + { 2009, 1, 26 }, + { 2010, 2, 14 }, + // Recent dates (2011-2020) + { 2011, 2, 3 }, + { 2012, 1, 23 }, + { 2013, 2, 10 }, + { 2014, 1, 31 }, + { 2015, 2, 19 }, + { 2016, 2, 8 }, + { 2017, 1, 28 }, + { 2018, 2, 16 }, + { 2019, 2, 5 }, + { 2020, 1, 25 }, + // Current and near-future dates (2021-2031) + { 2021, 2, 12 }, + { 2022, 2, 1 }, + { 2023, 1, 22 }, + { 2024, 2, 10 }, + { 2025, 1, 29 }, + { 2026, 2, 17 }, + { 2027, 2, 6 }, + { 2028, 1, 26 }, + { 2029, 2, 13 }, + { 2030, 2, 3 }, + { 2031, 1, 23 }, + }; + + for (auto const& tc : testCases) + { + std::tm lunarNewYear = HolidayDateCalculator::CalculateLunarNewYear(tc.year); + SCOPED_TRACE("Year: " + std::to_string(tc.year)); + ExpectDate(lunarNewYear, tc.year, tc.month, tc.day); + } +} + +TEST_F(HolidayDateCalculatorTest, LunarNewYear_ValidDateRange_1900_2200) +{ + // Chinese New Year must always fall between January 21 and February 20 (inclusive) + // This is a fundamental property of the lunisolar calendar + for (int year = 1900; year <= 2200; ++year) + { + std::tm lunarNewYear = HolidayDateCalculator::CalculateLunarNewYear(year); + + SCOPED_TRACE("Year: " + std::to_string(year)); + + // Verify year is correct + EXPECT_EQ(lunarNewYear.tm_year + 1900, year); + + // Chinese New Year must be in January or February + EXPECT_TRUE(lunarNewYear.tm_mon == 0 || lunarNewYear.tm_mon == 1) + << "Lunar New Year must be in January (0) or February (1), got month " << lunarNewYear.tm_mon; + + // Valid range: January 21 - February 20 + if (lunarNewYear.tm_mon == 0) // January + { + EXPECT_GE(lunarNewYear.tm_mday, 21) << "Lunar New Year in January must be >= 21"; + EXPECT_LE(lunarNewYear.tm_mday, 31) << "Lunar New Year in January must be <= 31"; + } + else // February + { + EXPECT_GE(lunarNewYear.tm_mday, 1) << "Lunar New Year in February must be >= 1"; + EXPECT_LE(lunarNewYear.tm_mday, 20) << "Lunar New Year in February must be <= 20"; + } + + // Verify it's a valid calendar date + EXPECT_TRUE(IsValidDate(year, lunarNewYear.tm_mon + 1, lunarNewYear.tm_mday)) + << "Lunar New Year should be a valid calendar date"; + } +} + +TEST_F(HolidayDateCalculatorTest, LunarFestival_DayBeforeChineseNewYear) +{ + // Lunar Festival starts 1 day before Chinese New Year + // Test with -1 offset applied + HolidayRule lunarFestival = { 327, HolidayCalculationType::LUNAR_NEW_YEAR, 0, 0, 0, -1 }; + + for (int year = 2000; year <= 2100; ++year) + { + std::tm fromRule = HolidayDateCalculator::CalculateHolidayDate(lunarFestival, year); + std::tm cny = HolidayDateCalculator::CalculateLunarNewYear(year); + + // Expected: CNY - 1 day + std::tm expected = cny; + expected.tm_mday -= 1; + mktime(&expected); + + SCOPED_TRACE("Year: " + std::to_string(year)); + + // Lunar Festival should be 1 day before Chinese New Year + EXPECT_EQ(fromRule.tm_year, expected.tm_year); + EXPECT_EQ(fromRule.tm_mon, expected.tm_mon); + EXPECT_EQ(fromRule.tm_mday, expected.tm_mday); + } +} + +TEST_F(HolidayDateCalculatorTest, LunarFestival_KnownDates) +{ + // Verify Lunar Festival (CNY - 1 day) against known official dates + // Source: WoW official event announcements + struct LunarFestivalTestCase { int year; int month; int day; }; + std::vector testCases = { + { 2024, 2, 9 }, // CNY Feb 10 - 1 = Feb 9 + { 2025, 1, 28 }, // CNY Jan 29 - 1 = Jan 28 (confirmed official) + { 2026, 2, 16 }, // CNY Feb 17 - 1 = Feb 16 + { 2027, 2, 5 }, // CNY Feb 6 - 1 = Feb 5 + }; + + HolidayRule lunarFestival = { 327, HolidayCalculationType::LUNAR_NEW_YEAR, 0, 0, 0, -1 }; + + for (auto const& tc : testCases) + { + std::tm date = HolidayDateCalculator::CalculateHolidayDate(lunarFestival, tc.year); + SCOPED_TRACE("Year: " + std::to_string(tc.year)); + ExpectDate(date, tc.year, tc.month, tc.day); + } +} + +TEST_F(HolidayDateCalculatorTest, LunarNewYear_NoRepeatedDates) +{ + // Each year should have a unique Chinese New Year date + // (no two consecutive years should have the exact same month/day) + int prevMonth = -1; + int prevDay = -1; + + for (int year = 1900; year <= 2200; ++year) + { + std::tm lunarNewYear = HolidayDateCalculator::CalculateLunarNewYear(year); + + if (prevMonth != -1) + { + // The date should be different from previous year + // (due to ~11 day lunar cycle drift) + bool sameDate = (lunarNewYear.tm_mon == prevMonth && lunarNewYear.tm_mday == prevDay); + EXPECT_FALSE(sameDate) + << "Year " << year << " has same date as previous year: " + << (lunarNewYear.tm_mon + 1) << "/" << lunarNewYear.tm_mday; + } + + prevMonth = lunarNewYear.tm_mon; + prevDay = lunarNewYear.tm_mday; + } +} + +TEST_F(HolidayDateCalculatorTest, LunarNewYear_19YearMetonicCycle) +{ + // The Chinese calendar roughly follows a 19-year Metonic cycle + // Dates should approximately repeat every 19 years (within a few days) + for (int year = 1900; year <= 2180; ++year) + { + std::tm date1 = HolidayDateCalculator::CalculateLunarNewYear(year); + std::tm date2 = HolidayDateCalculator::CalculateLunarNewYear(year + 19); + + SCOPED_TRACE("Comparing year " + std::to_string(year) + " with " + std::to_string(year + 19)); + + // Convert to day-of-year for easier comparison + int doy1 = (date1.tm_mon == 0) ? date1.tm_mday : 31 + date1.tm_mday; + int doy2 = (date2.tm_mon == 0) ? date2.tm_mday : 31 + date2.tm_mday; + + // The Metonic cycle is approximate - typically within a few days, but can shift + // by up to a lunar month (~29 days) at cycle boundaries due to intercalary months + int diff = std::abs(doy1 - doy2); + EXPECT_LE(diff, 30) << "19-year Metonic cycle should keep dates within one lunar month"; + } +} + +// ============================================================ +// Darkmoon Faire Tests +// First Sunday of the month, rotating between 3 locations +// ============================================================ + +TEST_F(HolidayDateCalculatorTest, DarkmoonFaire_LocationRotation) +{ + // Verify the 3-location rotation pattern: + // Mulgore (offset 1): Jan, Apr, Jul, Oct - month % 3 == 1 + // Terokkar (offset 2): Feb, May, Aug, Nov - month % 3 == 2 + // Elwynn (offset 0): Mar, Jun, Sep, Dec - month % 3 == 0 + + // Mulgore months (offset 1) + EXPECT_EQ(1 % 3, 1); + EXPECT_EQ(4 % 3, 1); + EXPECT_EQ(7 % 3, 1); + EXPECT_EQ(10 % 3, 1); + + // Terokkar months (offset 2) + EXPECT_EQ(2 % 3, 2); + EXPECT_EQ(5 % 3, 2); + EXPECT_EQ(8 % 3, 2); + EXPECT_EQ(11 % 3, 2); + + // Elwynn months (offset 0) + EXPECT_EQ(3 % 3, 0); + EXPECT_EQ(6 % 3, 0); + EXPECT_EQ(9 % 3, 0); + EXPECT_EQ(12 % 3, 0); +} + +TEST_F(HolidayDateCalculatorTest, DarkmoonFaire_FirstSundayOfMonth_KnownDates) +{ + // Verify first Sunday calculation against known dates + struct FirstSundayTestCase { int year; int month; int expectedDay; }; + std::vector testCases = { + // 2024 + { 2024, 1, 7 }, // Jan 2024: First Sunday = Jan 7 + { 2024, 2, 4 }, // Feb 2024: First Sunday = Feb 4 + { 2024, 3, 3 }, // Mar 2024: First Sunday = Mar 3 + { 2024, 4, 7 }, // Apr 2024: First Sunday = Apr 7 + { 2024, 9, 1 }, // Sep 2024: First Sunday = Sep 1 + { 2024, 12, 1 }, // Dec 2024: First Sunday = Dec 1 + // 2025 + { 2025, 1, 5 }, // Jan 2025: First Sunday = Jan 5 + { 2025, 2, 2 }, // Feb 2025: First Sunday = Feb 2 + { 2025, 3, 2 }, // Mar 2025: First Sunday = Mar 2 + { 2025, 6, 1 }, // Jun 2025: First Sunday = Jun 1 + { 2025, 9, 7 }, // Sep 2025: First Sunday = Sep 7 + { 2025, 12, 7 }, // Dec 2025: First Sunday = Dec 7 + // 2026 + { 2026, 1, 4 }, // Jan 2026: First Sunday = Jan 4 + { 2026, 3, 1 }, // Mar 2026: First Sunday = Mar 1 + }; + + for (auto const& tc : testCases) + { + std::tm date = HolidayDateCalculator::CalculateNthWeekday(tc.year, tc.month, Weekday::SUNDAY, 1); + + SCOPED_TRACE("Year: " + std::to_string(tc.year) + " Month: " + std::to_string(tc.month)); + + EXPECT_EQ(date.tm_year + 1900, tc.year); + EXPECT_EQ(date.tm_mon + 1, tc.month); + EXPECT_EQ(date.tm_mday, tc.expectedDay); + EXPECT_EQ(date.tm_wday, 0) << "Should be Sunday"; + } +} + +TEST_F(HolidayDateCalculatorTest, DarkmoonFaire_GetDates_Elwynn) +{ + // Elwynn (offset 0): Mar, Jun, Sep, Dec + std::vector dates = HolidayDateCalculator::GetDarkmoonFaireDates(0, 2025, 1); + + // Should have exactly 4 dates for one year + EXPECT_EQ(dates.size(), 4u); + + // Verify each date is in the correct month and is a Sunday + std::vector expectedMonths = { 3, 6, 9, 12 }; + for (size_t i = 0; i < dates.size(); ++i) + { + std::tm date = HolidayDateCalculator::UnpackDate(dates[i]); + + SCOPED_TRACE("Date index: " + std::to_string(i)); + + EXPECT_EQ(date.tm_year + 1900, 2025); + EXPECT_EQ(date.tm_mon + 1, expectedMonths[i]); + EXPECT_EQ(date.tm_wday, 0) << "Should be Sunday"; + EXPECT_GE(date.tm_mday, 1); + EXPECT_LE(date.tm_mday, 7) << "First Sunday must be within first 7 days"; + } +} + +TEST_F(HolidayDateCalculatorTest, DarkmoonFaire_GetDates_Mulgore) +{ + // Mulgore (offset 1): Jan, Apr, Jul, Oct + std::vector dates = HolidayDateCalculator::GetDarkmoonFaireDates(1, 2025, 1); + + // Should have exactly 4 dates for one year + EXPECT_EQ(dates.size(), 4u); + + std::vector expectedMonths = { 1, 4, 7, 10 }; + for (size_t i = 0; i < dates.size(); ++i) + { + std::tm date = HolidayDateCalculator::UnpackDate(dates[i]); + + SCOPED_TRACE("Date index: " + std::to_string(i)); + + EXPECT_EQ(date.tm_year + 1900, 2025); + EXPECT_EQ(date.tm_mon + 1, expectedMonths[i]); + EXPECT_EQ(date.tm_wday, 0) << "Should be Sunday"; + } +} + +TEST_F(HolidayDateCalculatorTest, DarkmoonFaire_GetDates_Terokkar) +{ + // Terokkar (offset 2): Feb, May, Aug, Nov + std::vector dates = HolidayDateCalculator::GetDarkmoonFaireDates(2, 2025, 1); + + // Should have exactly 4 dates for one year + EXPECT_EQ(dates.size(), 4u); + + std::vector expectedMonths = { 2, 5, 8, 11 }; + for (size_t i = 0; i < dates.size(); ++i) + { + std::tm date = HolidayDateCalculator::UnpackDate(dates[i]); + + SCOPED_TRACE("Date index: " + std::to_string(i)); + + EXPECT_EQ(date.tm_year + 1900, 2025); + EXPECT_EQ(date.tm_mon + 1, expectedMonths[i]); + EXPECT_EQ(date.tm_wday, 0) << "Should be Sunday"; + } +} + +TEST_F(HolidayDateCalculatorTest, DarkmoonFaire_GetDates_MultiYear) +{ + // Get 4 years of dates for Elwynn (offset 0) + std::vector dates = HolidayDateCalculator::GetDarkmoonFaireDates(0, 2025, 4); + + // 4 dates per year * 4 years = 16 dates + EXPECT_EQ(dates.size(), 16u); + + // Verify dates are in chronological order + for (size_t i = 1; i < dates.size(); ++i) + { + std::tm prev = HolidayDateCalculator::UnpackDate(dates[i - 1]); + std::tm curr = HolidayDateCalculator::UnpackDate(dates[i]); + + time_t prevTime = mktime(&prev); + time_t currTime = mktime(&curr); + + EXPECT_GT(currTime, prevTime) << "Dates should be in chronological order"; + } +} + +TEST_F(HolidayDateCalculatorTest, DarkmoonFaire_AlwaysSunday_AllLocations_2000_2030) +{ + // Verify all Darkmoon Faire dates are Sundays for entire valid range + // 3 locations: offset 0 (Elwynn), offset 1 (Mulgore), offset 2 (Terokkar) + for (int offset = 0; offset <= 2; ++offset) + { + std::vector dates = HolidayDateCalculator::GetDarkmoonFaireDates(offset, 2000, 31); + + SCOPED_TRACE("Location offset: " + std::to_string(offset)); + + for (size_t i = 0; i < dates.size(); ++i) + { + std::tm date = HolidayDateCalculator::UnpackDate(dates[i]); + EXPECT_EQ(date.tm_wday, 0) + << "Date " << (date.tm_year + 1900) << "-" << (date.tm_mon + 1) << "-" << date.tm_mday + << " should be Sunday"; + } + } +} + +TEST_F(HolidayDateCalculatorTest, DarkmoonFaire_CalculateHolidayDate_ReturnsFirstOccurrence) +{ + // Using CalculateHolidayDate with DARKMOON_FAIRE should return the first occurrence of the year + // Elwynn (offset 0) = Mar/Jun/Sep/Dec, Mulgore (offset 1) = Jan/Apr/Jul/Oct, Terokkar (offset 2) = Feb/May/Aug/Nov + HolidayRule elwynnRule = { 374, HolidayCalculationType::DARKMOON_FAIRE, 0, 0, 0, -2 }; + HolidayRule mulgoreRule = { 375, HolidayCalculationType::DARKMOON_FAIRE, 1, 0, 0, -2 }; + HolidayRule terokkarRule = { 376, HolidayCalculationType::DARKMOON_FAIRE, 2, 0, 0, -2 }; + + // 2025 first occurrences: + // Elwynn (offset 0): March (first month where month % 3 == 0) + std::tm elwynn2025 = HolidayDateCalculator::CalculateHolidayDate(elwynnRule, 2025); + EXPECT_EQ(elwynn2025.tm_mon + 1, 3) << "Elwynn first occurrence should be March"; + EXPECT_EQ(elwynn2025.tm_wday, 0) << "Should be Sunday"; + + // Mulgore (offset 1): January (first month where month % 3 == 1) + std::tm mulgore2025 = HolidayDateCalculator::CalculateHolidayDate(mulgoreRule, 2025); + EXPECT_EQ(mulgore2025.tm_mon + 1, 1) << "Mulgore first occurrence should be January"; + EXPECT_EQ(mulgore2025.tm_wday, 0) << "Should be Sunday"; + + // Terokkar (offset 2): February (first month where month % 3 == 2) + std::tm terokkar2025 = HolidayDateCalculator::CalculateHolidayDate(terokkarRule, 2025); + EXPECT_EQ(terokkar2025.tm_mon + 1, 2) << "Terokkar first occurrence should be February"; + EXPECT_EQ(terokkar2025.tm_wday, 0) << "Should be Sunday"; +} + +TEST_F(HolidayDateCalculatorTest, DarkmoonFaire_NoOverlap_AllLocations) +{ + // Verify all three locations don't share dates (they're in different months) + for (int year = 2000; year <= 2030; ++year) + { + std::vector elwynn = HolidayDateCalculator::GetDarkmoonFaireDates(0, year, 1); // Mar/Jun/Sep/Dec + std::vector mulgore = HolidayDateCalculator::GetDarkmoonFaireDates(1, year, 1); // Jan/Apr/Jul/Oct + std::vector terokkar = HolidayDateCalculator::GetDarkmoonFaireDates(2, year, 1); // Feb/May/Aug/Nov + + SCOPED_TRACE("Year: " + std::to_string(year)); + + // Check no overlap between any locations + for (auto const& e : elwynn) + { + for (auto const& m : mulgore) + EXPECT_NE(e, m) << "Elwynn and Mulgore should not share dates"; + for (auto const& t : terokkar) + EXPECT_NE(e, t) << "Elwynn and Terokkar should not share dates"; + } + for (auto const& m : mulgore) + { + for (auto const& t : terokkar) + EXPECT_NE(m, t) << "Mulgore and Terokkar should not share dates"; + } + } +} + +TEST_F(HolidayDateCalculatorTest, DarkmoonFaire_InHolidayRules) +{ + // Verify all three Darkmoon Faire locations are in the HolidayRules + auto const& rules = HolidayDateCalculator::GetHolidayRules(); + + bool foundElwynn = false, foundMulgore = false, foundTerokkar = false; + for (auto const& rule : rules) + { + if (rule.holidayId == 374 && rule.type == HolidayCalculationType::DARKMOON_FAIRE) + foundElwynn = true; + if (rule.holidayId == 375 && rule.type == HolidayCalculationType::DARKMOON_FAIRE) + foundMulgore = true; + if (rule.holidayId == 376 && rule.type == HolidayCalculationType::DARKMOON_FAIRE) + foundTerokkar = true; + } + + EXPECT_TRUE(foundElwynn) << "Darkmoon Faire Elwynn (374) should be in HolidayRules"; + EXPECT_TRUE(foundMulgore) << "Darkmoon Faire Mulgore (375) should be in HolidayRules"; + EXPECT_TRUE(foundTerokkar) << "Darkmoon Faire Terokkar (376) should be in HolidayRules"; +}