Module:BassaridiaForecast: Difference between revisions

From MicrasWiki
Jump to navigationJump to search
NewZimiaGov (talk | contribs)
No edit summary
NewZimiaGov (talk | contribs)
No edit summary
Line 1: Line 1:
---------------------------------------------------------
---------------------------------------------------------
-- Module: BassaridiaForecast
-- Module:BassaridiaForecast  (Rewritten w/ Ops Alert)
-- Provides three functions:
-- 1) weatherForecast(frame) -> returns a single
--    big table for ALL cities
--  2) weatherForCity(frame)  -> single table for a city
--  3) starsForTonight(frame) -> example star-visibility
--
--
-- All cells are re-randomized once per day
-- Outputs:
-- by seeding math.random with (year*1000 + dayOfYear).
--  * weatherForecast(frame)  -> full daily forecast table for all cities
--  * weatherForCity(frame)  -> single-city forecast table
--  * starsForTonight(frame)  -> deterministic host-star visibility table
--  * getOpsData(city, year, dayOfYear) -> structured ops alert signal for other modules
--   * opsAlert(frame)        -> quick badge + impacts + hazard for a single city
--
-- Key features:
--  - Deterministic per (city, year, day), so Hatch/Missionary/WL modules match exactly
--   - Adds Condition, Feels Like, Gusts, Cloud %, Visibility, Ops Impact, Ops Alert
--  - Wind ranges scale with conditions (storms/gales stronger than fair weather)
--  - Optional args: |year= |day= for testing/overrides
---------------------------------------------------------
---------------------------------------------------------


Line 14: Line 19:


---------------------------------------------------------
---------------------------------------------------------
-- 1. Determine Today's Date for PSSC
-- 0) Calendar integration (Module:BassaridianCalendar)
--    Also used to create a daily random seed.
---------------------------------------------------------
---------------------------------------------------------
local function getCurrentDateInfo()
local calOk, cal = pcall(require, "Module:BassaridianCalendar")
    local startDate  = os.time({year=1999, month=8, day=6})
    local secondsInDay= 86400
    local daysPerYear = 183


    local currentTime = os.time()
local DAYS_IN_YEAR = 183
    local totalDays  = math.floor((currentTime - startDate) / secondsInDay)


    local fractionYear= totalDays / daysPerYear
local function getCurrentDateInfo_fallback()
    local psscYear   = math.floor(fractionYear)
  local startDate   = os.time({year=1999, month=8, day=6})
    local dayOfYear   = math.floor((fractionYear - psscYear) * daysPerYear) + 1
  local secondsInDay = 86400
  local currentTime  = os.time()
   local totalDays    = math.floor((currentTime - startDate) / secondsInDay)


    return {
  local psscYear  = math.floor(totalDays / DAYS_IN_YEAR)
        psscYear = psscYear,
  local dayOfYear = (totalDays % DAYS_IN_YEAR) + 1
        dayOfYear= dayOfYear
  return { psscYear = psscYear, dayOfYear = dayOfYear }
     }
end
 
local function parseCalendarString(s)
  s = tostring(s or "")
  -- Common patterns:
  -- "8, Opsitheiel (...), 52 PSSC"
  -- "8, Opsitheiel, 52 PSSC"
  local day, year = s:match("^(%d+)%s*,.-,%s*(%d+)%s*PSSC")
  if day and year then return tonumber(year), tonumber(day) end
 
  -- More forgiving: first number is day, last is year
  year = s:match("(%d+)%s*PSSC")
  day  = s:match("^(%d+)")
  if year and day then return tonumber(year), tonumber(day) end
 
  return nil, nil
end
 
local function getDateInfo(args)
  args = args or {}
  local yy = tonumber(args.year)
  local dd = tonumber(args.day)
 
  if yy and dd and dd >= 1 and dd <= DAYS_IN_YEAR then
    return { year = yy, dayOfYear = dd, source = "args" }
  end
 
  if calOk and cal and type(cal.getCurrentDate) == "function" then
    local raw = cal.getCurrentDate()
    local y, d = parseCalendarString(raw)
    if y and d and d >= 1 and d <= DAYS_IN_YEAR then
      return { year = y, dayOfYear = d, source = "calendar", raw = raw }
     end
  end
 
  local fb = getCurrentDateInfo_fallback()
  return { year = fb.psscYear, dayOfYear = fb.dayOfYear, source = "fallback" }
end
end


---------------------------------------------------------
---------------------------------------------------------
-- 2. Determine Season (Atosiel=1..61, Thalassiel=62..122, Opsitheiel=123..183)
-- 1) Season helper
---------------------------------------------------------
---------------------------------------------------------
local function getSeasonName(dayOfYear)
local function getSeasonName(dayOfYear)
    if dayOfYear <= 61 then
  if dayOfYear <= 61 then
        return "Atosiel"
    return "Atosiel"
    elseif dayOfYear <= 122 then
  elseif dayOfYear <= 122 then
        return "Thalassiel"
    return "Thalassiel"
    else
  else
        return "Opsitheiel"
    return "Opsitheiel"
     end
  end
end
 
---------------------------------------------------------
-- 2) Deterministic RNG helpers
---------------------------------------------------------
local function hash31(str)
  local h = 0
  str = tostring(str or "")
  for i = 1, #str do
    h = (h * 131 + str:byte(i)) % 2147483647
  end
  return h
end
 
local function lcg(seed)
  local s = seed % 2147483647
  if s <= 0 then s = s + 2147483646 end
  return function()
     s = (1103515245 * s + 12345) % 2147483647
    return s
  end
end
 
local function rand01(nextInt) return (nextInt() % 1000000) / 1000000 end
 
local function randInt(nextInt, a, b)
  a, b = tonumber(a) or 0, tonumber(b) or 0
  if b < a then a, b = b, a end
  return a + (nextInt() % (b - a + 1))
end
 
local function pickFromList(nextInt, list)
  if not list or #list == 0 then return nil end
  return list[(nextInt() % #list) + 1]
end
 
---------------------------------------------------------
-- 3) Conversions + derived weather
---------------------------------------------------------
local function cToF(c)
  if type(c) == "number" then
    return math.floor(c * 9/5 + 32 + 0.5)
  end
  return c
end
 
local function conditionFromText(t)
  t = (t or ""):lower()
  if t:find("blizzard") then return "Blizzard" end
  if t:find("snow") or t:find("sleet") or t:find("flurr") or t:find("ice") then return "Snow" end
  if t:find("thunder") or t:find("severe") or t:find("downpour") or t:find("storm") then return "Storm" end
  if t:find("rain") or t:find("drizzle") or t:find("shower") then return "Rain" end
  if t:find("fog") or t:find("mist") or t:find("low visibility") then return "Fog" end
  if t:find("dust") or t:find("sandstorm") or t:find("blowing sand") then return "Dust" end
  if t:find("clear") or t:find("spotless") or t:find("sunny") then return "Clear" end
  if t:find("overcast") or t:find("cloud") or t:find("gloom") or t:find("haze") then return "Cloudy" end
  return "Fair"
end
 
-- Rothfusz regression (approx; OK for narrative/ops signal)
local function heatIndexF(T, RH)
  return -42.379 + 2.04901523*T + 10.14333127*RH - 0.22475541*T*RH
        - 0.00683783*T*T - 0.05481717*RH*RH + 0.00122874*T*T*RH
        + 0.00085282*T*RH*RH - 0.00000199*T*T*RH*RH
end
 
local function windChillF(T, windMph)
  return 35.74 + 0.6215*T - 35.75*(windMph^0.16) + 0.4275*T*(windMph^0.16)
end
 
local function computeFeelsLikeF(hiF, hum, windKmh)
  local windMph = (tonumber(windKmh) or 0) * 0.621371
  hiF = tonumber(hiF) or 0
  hum = tonumber(hum) or 0
  if hiF >= 80 and hum >= 40 then
    return math.floor(heatIndexF(hiF, hum) + 0.5)
  end
  if hiF <= 50 and windMph >= 3 then
    return math.floor(windChillF(hiF, windMph) + 0.5)
  end
  return hiF
end
 
local function computeCloudAndVis(nextInt, cond)
  if cond == "Clear" then
    return randInt(nextInt, 0, 20), randInt(nextInt, 15, 30)
  elseif cond == "Cloudy" then
    return randInt(nextInt, 35, 95), randInt(nextInt, 10, 25)
  elseif cond == "Fog" then
    return randInt(nextInt, 90, 100), randInt(nextInt, 1, 6)
  elseif cond == "Rain" then
    return randInt(nextInt, 70, 100), randInt(nextInt, 4, 15)
  elseif cond == "Storm" then
    return randInt(nextInt, 85, 100), randInt(nextInt, 2, 10)
  elseif cond == "Snow" then
    return randInt(nextInt, 80, 100), randInt(nextInt, 2, 10)
  elseif cond == "Blizzard" then
    return randInt(nextInt, 90, 100), randInt(nextInt, 1, 5)
  elseif cond == "Dust" then
    return randInt(nextInt, 20, 80), randInt(nextInt, 2, 10)
  end
  return randInt(nextInt, 10, 60), randInt(nextInt, 10, 25)
end
 
local function computeImpacts(cond, precipProb, windKmh, visKm, hiF, loF)
  local impacts = { ground="OK", sea="OK", air="OK" }
  precipProb = tonumber(precipProb) or 0
  windKmh = tonumber(windKmh) or 0
  visKm = tonumber(visKm) or 99
  hiF = tonumber(hiF) or 0
  loF = tonumber(loF) or 0
 
  -- Air
  if visKm <= 1 or windKmh >= 55 or cond == "Blizzard" then
    impacts.air = "No-Go"
  elseif visKm <= 5 or windKmh >= 40 or cond == "Storm" then
    impacts.air = "Limited"
  end
 
  -- Sea
  if windKmh >= 50 or (cond == "Storm" and precipProb >= 85) or cond == "Blizzard" then
    impacts.sea = "Closed"
  elseif windKmh >= 28 or cond == "Storm" or (cond == "Rain" and precipProb >= 80) then
    impacts.sea = "Restricted"
  end
 
  -- Ground
  if (cond == "Snow" or cond == "Blizzard") and (loF <= 32 or visKm <= 3) then
    impacts.ground = "Hazard"
  elseif cond == "Storm" or (cond == "Rain" and precipProb >= 80) or visKm <= 5 then
    impacts.ground = "Slow"
  end
 
  -- Heat/Cold stress (ground modifier)
  if hiF >= 100 and impacts.ground == "OK" then impacts.ground = "Slow" end
  if loF <= 10 and impacts.ground == "OK" then impacts.ground = "Slow" end
 
  return impacts
end
 
---------------------------------------------------------
-- 4) Ops Alert badge helpers
---------------------------------------------------------
local function alertLabel(level)
  if level == 3 then return "Red" end
  if level == 2 then return "Orange" end
  if level == 1 then return "Yellow" end
  return "Green"
end
 
local function alertColor(level)
  if level == 3 then return "#f8d7da" end
  if level == 2 then return "#ffe5cc" end
  if level == 1 then return "#fff3cd" end
  return "#d4edda"
end
 
local function spanBadge(level, text)
  return string.format(
    "<span style='background:%s;padding:2px 6px;border:1px solid #ccc;border-radius:10px;white-space:nowrap;'>%s</span>",
    alertColor(level), text
  )
end
 
local function formatImpact(imp)
  imp = imp or {}
  return string.format("Gnd: %s; Sea: %s; Air: %s", imp.ground or "OK", imp.sea or "OK", imp.air or "OK")
end
end


---------------------------------------------------------
---------------------------------------------------------
-- 3. climateTemperature (°C behind scenes, displayed °F)
-- 5) Temperature ranges per climate (°C base; displayed °F)
---------------------------------------------------------
---------------------------------------------------------
local climateTemperature = {
local climateTemperature = {
    ["Humid Subtropical"] = {
  ["Humid Subtropical"] = {
        Atosiel    = { hiMin=18, hiMax=26, loMin=10, loMax=16, humMin=60, humMax=80 },
    Atosiel    = { hiMin=18, hiMax=26, loMin=10, loMax=16, humMin=60, humMax=80 },
        Thalassiel  = { hiMin=25, hiMax=34, loMin=19, loMax=24, humMin=65, humMax=90 },
    Thalassiel  = { hiMin=25, hiMax=34, loMin=19, loMax=24, humMin=65, humMax=90 },
        Opsitheiel  = { hiMin=20, hiMax=28, loMin=12, loMax=18, humMin=50, humMax=75 }
    Opsitheiel  = { hiMin=20, hiMax=28, loMin=12, loMax=18, humMin=50, humMax=75 }
  },
  ["Oceanic"] = {
    Atosiel    = { hiMin=10, hiMax=17, loMin=5,  loMax=12, humMin=70, humMax=90 },
    Thalassiel  = { hiMin=14, hiMax=21, loMin=9,  loMax=14, humMin=65, humMax=85 },
    Opsitheiel  = { hiMin=5,  hiMax=12, loMin=1,  loMax=6,  humMin=70, humMax=90 }
  },
  ["Subpolar Oceanic"] = {
    Atosiel    = { hiMin=3,  hiMax=9,  loMin=-2, loMax=3,  humMin=70, humMax=95 },
    Thalassiel  = { hiMin=6,  hiMax=12, loMin=1,  loMax=6,  humMin=70, humMax=90 },
    Opsitheiel  = { hiMin=-1, hiMax=4,  loMin=-6, loMax=-1, humMin=75, humMax=95 }
  },
  ["Mediterranean (Hot Summer)"] = {
    Atosiel    = { hiMin=15, hiMax=21, loMin=7,  loMax=12, humMin=40, humMax=60 },
    Thalassiel  = { hiMin=25, hiMax=35, loMin=15, loMax=20, humMin=30, humMax=50 },
    Opsitheiel  = { hiMin=12, hiMax=18, loMin=5,  loMax=10, humMin=45, humMax=65 }
  },
  ["Hot Desert"] = {
    Atosiel    = { hiMin=25, hiMax=35, loMin=12, loMax=18, humMin=10, humMax=30 },
    Thalassiel  = { hiMin=35, hiMax=45, loMin=25, loMax=30, humMin=5,  humMax=20 },
    Opsitheiel  = { hiMin=22, hiMax=30, loMin=10, loMax=16, humMin=5,  humMax=25 }
  },
  ["Cold Steppe"] = {
    Atosiel    = { hiMin=10, hiMax=18, loMin=2,  loMax=8,  humMin=30, humMax=50 },
    Thalassiel  = { hiMin=20, hiMax=28, loMin=10, loMax=15, humMin=25, humMax=45 },
    Opsitheiel  = { hiMin=0,  hiMax=7,  loMin=-6, loMax=0,  humMin=35, humMax=55 }
  },
  ["Hot Steppe"] = {
    Atosiel    = { hiMin=23, hiMax=30, loMin=12, loMax=18, humMin=20, humMax=40 },
    Thalassiel  = { hiMin=30, hiMax=40, loMin=20, loMax=25, humMin=15, humMax=35 },
    Opsitheiel  = { hiMin=25, hiMax=33, loMin=15, loMax=20, humMin=15, humMax=40 }
  },
  ["Subarctic"] = {
    Atosiel    = { hiMin=0,  hiMax=8,  loMin=-10,loMax=-1, humMin=50, humMax=80 },
    Thalassiel  = { hiMin=5,  hiMax=15, loMin=-1, loMax=5,  humMin=50, humMax=85 },
    Opsitheiel  = { hiMin=-5, hiMax=0,  loMin=-15,loMax=-5, humMin=60, humMax=90 }
  }
}
 
---------------------------------------------------------
-- 6) Weather flavor strings by climate & season
-- (Keep/expand these freely—these strings drive condition + advisory keywords.)
---------------------------------------------------------
local climateEvents = {
  ["Humid Subtropical"] = {
    Atosiel = {
      "Morning drizzle and warm afternoon sunshine",
      "Mild thunderstorm building by midday",
      "Patchy fog at dawn, clearing toward lunch",
      "Light rain with sunny breaks after noon",
      "Overcast for part of the day, mild temperatures",
      "Scattered clouds with a brief shower by dusk",
      "Warm breezes carrying faint floral scents",
      "Early morning seasonal storms and lake-driven floods may develop"
    },
    Thalassiel = {
      "Hot, steamy day with intense midday heat",
      "Tropical-like humidity, afternoon thunder possible",
      "Intermittent heavy downpours, muggy evenings",
      "High humidity and patchy thunderstorms late",
      "Heat advisory with only brief cooling at night",
      "Nighttime storms lingering into early morning",
      "Afternoon heavy rains could trigger river floods"
     },
     },
     ["Oceanic"] = {
     Opsitheiel = {
        Atosiel    = { hiMin=10, hiMax=17, loMin=5, loMax=12, humMin=70, humMax=90 },
      "Warm daytime, gentle evening breezes",
        Thalassiel  = { hiMin=14, hiMax=21, loMin=9, loMax=14, humMin=65, humMax=85 },
      "Occasional rain, otherwise mild temperatures",
        Opsitheiel  = { hiMin=5, hiMax=12, loMin=1, loMax=6, humMin=70, humMax=90 }
      "Fog at dawn, warm midday, pleasant night",
      "Evening drizzle with mild breezes",
      "Short-lived shower, then clearing skies",
      "Evening cooling may lead to localized mudslides on steep slopes"
    }
  },
 
  ["Oceanic"] = {
    Atosiel = {
      "Frequent light rain, cool breezes all day",
      "Foggy dawn, mild midmorning sunshine",
      "Short sunbreaks among passing showers",
      "Consistent drizzle, fresh winds from the sea",
      "Morning fog with coastal storms and marsh flooding possible"
     },
     },
     ["Subpolar Oceanic"] = {
     Thalassiel = {
        Atosiel    = { hiMin=3, hiMax=9, loMin=-2, loMax=3,  humMin=70, humMax=95 },
      "Light rain off and on, mild temperatures",
        Thalassiel  = { hiMin=6,  hiMax=12, loMin=1,  loMax=6,  humMin=70, humMax=90 },
      "Overcast with comfortable breezes throughout",
        Opsitheiel  = { hiMin=-1, hiMax=4,  loMin=-6, loMax=-1, humMin=75, humMax=95 }
      "Moist air with lingering fog near streams",
      "Intermittent showers may escalate into flash floods and coastal storms"
     },
     },
     ["Mediterranean (Hot Summer)"] = {
     Opsitheiel = {
        Atosiel    = { hiMin=15, hiMax=21, loMin=7, loMax=12, humMin=40, humMax=60 },
      "Overcast skies, cool drizzle through the day",
        Thalassiel  = { hiMin=25, hiMax=35, loMin=15, loMax=20, humMin=30, humMax=50 },
      "Low visibility in morning fog, slow clearing",
        Opsitheiel  = { hiMin=12, hiMax=18, loMin=5, loMax=10, humMin=45, humMax=65 }
      "Heavier showers arriving late afternoon",
      "Evening drizzle may develop into coastal gales and shoreline flooding"
    }
  },
 
  ["Subpolar Oceanic"] = {
    Atosiel = {
      "Near-freezing dawn, cold drizzle by midday",
      "Overcast skies, mix of rain and sleet",
      "Snowmelt water raising local streams",
      "Cold drizzle combined with snowstorms and blizzards in higher areas"
     },
     },
     ["Hot Desert"] = {
     Thalassiel = {
        Atosiel    = { hiMin=25, hiMax=35, loMin=12, loMax=18, humMin=10, humMax=30 },
      "Cool, short days with periodic drizzle",
        Thalassiel  = { hiMin=35, hiMax=45, loMin=25, loMax=30, humMin=5,  humMax=20 },
      "Short bursts of rain followed by fog",
        Opsitheiel  = { hiMin=22, hiMax=30, loMin=10, loMax=16, humMin=5,  humMax=25 }
      "Misty conditions could prompt snowstorms and river floods"
     },
     },
     ["Cold Steppe"] = {
     Opsitheiel = {
        Atosiel    = { hiMin=10, hiMax=18, loMin=2, loMax=8, humMin=30, humMax=50 },
      "Steady cold rain mixing with wet snow",
        Thalassiel  = { hiMin=20, hiMax=28, loMin=10, loMax=15, humMin=25, humMax=45 },
      "Freezing drizzle at dawn, raw gusty winds",
        Opsitheiel  = { hiMin=0, hiMax=7, loMin=-6, loMax=0,  humMin=35, humMax=55 }
      "Cloudy, damp day with potential sleet storms",
      "Freezing drizzle may trigger avalanches in higher passes"
    }
  },
 
  ["Mediterranean (Hot Summer)"] = {
    Atosiel = {
      "Mild temperatures, bright sunshine, dry air",
      "Sunny midday with a crisp, light wind",
      "Short drizzly spell, then mostly clear",
      "Bright skies overshadowed by potential heat advisory and droughts"
     },
     },
     ["Hot Steppe"] = {
     Thalassiel = {
        Atosiel    = { hiMin=23, hiMax=30, loMin=12, loMax=18, humMin=20, humMax=40 },
      "Very hot midday sun, minimal clouds",
        Thalassiel  = { hiMin=30, hiMax=40, loMin=20, loMax=25, humMin=15, humMax=35 },
      "Heatwaves persisting, zero rain expected",
        Opsitheiel  = { hiMin=25, hiMax=33, loMin=15, loMax=20, humMin=15, humMax=40 }
      "Intense midday sun might lead to severe heat waves and dust storms"
     },
     },
     ["Subarctic"] = {
     Opsitheiel = {
        Atosiel    = { hiMin=0, hiMax=8,  loMin=-10,loMax=-1, humMin=50, humMax=80 },
      "Cooler spells, brief showery intervals",
        Thalassiel  = { hiMin=5, hiMax=15, loMin=-1, loMax=5,  humMin=50, humMax=85 },
      "Sporadic light rainfall, refreshing breezes",
        Opsitheiel  = { hiMin=-5, hiMax=0,  loMin=-15,loMax=-5, humMin=60, humMax=90 }
      "Cooling breezes could reduce temperatures, but localized brushfires and summer wildfires remain possible"
     }
     }
}
  },


---------------------------------------------------------
  ["Hot Desert"] = {
-- 4. Weather Events by Climate & Season
    Atosiel = {
--    (Revised: several forecast strings now include
      "Dry air, bright sunshine, no clouds in sight",
--    keywords from triggeredDisasters)
      "Gentle breezes raising light sand by midday",
---------------------------------------------------------
      "Minor dust devil scouring the dunes",
local climateEvents = {
      "Scorching afternoons may be accompanied by dust storms and occasional mirage-like shimmers"
    ["Humid Subtropical"] = {
        Atosiel = {
            "Morning drizzle and warm afternoon sunshine",
            "Mild thunderstorm building by midday",
            "Patchy fog at dawn, clearing toward lunch",
            "Light rain with sunny breaks after noon",
            "Gentle breezes, blossoming warmth, low humidity",
            "Scattered clouds with a brief shower by dusk",
            "Humid sunrise, comfortable high around midday",
            "Overcast for part of the day, mild temperatures",
            "Warm breezes carrying faint floral scents",
            "Partial sun, warm with a slight chance of rain",
            "Early morning seasonal storms and lake-driven floods may develop"
        },
        Thalassiel = {
            "Hot, steamy day with intense midday heat",
            "Tropical-like humidity, afternoon thunder possible",
            "Intermittent heavy downpours, muggy evenings",
            "High humidity and patchy thunderstorms late",
            "Sun-scorched morning, scattered storms by dusk",
            "Hazy sunshine, extremely warm all day",
            "Thick humidity, chance of lightning late evening",
            "Sticky air, short downpours in isolated spots",
            "Heat advisory with only brief cooling at night",
            "Nighttime storms lingering into early morning",
            "Afternoon heavy rains could trigger river floods"
        },
        Opsitheiel = {
            "Warm daytime, gentle evening breezes",
            "Occasional rain, otherwise mild temperatures",
            "Late-season warmth, scattered rain after sunset",
            "Cooler mornings, returning to warmth by midday",
            "Sparse cloud cover, tranquil weather overall",
            "Fog at dawn, warm midday, pleasant night",
            "Partial sun, comfortable humidity levels",
            "Evening drizzle with mild breezes",
            "Patchy haze, moderate warmth throughout the day",
            "Short-lived shower, then clearing skies",
            "Evening cooling may lead to localized mudslides on steep slopes"
        }
     },
     },
     ["Oceanic"] = {
     Thalassiel = {
        Atosiel = {
      "Blistering midday sun, risk of heat stroke",
            "Frequent light rain, cool breezes all day",
      "Occasional dust storm cutting visibility briefly",
            "Foggy dawn, mild midmorning sunshine",
      "Extreme heat could combine with blowing sand to trigger potential sandstorms"
            "Short sunbreaks among passing showers",
            "Consistent drizzle, fresh winds from the sea",
            "Cloudy intervals with a few bright spells",
            "Late-afternoon clearing, crisp evening air",
            "Slow-moving clouds, mild temperatures",
            "Gentle but persistent rain, green foliage thriving",
            "Off-and-on showers, brief sunny interludes",
            "Steady breeze, moderate humidity levels",
            "Morning fog with coastal storms and marsh flooding possible"
        },
        Thalassiel = {
            "Light rain off and on, mild temperatures",
            "Overcast with comfortable breezes throughout",
            "Drizzly morning, partly cloudy afternoon",
            "Soft rains, lush vegetation, stable temps",
            "Cool, damp start, drying toward evening",
            "Frequent cloud cover, gentle summer breeze",
            "Moist air with lingering fog near streams",
            "Rain-laden air, occasional breaks of sun",
            "Maritime winds bringing light spray inland",
            "Cloudy midday, mild sunshine before dusk",
            "Intermittent showers may escalate into flash floods and coastal storms"
        },
        Opsitheiel = {
            "Overcast skies, cool drizzle through the day",
            "Sporadic showers, chillier in the evening",
            "Low visibility in morning fog, slow clearing",
            "Gray skies, frequent rain squalls, cool wind",
            "Chilly nightfall, damp ground all day",
            "Heavier showers arriving late afternoon",
            "Periodic drizzle, temperatures on the cool side",
            "Morning gloom, occasional pockets of clearing",
            "Evening rainfall pushing in from the coast",
            "Dense cloud cover, breezy and damp",
            "Evening drizzle may develop into coastal gales and shoreline flooding"
        }
     },
     },
     ["Subpolar Oceanic"] = {
     Opsitheiel = {
        Atosiel = {
      "Warm, mostly sunny with cooler nights",
            "Near-freezing dawn, cold drizzle by midday",
      "Occasional gust picking up desert grit",
            "Mist over melting snow patches, breezy afternoon",
      "Evening cooling might still see intense heat with possible dust devils on dry slopes"
            "Overcast skies, mix of rain and sleet",
    }
            "Cool, damp air, slight warmup after noon",
  },
            "Late-day sleet turning to light rain",
 
            "Mountaintop flurries, valley drizzle",
  ["Cold Steppe"] = {
            "Cloudy morning, partial clearing in late afternoon",
    Atosiel = {
            "Chilly breeze, scattered showers off and on",
      "Cool mornings, moderate midday warmth, breezy",
            "Gray skies, some slushy buildup on paths",
      "Light rain passing through grasslands midday",
            "Snowmelt water raising local streams",
      "Variable cloud cover, mild temperature swings",
            "Cold drizzle combined with snowstorms and blizzards in higher areas"
      "Mild conditions could turn volatile with sudden flash floods and minor landslides"
        },
        Thalassiel = {
            "Cool, short days with periodic drizzle",
            "Heavy low clouds, pockets of bright breaks",
            "Gentle rain, evening chill deeper than normal",
            "Intermittent showers, damp wind from hills",
            "Cloud deck lingering, occasional lighter drizzle",
            "Faint sunlight overshadowed by thick cloud",
            "Subdued warmth, sporadic mist across ridges",
            "Surprisingly bright morning, clouding by dusk",
            "Short bursts of rain followed by fog",
            "Late-night chill with drizzle or brief sleet",
            "Misty conditions could prompt snowstorms and river floods"
        },
        Opsitheiel = {
            "Steady cold rain mixing with wet snow",
            "Freezing drizzle at dawn, raw gusty winds",
            "Snowfall likely at higher elevations, rain below",
            "Cloudy, damp day with potential sleet storms",
            "Early morning frost turning to slush midday",
            "Occasional heavy showers with flurry bursts",
            "Gray gloom, subfreezing nights across valleys",
            "Pelting sleet for an hour, then calm, cold air",
            "Gusty evening, possible snow accumulations",
            "Sun rarely visible, persistent winter gloom",
            "Freezing drizzle may trigger avalanches in higher passes"
        }
     },
     },
     ["Mediterranean (Hot Summer)"] = {
     Thalassiel = {
        Atosiel = {
      "Warm day, potential for gusty thunderstorms",
            "Mild temperatures, bright sunshine, dry air",
      "Cloud buildup, short but intense showers possible",
            "Morning dew, warming fast under clear skies",
      "Rolling thunder near dusk, rain squalls possible",
            "Cool breezes off the sea, comfortable afternoon",
      "Afternoon heat may intensify, leading to thunderstorms, river floods, and gusty rain squalls"
            "Sunny midday with a crisp, light wind",
            "Short drizzly spell, then mostly clear",
            "Early bloom across hills, moderate warmth",
            "Mostly sunny, a few scattered clouds late",
            "Light shower possible at sunrise, drying by noon",
            "Warming trend, low humidity, pleasant day",
            "Gradual warming under spotless sky",
            "Bright skies overshadowed by potential heat advisory and droughts"
        },
        Thalassiel = {
            "Very hot midday sun, minimal clouds",
            "Scorching days, slight breeze at dusk",
            "Parched hillsides, no sign of rainfall",
            "High UV index, bright, glaring sunlight",
            "Coastal dryness, strong midday glare",
            "Overnight relief, but intense heat returns early",
            "Dust in the wind from inland hills",
            "Cloudless sky, near-record high temps",
            "Salt-laden breezes if near the coast, scorching inland",
            "Heatwaves persisting, zero rain expected",
            "Intense midday sun might lead to severe heat waves and dust storms"
        },
        Opsitheiel = {
            "Cooler spells, brief showery intervals",
            "Cloudy periods bringing mild relief from heat",
            "Pleasant afternoons, occasional dusk rains",
            "Moderate days, crisp mornings, calm nights",
            "Sporadic light rainfall, refreshing breezes",
            "Cloudbanks drifting inland, mild drizzle at times",
            "Patchy overcast, comfortable midday temps",
            "Clear dawn, mild sunshine, gentle wind late",
            "Intervals of cloud cover, potential short rain",
            "Moist sea air, moderate daytime warmth",
            "Cooling breezes could reduce temperatures, but localized brushfires and summer wildfires remain possible"
        }
     },
     },
     ["Hot Desert"] = {
     Opsitheiel = {
        Atosiel = {
      "Wind-driven chill under gray skies",
            "Hot afternoons, cooler late nights, wide temp swing",
      "Possible light snowfall, especially overnight",
            "Dry air, bright sunshine, no clouds in sight",
      "Sparse precipitation, dryness persists, cold air",
            "Gentle breezes raising light sand by midday",
      "Chilly nights might bring frost and the risk of localized landslides on steep slopes"
            "Evening chill setting in after hot daytime",
    }
            "Minor dust devil scouring the dunes",
  },
            "Visibility excellent, stable high pressure",
 
            "Patchy desert haze, strong sun overhead",
  ["Hot Steppe"] = {
            "No hint of precipitation, cloudless horizon",
    Atosiel = {
            "Daytime warmth, refreshing if slight breeze arrives",
      "Hot daytime sun, slight breeze, no rain",
            "Sun-warmed rock surfaces, mild nights",
      "Gusty wind with dust swirling near midday",
            "Scorching afternoons may be accompanied by dust storms and occasional mirage-like shimmers"
      "Scorching sun may lead to persistent dryness with a risk of brushfires and dust devils on dry slopes"
        },
        Thalassiel = {
            "Extreme heat from mid-morning onward",
            "Blistering midday sun, risk of heat stroke",
            "Near-record highs, swirling dust at times",
            "Virtually no clouds, scorching open sands",
            "Occasional dust storm cutting visibility briefly",
            "Mirage-like shimmer across flat terrain",
            "Sunset brings partial relief, still quite warm",
            "Dry conditions, subpar humidity all day",
            "Baking desert floor, potential for blowing sand",
            "Intense heat building day after day",
            "Extreme heat could combine with blowing sand to trigger potential sandstorms"
        },
        Opsitheiel = {
            "Warm, mostly sunny with cooler nights",
            "Sparse winds carrying fine sand sporadically",
            "Large day-night temperature gap, no precipitation",
            "Stable conditions, sunlit afternoons, brisk evenings",
            "Mildly hot days, star-filled clear skies at night",
            "Shallow dust clouds after brief winds",
            "Occasional gust picking up desert grit",
            "Gentle warmth, dryness prevalent, no clouds",
            "Cool breezes shortly after sunset, mild day overall",
            "Hazy horizon, soft morning glow across dunes",
            "Evening cooling might still see intense heat with possible dust devils on dry slopes"
        }
     },
     },
     ["Cold Steppe"] = {
     Thalassiel = {
        Atosiel = {
      "Brutally hot midday sun, patchy dust storms",
            "Cool mornings, moderate midday warmth, breezy",
      "Occasional swirling wind gusts, drifting dust",
            "Light rain passing through grasslands midday",
      "Intense heat may trigger severe dust storms and a potential heat wave"
            "Variable cloud cover, mild temperature swings",
            "Patchy frost at dawn, warming soon after",
            "Crisp air, possible small hail if storms appear",
            "Sunny afternoon, cooler late evening",
            "Gentle breeze, fleeting sun amidst scattered clouds",
            "Slow temperature rise, slight dryness in mid-afternoon",
            "Brief drizzle, otherwise bright and breezy",
            "Day-night difference noticeable, mild but windy",
            "Mild conditions could turn volatile with sudden flash floods and minor landslides"
        },
        Thalassiel = {
            "Warm day, potential for gusty thunderstorms",
            "Hot midday sun, dryness intensifying by late day",
            "Cloud buildup, short but intense showers possible",
            "Clear morning, chance of late afternoon storms",
            "Long daylight hours, rolling thunder near dusk",
            "Slight dryness, patchy heat with limited shade",
            "Periodic storms bringing relief from heat",
            "Warm evening, slight nighttime cooldown",
            "Sky remains mostly clear, grassland shimmering",
            "Thunderheads visible in distance, might pass by",
            "Afternoon heat may intensify, leading to thunderstorms, river floods, and gusty rain squalls"
        },
        Opsitheiel = {
            "Cool to cold days, frosty nights on open steppe",
            "Possible light snowfall, especially overnight",
            "Wind-driven chill under gray skies",
            "Sparse precipitation, dryness persists, cold air",
            "Subfreezing after sunset, sometimes sunny midday",
            "Short bursts of sunshine, otherwise chilly conditions",
            "Occasional dusting of snow, quickly melting by noon",
            "Biting wind, few clouds, stark horizon",
            "Clearing late morning, crisp, cold nightfall",
            "Daytime near freezing, nights well below",
            "Chilly nights might bring frost and the risk of localized landslides on steep slopes"
        }
     },
     },
     ["Hot Steppe"] = {
     Opsitheiel = {
        Atosiel = {
      "Hot days, moderate evenings, stable dryness",
            "Hot daytime sun, slight breeze, no rain",
      "Dust devils possible over parched plains",
            "Gusty wind with dust swirling near midday",
      "Evening cooling might reduce temperatures, yet localized sandstorms and minor landslides remain a concern"
            "Warm mornings, scorching afternoon, very low humidity",
    }
            "Light breeze after sundown, starry overhead",
  },
            "Some haze from heated ground, minimal cloud cover",
 
            "Sun intensity rising toward midday peak",
  ["Subarctic"] = {
            "Dry soil conditions, no sign of moisture",
    Atosiel = {
            "Sparse vegetation, daily heat climbing gradually",
      "Snow melting, slushy paths, daytime sunshine",
            "Clear horizon, wind picking up in late evening",
      "Mixed precipitation, cold rain at lower altitudes",
            "Sun exposure high, consistent dryness",
      "Rapid thawing could spark river floods and snowstorms with heavy snowfall"
            "Scorching sun may lead to persistent dryness with a risk of brushfires and dust devils on dry slopes"
    },
        },
    Thalassiel = {
        Thalassiel = {
      "Short, cool days, moderate sunshine at times",
            "Brutally hot midday sun, patchy dust storms",
      "Drizzle commonly, heavier rainfall occasionally",
            "Scorching conditions, faint breeze offers minimal relief",
      "Cool days may be interrupted by sudden blizzard-like conditions and heavy rain squalls"
            "Occasional swirling wind gusts, drifting dust",
            "Heated ground emits rippling mirages at distance",
            "Persistent dryness, minimal nighttime cooldown",
            "Skies remain cloudless, high evaporation rate",
            "Dust-laden air in late afternoon swirl",
            "Rare cloud can appear but quickly vanishes",
            "Daily highs remain extreme, nights only mild",
            "Lingering warmth through sunset, dryness unwavering",
            "Intense heat may trigger severe dust storms and a potential heat wave"
        },
        Opsitheiel = {
            "Hot days, moderate evenings, stable dryness",
            "Rare short rains if storm fronts approach, mostly none",
            "Daytime heat lingering, nights slightly cooler",
            "Periodically hazy morning from airborne dust",
            "Mild breezes, daytime remains intensely hot",
            "Cloudless skies, gentle drop in temps overnight",
            "Dust devils possible over parched plains",
            "Occasional relief from mild wind, no precipitation",
            "Hot, tranquil afternoon, moderate wind at dusk",
            "Minimal cloud presence, dryness dominating environment",
            "Evening cooling might reduce temperatures, yet localized sandstorms and minor landslides remain a concern"
        }
     },
     },
     ["Subarctic"] = {
     Opsitheiel = {
        Atosiel = {
      "Early snowfall returning, freezing ground rapidly",
            "Snow melting, slushy paths, daytime sunshine",
      "Blizzard-like conditions with large snow accumulations",
            "Chilly breezes, frequent flurry patches possible",
      "Deep winter chill may result in persistent blizzards and avalanches in higher passes"
            "Long nights gradually shortening, crisp mornings",
            "Mixed precipitation, cold rain at lower altitudes",
            "Slush build-up midmorning, partial sun midday",
            "Sunny breaks around noon, subzero by late night",
            "Sporadic storm with sleet or melting snow",
            "Icy patches linger, overall chilly but bright",
            "Early thaw, mild midday, returning chill after dusk",
            "Reluctant spring with freezing nights, slow warm days",
            "Rapid thawing could spark river floods and snowstorms with heavy snowfall"
        },
        Thalassiel = {
            "Short, cool days, moderate sunshine at times",
            "Rainy intervals, potential sleet in highest peaks",
            "Snow line retreating, green valleys emerging slowly",
            "Extended twilight, crisp air after sundown",
            "Sudden midafternoon chill despite mid-summer period",
            "Drizzle commonly, heavier rainfall occasionally",
            "Mountain passes remain cool, breezy conditions",
            "Mostly cloudy, occasional glimpses of sun",
            "Brisk wind, earlier nightfall than lower latitudes",
            "Daytime highs remain mild, nights quite cold",
            "Cool days may be interrupted by sudden blizzard-like conditions and heavy rain squalls"
        },
        Opsitheiel = {
            "Early snowfall returning, freezing ground rapidly",
            "Prolonged darkness, bitterness sets in quickly",
            "Gusty winds carrying ice crystals through valleys",
            "Heavy drifts forming, multiple snowfall events",
            "Frequent subzero lows, no hint of thaw",
            "Blizzard-like conditions with large snow accumulations",
            "Icy rivers, widespread frost in daylight",
            "Mountains see near-constant snow, valleys freeze nightly",
            "Rare clear spells, intense wind chill outside",
            "Deep winter mode, swirling snow flurries all day",
            "Deep winter chill may result in persistent blizzards and avalanches in higher passes"
        }
     }
     }
  }
}
}


---------------------------------------------------------
---------------------------------------------------------
-- 5. Triggered Disasters and Natural Disaster Advisory
-- 7) Triggered disaster keywords (extra advisory layer)
--    (Each hazard is triggered with a 10% chance)
---------------------------------------------------------
---------------------------------------------------------
local triggeredDisasters = {
local triggeredDisasters = {
    { keywords = {"seasonal storms","lake%-driven floods"}, hazard = "Seasonal storms and lake-driven floods" },
  { keywords={"seasonal storms","lake%-driven floods"}, hazard="Seasonal storms and lake-driven floods" },
    { keywords = {"river floods","heavy rains"},           hazard = "River floods during heavy rains" },
  { keywords={"river floods","heavy rains"}, hazard="River floods during heavy rains" },
    { keywords = {"snowstorms","higher elevations"},       hazard = "Snowstorms at higher elevations" },
  { keywords={"snowstorms","heavy snowfall"}, hazard="Snowstorms / heavy snowfall" },
    { keywords = {"steam vent eruptions"},                 hazard = "Occasional steam vent eruptions" },
  { keywords={"avalanch"}, hazard="Avalanche risk" },
    { keywords = {"landslides","steep slopes"},             hazard = "Landslides on steep slopes" },
  { keywords={"landslides","mudslides"}, hazard="Landslides / mudslides" },
    { keywords = {"droughts","brushfires"},                hazard = "Droughts and brushfires" },
  { keywords={"drought","persistent dryness","dry conditions"}, hazard="Drought / elevated fire risk" },
    { keywords = {"recurrent flooding in marshes","flooding in marshes"}, hazard = "Recurrent flooding in marshes" },
  { keywords={"brushfires","wildfires"}, hazard="Brushfire / wildfire risk" },
    { keywords = {"localized mudslides"},                  hazard = "Localized mudslides" },
  { keywords={"dust storm","sandstorm","blowing sand"}, hazard="Dust / sandstorm risk" },
    { keywords = {"fog%-related travel hazards"},           hazard = "Fog-related travel hazards" },
  { keywords={"fog","low visibility"}, hazard="Fog-related hazards" },
    { keywords = {"swamp flooding"},                       hazard = "Swamp flooding" },
  { keywords={"severe storm","thunder"}, hazard="Severe storm / lightning risk" },
    { keywords = {"river floods"},                         hazard = "River floods" },
  { keywords={"shoreline flooding","storm surges","coastal gales"}, hazard="Coastal flooding / surge risk" }
    { keywords = {"blizzards"},                            hazard = "Blizzards" },
    { keywords = {"dust storms","heatwaves"},              hazard = "Dust storms and heatwaves" },
    { keywords = {"flash floods"},                          hazard = "Occasional flash floods" },
    { keywords = {"tar%-pit gas releases"},                hazard = "Tar-pit gas releases" },
    { keywords = {"landslides on steep slopes"},            hazard = "Landslides on steep slopes" },
    { keywords = {"coastal storms"},                        hazard = "Coastal storms" },
    { keywords = {"mild landslides"},                      hazard = "Mild landslides" },
    { keywords = {"summer wildfires"},                     hazard = "Summer wildfires" },
    { keywords = {"cliff erosion","sea storms"},            hazard = "Cliff erosion and sea storms" },
    { keywords = {"flood surges near waterfalls"},          hazard = "Flood surges near waterfalls" },
    { keywords = {"coastal gales","blizzards"},            hazard = "Coastal gales, blizzards" },
    { keywords = {"marsh flooding"},                        hazard = "Marsh flooding" },
    { keywords = {"cliff collapses"},                      hazard = "Occasional cliff collapses" },
    { keywords = {"sandstorms"},                            hazard = "Sandstorms" },
    { keywords = {"minor landslides"},                      hazard = "Minor landslides" },
    { keywords = {"lake overflows","marsh flooding"},      hazard = "Lake overflows, marsh flooding" },
    { keywords = {"avalanches in higher passes"},          hazard = "Avalanches in higher passes" },
    { keywords = {"shoreline flooding"},                    hazard = "Shoreline flooding" },
    { keywords = {"wildfires in dry spells"},              hazard = "Wildfires in dry spells" },
    { keywords = {"droughts"},                              hazard = "Droughts" },
    { keywords = {"swamp overflows"},                      hazard = "Swamp overflows" },
    { keywords = {"landslides near hot springs"},          hazard = "Occasional landslides near hot springs" },
    { keywords = {"dust devils on dry slopes"},            hazard = "Dust devils on dry slopes" },
    { keywords = {"storm surges from inlet"},              hazard = "Storm surges from inlet" },
    { keywords = {"mine collapses","gas leaks"},           hazard = "Mine collapses, gas leaks" },
    { keywords = {"frequent rain squalls", "heavy downpours", "intermittent heavy downpours"}, hazard = "Localized mudslides" },
    { keywords = {"blizzard%-like conditions"},            hazard = "Blizzards" },
    { keywords = {"extreme heat", "intense heat", "heat advisory"}, hazard = "Heat wave" },
    { keywords = {"rain squalls", "persistent dryness", "dry conditions"}, hazard = "Risk of brushfires" },
    { keywords = {"foggy dawn", "low visibility", "dense fog"}, hazard = "Fog-related hazards" },
    { keywords = {"heavy snowfall", "gusty winds carrying ice", "deep winter mode"}, hazard = "Possible avalanche" },
    { keywords = {"strong desert winds", "dust storm", "mirage%-like shimmer"}, hazard = "Sandstorm risk" },
    { keywords = {"swamp flooding", "marsh flooding"},      hazard = "Potential flood surge" },
    { keywords = {"tornado", "twister"},                  hazard = "Tornado possibility" },
    { keywords = {"volcano", "volcanic"},                  hazard = "Volcanic activity nearby" },
    { keywords = {"earthquake", "seismic"},                hazard = "Minor earthquake risk" },
    { keywords = {"hail", "ice storm"},                   hazard = "Hail storm risk" },
    { keywords = {"severe storm", "severe weather"},       hazard = "Severe weather warning" },
    { keywords = {"tsunami", "coastal surge"},            hazard = "Tsunami risk" },
}
}


local function getAdvisory(forecastStr)
---------------------------------------------------------
    local textLower = forecastStr:lower()
-- 8) Wind directions
    local extras = {}
---------------------------------------------------------
    -- Each disaster is triggered with a 10% chance:
local windDirections = { "N","NE","E","SE","S","SW","W","NW" }
    local randomChance = 0.10


    for _, item in ipairs(triggeredDisasters) do
---------------------------------------------------------
         for _, kw in ipairs(item.keywords) do
-- 9) City list
             if textLower:find(kw) then
---------------------------------------------------------
                if math.random() <= randomChance then
local cityData = {
                    table.insert(extras, item.hazard)
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Vaeringheim Vaeringheim]",      climate = "Humid Subtropical"},
                end
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Luminaria Luminaria]",         climate = "Oceanic"},
                break
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Serena Serena]",              climate = "Subpolar Oceanic"},
             end
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Pyralis Pyralis]",             climate = "Oceanic"},
         end
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Symphonara Symphonara]",      climate = "Oceanic"},
    end
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Aurelia Aurelia]",            climate = "Mediterranean (Hot Summer)"},
 
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Somniumpolis Somniumpolis]",  climate = "Humid Subtropical"},
    -- Fallback: if no hazard was triggered, a 10% chance for a generic advisory
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Nexa Nexa]",                  climate = "Oceanic"},
     if #extras == 0 and math.random() <= 0.10 then
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Saluria Saluria]",             climate = "Oceanic"},
        table.insert(extras, "Unusual atmospheric conditions detected")
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Aetherium Aetherium]",         climate = "Subarctic"},
     end
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Ferrum_Citadel Ferrum Citadel]", climate = "Hot Desert"},
 
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Acheron Acheron]",            climate = "Cold Steppe"},
    if #extras > 0 then
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Tonar Tonar]",                climate = "Cold Steppe"}, -- ADDED
        return table.concat(extras, "; ")
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Ephyra Ephyra]",              climate = "Subpolar Oceanic"},
     else
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Keybir-Aviv Keybir-Aviv]",     climate = "Humid Subtropical"},
         return "No reports"
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Tel-Amin Tel-Amin]",          climate = "Mediterranean (Hot Summer)"},
     end
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Diamandis Diamandis]",        climate = "Mediterranean (Hot Summer)"},
end
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Thermosalem Thermosalem]",    climate = "Oceanic"},
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Sufriya Sufriya]",             climate = "Humid Subtropical"},
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Akróstadium Akróstadium]",     climate = "Cold Steppe"},
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Lykopolis Lykopolis]",        climate = "Oceanic"},
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Ourid Ourid]",                climate = "Cold Steppe"},
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Bashkim Bashkim]",            climate = "Cold Steppe"},
  {city = "Slevik",       climate = "Subpolar Oceanic"},
  {city = "Sårensby",     climate = "Subpolar Oceanic"},
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Sjøsborg Sjøsborg]",          climate = "Subpolar Oceanic"},
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Storesund Storesund]",         climate = "Subpolar Oceanic"},
  {city = "Fanghorn",     climate = "Subarctic"},
  {city = "Eikbu",        climate = "Subarctic"}
}


---------------------------------------------------------
---------------------------------------------------------
-- 7. Conversions and Random Stats
-- 10) City name parsing (for deterministic seeds + ops API)
---------------------------------------------------------
---------------------------------------------------------
local function cToF(c)
local function extractPlainCity(cityField)
    if type(c) == "number" then
  cityField = tostring(cityField or "")
        return math.floor(c * 9/5 + 32 + 0.5)
  -- [https://... Label]
    end
  local label = cityField:match("^%[https?://[^%s]+%s+([^%]]+)%]$")
    return c
  if label and label ~= "" then return label end
  -- [[...|Label]]
  label = cityField:match("^%[%[[^%]|]+|([^%]]+)%]%]$")
  if label and label ~= "" then return label end
  -- [[Label]]
  label = cityField:match("^%[%[([^%]]+)%]%]$")
  if label and label ~= "" then return label end
  return cityField
end
end


-- Limit wind speeds to 0-15 km/h for Earth-like mild conditions.
local function getCityEntryByName(cityName)
local windDirections = {"N","NE","E","SE","S","SW","W","NW"}
  local wanted = tostring(cityName or "")
 
   for _, entry in ipairs(cityData) do
local function getRandomWeatherStats(climate, season)
     local plain = extractPlainCity(entry.city)
    local data = climateTemperature[climate] and climateTemperature[climate][season]
     if plain == wanted then return entry, plain end
    if not data then
  end
        return {
  return nil, wanted
            high = "N/A",
            low = "N/A",
            humidity = "N/A",
            chanceOfRain = "N/A",
            windDir = "N/A",
            windSpeed = "N/A"
        }
    end
 
    local hi   = math.random(data.hiMin, data.hiMax)
    local lo  = math.random(data.loMin, data.loMax)
     local hum  = math.random(data.humMin, data.humMax)
     local cRain= math.random(0,100)
    local wDir = windDirections[math.random(#windDirections)]
    local wSpd = math.random(0,15)
 
    return {
        high        = hi,
        low        = lo,
        humidity    = hum,
        chanceOfRain= cRain,
        windDir    = wDir,
        windSpeed  = wSpd
    }
end
end


---------------------------------------------------------
---------------------------------------------------------
-- 8. City Data
-- 11) Ops alert computation
---------------------------------------------------------
---------------------------------------------------------
local cityData = {
local function computeAlert(nextInt, cond, impacts, precipProb, windKmh, visKm, hiF, loF, forecastText)
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Vaeringheim Vaeringheim]",      climate = "Humid Subtropical"},
   local level = 0
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Luminaria Luminaria]",         climate = "Oceanic"},
  local hazard = "No reports"
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Serena Serena]",               climate = "Subpolar Oceanic"},
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Pyralis Pyralis]",             climate = "Oceanic"},
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Symphonara Symphonara]",       climate = "Oceanic"},
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Aurelia Aurelia]",             climate = "Mediterranean (Hot Summer)"},
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Somniumpolis Somniumpolis]",   climate = "Humid Subtropical"},
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Nexa Nexa]",                  climate = "Oceanic"},
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Lunalis_Sancta Lunalis Sancta]", climate = "Oceanic"},
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Sylvapolis Sylvapolis]",      climate = "Humid Subtropical"},


    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Saluria Saluria]",            climate = "Oceanic"},
  local function bump() level = math.min(3, level + 1) end
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Aetherium Aetherium]",        climate = "Subarctic"},
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Ferrum_Citadel Ferrum Citadel]", climate = "Hot Desert"},
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Acheron Acheron]",            climate = "Cold Steppe"},
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Erythros Erythros]",          climate = "Oceanic"},
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Catonis_Atrium Catonis Atrium]", climate = "Oceanic"},
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Delphica Delphica]",           climate = "Oceanic"},
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Koinonía Koinonía]",          climate = "Oceanic"},
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Aureum Aureum]",              climate = "Mediterranean (Hot Summer)"},


    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Skýrophos Skýrophos]",        climate = "Oceanic"},
  precipProb = tonumber(precipProb) or 0
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Bjornopolis Bjornopolis]",    climate = "Oceanic"},
  windKmh = tonumber(windKmh) or 0
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Aegirheim Aegirheim]",        climate = "Subarctic"},
  visKm = tonumber(visKm) or 99
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Norsolyra Norsolyra]",        climate = "Oceanic"},
  hiF = tonumber(hiF) or 0
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Thorsalon Thorsalon]",        climate = "Oceanic"},
  loF = tonumber(loF) or 0


    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Pelagia Pelagia]",            climate = "Hot Steppe"},
  -- Base bump from impacts
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Myrene Myrene]",              climate = "Oceanic"},
  if impacts and (impacts.air ~= "OK" or impacts.sea ~= "OK" or impacts.ground ~= "OK") then bump() end
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Thyrea Thyrea]",              climate = "Humid Subtropical"},
  if impacts and (impacts.air == "No-Go" or impacts.sea == "Closed" or impacts.ground == "Hazard") then bump(); bump() end
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Ephyra Ephyra]",              climate = "Subpolar Oceanic"},
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Halicarn Halicarn]",          climate = "Mediterranean (Hot Summer)"},


    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Keybir-Aviv Keybir-Aviv]",    climate = "Humid Subtropical"},
  -- Additional bumps
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Tel-Amin Tel-Amin]",          climate = "Mediterranean (Hot Summer)"},
  if windKmh >= 30 or visKm <= 5 or precipProb >= 85 then bump() end
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Diamandis Diamandis]",        climate = "Mediterranean (Hot Summer)"},
  if hiF >= 105 or loF <= 5 then bump() end
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Jogi Jogi]",                  climate = "Oceanic"},
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Lewisburg Lewisburg]",        climate = "Humid Subtropical"},


    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Thermosalem Thermosalem]",    climate = "Oceanic"},
  -- Hazard summary
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Akróstadium Akróstadium]",    climate = "Cold Steppe"},
  if cond == "Storm" then hazard = "Severe storm / lightning risk"
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Sufriya Sufriya]",            climate = "Humid Subtropical"},
  elseif cond == "Rain" then hazard = "Heavy rain / localized flooding"
     {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Lykopolis Lykopolis]",        climate = "Oceanic"},
  elseif cond == "Fog" then hazard = "Low visibility / navigation hazard"
  elseif cond == "Blizzard" then hazard = "Blizzard / whiteout conditions"
  elseif cond == "Snow" then hazard = "Snow/ice / hazardous travel"
  elseif cond == "Dust" then hazard = "Dust/sand reduction in visibility"
  else
    if hiF >= 100 then hazard = "Heat stress conditions"
     elseif loF <= 10 then hazard = "Extreme cold exposure risk"
    else hazard = "No reports"
    end
  end


    -- NEW ENTRIES for Bassaridian Normark:
  -- Deterministic “extra advisory” based on keyword hits
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Ardclach Ardclach]",       climate = "Subarctic"},        -- Dfc
  local extras = {}
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Riddersborg Riddersborg]",    climate = "Subpolar Oceanic"},  -- Cfc
  local t = (forecastText or ""):lower()
  for _, item in ipairs(triggeredDisasters) do
    for _, kw in ipairs(item.keywords or {}) do
       if t:find(kw) then
        local pHit = (level >= 2) and 0.35 or 0.10
        if rand01(nextInt) <= pHit then extras[#extras+1] = item.hazard end
        break
      end
    end
  end


    -- NEW (Valley of Keltia) central steppe cities:
  local recommend = { hatch=false, missionary=false }
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Ourid Ourid]",               climate = "Cold Steppe"},
  if impacts and (impacts.sea ~= "OK" or impacts.air ~= "OK") then recommend.hatch = true end
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Bashkim Bashkim]",          climate = "Cold Steppe"},
  if impacts and (impacts.ground ~= "OK") then recommend.missionary = true end
  if hiF >= 100 or loF <= 10 then recommend.missionary = true end


    -- NEW: Major Cities in Eastern Caledonia
  return {
    {city = "Skøda",        climate = "Subarctic"},          -- Dfc (mountainous, northern/central)
     level = level,
     {city = "Fanghorn",     climate = "Subarctic"},          -- Dfc (mountainous, northern/central)
     label = alertLabel(level),
     {city = "Eikbu",        climate = "Subarctic"},          -- Dfc (mountainous, northern/central)
     hazard = hazard,
    {city = "Galvø",       climate = "Subarctic"},          -- Dfc (mountainous, northern/central)
     impacts = impacts or {ground="OK", sea="OK", air="OK"},
     {city = "Slevik",       climate = "Subpolar Oceanic"},  -- Cfc (arctic/coastal)
     extras = extras,
     {city = "Sårensby",    climate = "Subpolar Oceanic"},  -- Cfc (arctic/coastal)
     recommend = recommend,
    {city = "Krlsgorod",   climate = "Subarctic"},         -- Dfc (lakeshore/arctic)
     advisory = (#extras > 0) and table.concat(extras, "; ") or "No reports"
    {city = "Kaledonija",  climate = "Subarctic"},         -- Dfc (lakeshore/arctic)
  }
     {city = "Notranskja",   climate = "Subarctic"},          -- Dfc
end
     {city = "Hammarfell",   climate = "Subarctic"},          -- Dfc
     {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Sjøsborg Sjøsborg]",     climate = "Subpolar Oceanic"},  -- Cfc (arctic/coastal)
    {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Storesund Storesund]",    climate = "Subpolar Oceanic"},  -- Cfc (arctic/coastal)
 
}


---------------------------------------------------------
---------------------------------------------------------
-- 9. Color-coded cell background
-- 12) Core forecast generator (deterministic)
---------------------------------------------------------
---------------------------------------------------------
local function getEventColor(eventText)
local function makeForecastForCity(cityName, year, dayOfYear)
    local textLower = eventText:lower()
  local entry, plain = getCityEntryByName(cityName)
    if textLower:find("thunder") or textLower:find("storm") then
  if not entry then return nil end
        return "#FFD2D2"
 
    elseif textLower:find("snow") or textLower:find("sleet") or textLower:find("flurries") then
  local season = getSeasonName(dayOfYear)
        return "#D2ECFF"
  local climateName = entry.climate
    elseif textLower:find("rain") or textLower:find("drizzle") or textLower:find("downpour") then
        return "#D2DFFF"
    elseif textLower:find("dust") or textLower:find("desert") then
        return "#FFFACD"
    else
        return "#F8F8F8"
    end
end


---------------------------------------------------------
  local nextInt = lcg(hash31(string.format("wx:%d:%03d:%s", year, dayOfYear, plain)))
-- 10A. ALL-CITIES FORECAST FUNCTION
--    Overriding chance of rain if precipitation is mentioned
---------------------------------------------------------
function p.weatherForecast(frame)
    -- Daily seed
    local date = os.date("*t")
    local dailySeed = (date.year * 1000) + date.yday
    math.randomseed(dailySeed)


    local dateInfo   = getCurrentDateInfo()
   local seasonTbl = climateEvents[climateName] and climateEvents[climateName][season]
    local dayOfYear  = dateInfo.dayOfYear
  local forecastStr = (seasonTbl and #seasonTbl > 0) and pickFromList(nextInt, seasonTbl) or "No data"
    local yearNumber = dateInfo.psscYear
    local seasonName = getSeasonName(dayOfYear)


    local out = {}
  local cond = conditionFromText(forecastStr)
    table.insert(out, "== Daily Weather Forecast ==\n")
    table.insert(out,
        string.format("''(Day %d of Year %d PSSC, %s)''\n\n",
            dayOfYear, yearNumber, seasonName
        )
    )


    table.insert(out, '{| class="wikitable sortable" style="width:100%; text-align:left;"' .. "\n")
  local tdata = climateTemperature[climateName] and climateTemperature[climateName][season]
    table.insert(out, [[
  if not tdata then return nil end
! City
! Climate
! Season
! High °F
! Low °F
! Humidity (%)
! Chance of Rain (%)
! Wind Direction
! Wind Speed (km/h)
! Today's Weather
! Natural Disaster Advisory
]])


    for _, entry in ipairs(cityData) do
  local hiC = randInt(nextInt, tdata.hiMin, tdata.hiMax)
        local cityLink    = entry.city
  local loC = randInt(nextInt, tdata.loMin, tdata.loMax)
        local climateName = entry.climate
  local hum = randInt(nextInt, tdata.humMin, tdata.humMax)


        -- 1) Random weather event
  -- precip probability (boost if forecast text implies precip)
        local climateTbl = climateEvents[climateName]
  local precipProb = randInt(nextInt, 0, 100)
        local seasonTbl  = climateTbl and climateTbl[seasonName]
  local fLower = (forecastStr or ""):lower()
        local fStr      = "No data"
  if fLower:find("rain") or fLower:find("drizzle") or fLower:find("downpour") or fLower:find("shower")
        if seasonTbl and #seasonTbl > 0 then
    or fLower:find("snow") or fLower:find("storm") or fLower:find("thunder") or fLower:find("sleet")
            local idx = math.random(#seasonTbl)
  then
            fStr = seasonTbl[idx]
    precipProb = randInt(nextInt, 70, 99)
        end
  end


        -- 2) Stats
  local precipType = "None"
        local stats = getRandomWeatherStats(climateName, seasonName)
  if cond == "Rain" or cond == "Storm" then precipType = "Rain"
  elseif cond == "Snow" or cond == "Blizzard" then precipType = "Snow"
  end


        -- Override chanceOfRain if forecast text indicates precipitation:
  -- wind / gusts scale by condition
        local forecastLower = fStr:lower()
  local windDir = windDirections[(nextInt() % #windDirections) + 1]
        if forecastLower:find("rain")
  local windKmh
          or forecastLower:find("drizzle")
  if cond == "Storm" then windKmh = randInt(nextInt, 22, 55)
          or forecastLower:find("downpour")
  elseif cond == "Blizzard" then windKmh = randInt(nextInt, 20, 50)
          or forecastLower:find("shower")
  elseif cond == "Snow" then windKmh = randInt(nextInt, 10, 40)
          or forecastLower:find("snow")
  elseif cond == "Dust" then windKmh = randInt(nextInt, 15, 45)
          or forecastLower:find("storm")
  else windKmh = randInt(nextInt, 0, 28)
          or forecastLower:find("thunder")
  end
          or forecastLower:find("sleet")
  local gustKmh = math.min(90, windKmh + randInt(nextInt, 5, 30))
        then
            stats.chanceOfRain = math.random(70, 99)
        end


        local hiF   = cToF(stats.high)
   local cloud, visKm = computeCloudAndVis(nextInt, cond)
        local loF  = cToF(stats.low)
        local hum  = stats.humidity
        local cRain = stats.chanceOfRain
        local wDir  = stats.windDir
        local wSpd  = stats.windSpeed


        local rowColor = getEventColor(fStr)
  local hiF, loF = cToF(hiC), cToF(loC)
        local advisory = getAdvisory(fStr)
  local feelsF = computeFeelsLikeF(hiF, hum, windKmh)


        table.insert(out, "|-\n")
  local impacts = computeImpacts(cond, precipProb, windKmh, visKm, hiF, loF)
        table.insert(out, string.format([[
  local alert = computeAlert(nextInt, cond, impacts, precipProb, windKmh, visKm, hiF, loF, forecastStr)
| %s
| %s
| %s
| %d
| %d
| %d
| %d
| %s
| %d
| style="background-color:%s" | %s
| %s
]],
            cityLink, climateName, seasonName,
            hiF, loF, hum, cRain, wDir, wSpd,
            rowColor, fStr, advisory
        ))
    end


     table.insert(out, "|}\n")
  return {
     return table.concat(out)
    entry = entry,
    cityPlain = plain,
     cityDisplay = entry.city,
    climate = climateName,
    season = season,
    condition = cond,
    hiF = hiF, loF = loF, feelsF = feelsF,
    humidity = hum,
    precipType = precipType,
    precipProb = precipProb,
    windDir = windDir,
    windKmh = windKmh,
    gustKmh = gustKmh,
    cloud = cloud,
    visKm = visKm,
    forecast = forecastStr,
     ops = alert
  }
end
end


---------------------------------------------------------
---------------------------------------------------------
-- 10B. SINGLE-CITY FORECAST FUNCTION
-- 13) Exported Ops API for other modules
--    Similarly overrides chance of rain if precipitation is found
---------------------------------------------------------
---------------------------------------------------------
function p.weatherForCity(frame)
function p.getOpsData(cityName, year, dayOfYear)
     local cityRequested = frame.args.city
  local y = tonumber(year)
    if not cityRequested or cityRequested == "" then
  local d = tonumber(dayOfYear)
        return "Error: Please specify a city. E.g. {{#invoke:BassaridiaForecast|weatherForCity|city=Vaeringheim}}"
  if not y or not d then
    end
     local di = getDateInfo(nil)
    y, d = di.year, di.dayOfYear
  end
  local fc = makeForecastForCity(cityName, y, d)
  if not fc then return nil end
  return {
    city = fc.cityPlain,
    climate = fc.climate,
    season = fc.season,
    condition = fc.condition,
    hiF = fc.hiF, loF = fc.loF, feelsF = fc.feelsF,
    humidity = fc.humidity,
    precipType = fc.precipType,
    precipProb = fc.precipProb,
    windDir = fc.windDir,
    windKmh = fc.windKmh,
    gustKmh = fc.gustKmh,
    cloud = fc.cloud,
    visKm = fc.visKm,
    impacts = fc.ops.impacts,
    alert = {
      level = fc.ops.level,
      label = fc.ops.label,
      hazard = fc.ops.hazard,
      recommend = fc.ops.recommend,
      advisory = fc.ops.advisory
    }
  }
end


    -- Daily seed
function p.opsAlert(frame)
    local date = os.date("*t")
  local args = (frame and frame.args) or {}
     local dailySeed = (date.year * 1000) + date.yday
  local di = getDateInfo(args)
    math.randomseed(dailySeed)
  local city = tostring(args.city or "")
  if city == "" then
     return "Error: city parameter required. Example: {{#invoke:BassaridiaForecast|opsAlert|city=Vaeringheim}}"
  end
  local op = p.getOpsData(city, di.year, di.dayOfYear)
  if not op then return "Error: unknown city '" .. city .. "'." end
  local badge = spanBadge(op.alert.level, op.alert.label .. " – " .. (op.condition or ""))
  return string.format("%s<br/>%s<br/>%s", badge, formatImpact(op.impacts), op.alert.hazard or "No reports")
end


    local dateInfo   = getCurrentDateInfo()
---------------------------------------------------------
    local dayOfYear = dateInfo.dayOfYear
-- 14) Forecast tables
    local yearNumber = dateInfo.psscYear
---------------------------------------------------------
    local seasonName = getSeasonName(dayOfYear)
function p.weatherForecast(frame)
  local args = (frame and frame.args) or {}
   local di = getDateInfo(args)
  local dayOfYear, yearNumber = di.dayOfYear, di.year
  local seasonName = getSeasonName(dayOfYear)


    local foundEntry = nil
  local out = {}
    for _, entry in ipairs(cityData) do
  table.insert(out, "== Daily Weather Forecast ==")
        -- Attempt to match either the full city link or the last plain text
  table.insert(out, string.format("''(Day %d of Year %d PSSC, %s)''", dayOfYear, yearNumber, seasonName))
        local plainCity = entry.city:match("%s+([A-Za-zÀ-ž%-]+)%]?$")
  table.insert(out, "")
        if entry.city == cityRequested or plainCity == cityRequested then
            foundEntry = entry
            break
        end
    end


    if not foundEntry then
  table.insert(out, '<div style="overflow-x:auto;">')
        return "Error: City '" .. cityRequested .. "' not found in cityData."
  table.insert(out, '{| class="wikitable sortable" style="width:100%; text-align:left;"')
     end
  table.insert(out, table.concat({
    "! City",
    "! Climate",
    "! Season",
    "! Condition",
    "! High °F",
    "! Low °F",
    "! Feels Like °F",
    "! Humidity (%)",
    "! Precip",
    "! Wind Dir",
    "! Wind (km/h)",
    "! Gust (km/h)",
    "! Cloud (%)",
    "! Vis (km)",
    "! Ops Impact",
    "! Ops Alert",
    "! Today's Weather",
     "! Natural Disaster Advisory"
  }, "\n"))


     local cityLink  = foundEntry.city
  for _, entry in ipairs(cityData) do
     local climateName= foundEntry.climate
     local cityPlain = extractPlainCity(entry.city)
     local fc = makeForecastForCity(cityPlain, yearNumber, dayOfYear)
    if fc then
      local opsBadge = spanBadge(fc.ops.level, fc.ops.label)
      local opsImpact = formatImpact(fc.ops.impacts)
      local precipCell = string.format("%s %d%%", fc.precipType, fc.precipProb)


    local climateTbl = climateEvents[climateName]
      table.insert(out, "|-")
    local seasonTbl  = climateTbl and climateTbl[seasonName]
      table.insert(out, string.format(
    local fStr      = "No data"
        "| %s\n| %s\n| %s\n| %s\n| %d\n| %d\n| %d\n| %d\n| %s\n| %s\n| %d\n| %d\n| %d\n| %d\n| %s\n| %s<br/>%s\n| %s\n| %s",
    if seasonTbl and #seasonTbl > 0 then
        fc.cityDisplay, fc.climate, fc.season, fc.condition,
         local idx = math.random(#seasonTbl)
        fc.hiF, fc.loF, fc.feelsF, fc.humidity,
         fStr = seasonTbl[idx]
        precipCell, fc.windDir, fc.windKmh, fc.gustKmh,
        fc.cloud, fc.visKm,
        opsImpact,
        opsBadge, fc.ops.hazard,
         fc.forecast,
         fc.ops.advisory
      ))
     end
     end
  end


    local stats = getRandomWeatherStats(climateName, seasonName)
  table.insert(out, "|}")
  table.insert(out, "</div>")
  return table.concat(out, "\n")
end


    local forecastLower = fStr:lower()
function p.weatherForCity(frame)
    if forecastLower:find("rain")
  local args = (frame and frame.args) or {}
      or forecastLower:find("drizzle")
  local cityRequested = tostring(args.city or "")
      or forecastLower:find("downpour")
  if cityRequested == "" then
      or forecastLower:find("shower")
    return "Error: Please specify a city. E.g. {{#invoke:BassaridiaForecast|weatherForCity|city=Vaeringheim}}"
      or forecastLower:find("snow")
   end
      or forecastLower:find("storm")
      or forecastLower:find("thunder")
      or forecastLower:find("sleet")
    then
        stats.chanceOfRain = math.random(70, 99)
    end
 
    local hiF  = cToF(stats.high)
    local loF  = cToF(stats.low)
    local hum   = stats.humidity
    local cRain = stats.chanceOfRain
    local wDir  = stats.windDir
    local wSpd  = stats.windSpeed


    local rowColor = getEventColor(fStr)
  local di = getDateInfo(args)
    local advisory = getAdvisory(fStr)
  local fc = makeForecastForCity(cityRequested, di.year, di.dayOfYear)
  if not fc then
    return "Error: City '" .. cityRequested .. "' not found in cityData."
  end


    local out = {}
  local opsBadge = spanBadge(fc.ops.level, fc.ops.label .. " – " .. fc.condition)
    table.insert(out, '{| class="wikitable" style="width:100%; text-align:left;"' .. "\n")
  local precipCell = string.format("%s %d%%", fc.precipType, fc.precipProb)
    table.insert(out, [[
! Climate
! Season
! High °F
! Low °F
! Humidity (%)
! Chance of Rain (%)
! Wind Dir
! Wind Speed (km/h)
! style="width:20em;" | Today's Weather
! Natural Disaster Advisory
]])
    table.insert(out, "|-\n")


    local row = string.format([[
  local out = {}
| %s
  table.insert(out, '{| class="wikitable" style="width:100%; text-align:left;"')
| %s
  table.insert(out, table.concat({
| %d
    "! Climate",
| %d
    "! Season",
| %d
    "! Condition",
| %d
    "! High °F",
| %s
    "! Low °F",
| %d
    "! Feels Like °F",
| style="background-color:%s" | %s
    "! Humidity (%)",
| %s
    "! Precip",
]], climateName, seasonName, hiF, loF, hum, cRain, wDir, wSpd, rowColor, fStr, advisory)
    "! Wind (dir / km/h / gust)",
     table.insert(out, row)
    "! Cloud (%)",
    "! Vis (km)",
    "! Ops Impact",
    "! Ops Alert",
    "! Today's Weather",
     "! Natural Disaster Advisory"
  }, "\n"))


    table.insert(out, "|}\n\n")
  table.insert(out, "|-")
  table.insert(out, string.format(
    "| %s\n| %s\n| %s\n| %d\n| %d\n| %d\n| %d\n| %s\n| %s / %d / %d\n| %d\n| %d\n| %s\n| %s<br/>%s\n| %s\n| %s",
    fc.climate, fc.season, fc.condition,
    fc.hiF, fc.loF, fc.feelsF, fc.humidity,
    precipCell,
    fc.windDir, fc.windKmh, fc.gustKmh,
    fc.cloud, fc.visKm,
    formatImpact(fc.ops.impacts),
    opsBadge, fc.ops.hazard,
    fc.forecast,
    fc.ops.advisory
  ))


    return table.concat(out)
  table.insert(out, "|}")
  return table.concat(out, "\n")
end
end


---------------------------------------------------------
---------------------------------------------------------
-- 10C. STARS FOR TONIGHT
-- 15) Stars for tonight (deterministic)
--    Unchanged
---------------------------------------------------------
---------------------------------------------------------
local observerLat = 38.0  -- rough “Bassaridia mid-latitude”; adjust if desired
local starChartData = {
local starChartData = {
    { name="Agave",    starClass="M", lat=29.5 },
   { name="Azos",    starClass="Host Star", lat=62.0 },
    { name="Amaäz",    starClass="K", lat=31  },
   { name="Danaß",    starClass="Host Star", lat=54.0 },
    { name="Amáenu",  starClass="G", lat=45  },
   { name="Aprobelle",starClass="Host Star", lat=46.0 },
    { name="Amap",    starClass="A", lat=55  },
   { name="Eos",     starClass="Host Star", lat=40.0 },
    { name="Amazä",    starClass="F", lat=39  },
   { name="Thalassa", starClass="Host Star", lat=34.0 },
    { name="Atämios",  starClass="F", lat=56   },
   { name="Cato",    starClass="Host Star", lat=28.0 },
    { name="Aprobelle",starClass="A", lat=59  },
   { name="Noctis",  starClass="Host Star", lat=22.0 }
     { name="Bebeakaus",starClass="K", lat=30.5 },
    { name="Bulhanu",  starClass="A", lat=40  },
    { name="Crösacío", starClass="K", lat=17   },
    { name="Danaß",    starClass="A", lat=67  },
    { name="Dilëtaz",  starClass="G", lat=36  },
    { name="Dranamos", starClass="A", lat=58  },
    { name="Gaht",    starClass="G", lat=29   },
    { name="Häpi",     starClass="F", lat=78  },
    { name="Hazaméos", starClass="A", lat=72   },
    { name="Liléigos", starClass="F", lat=18  },
    { name="Nyama",    starClass="F", lat=64   },
    { name="Ocananus", starClass="F", lat=60.5 },
    { name="Orebele",  starClass="F", lat=22  },
    { name="Osiríos",  starClass="G", lat=53  },
    { name="Pythe",    starClass="A", lat=45   },
    { name="Sanashalo",starClass="G", lat=27  },
     { name="Tä",      starClass="F", lat=39.5 },
    { name="Vï",      starClass="A", lat=46   },
    { name="Wedíos",  starClass="A", lat=54  },
}
}


local observerLat = 49 -- Vaeringheim ~49°N
function p.starsForTonight(frame)
  local args = (frame and frame.args) or {}
  local di = getDateInfo(args)
  local nextInt = lcg(hash31(string.format("stars:%d:%03d", di.year, di.dayOfYear)))


local function isVisibleAt630(lat)
  local function isVisibleAt630(lat)
     local offset = math.random(5,55)
    -- add a deterministic “seasonal/atmospheric” wobble
     local offset = randInt(nextInt, 5, 55)
     return (lat < (observerLat + offset))
     return (lat < (observerLat + offset))
end
  end
 
function p.starsForTonight(frame)
    local date  = os.date("*t")
    local dailySeed = (date.year * 1000) + date.yday + 777
    math.randomseed(dailySeed)
 
    local out = {}
    table.insert(out, "=== Northern Host Stars Visible at ~6:30 PM ===\n\n")
 
    table.insert(out, '{| class="wikitable" style="width:100%; text-align:left;"' .. "\n")
    table.insert(out, "! Star !! Class !! Star-Lat !! Visible? !! Approx. Altitude\n")


    for _, star in ipairs(starChartData) do
  local out = {}
        local yesNo = "No"
  table.insert(out, "=== Northern Host Stars Visible at ~6:30 PM ===")
        local alt   = 0
   table.insert(out, "")
        if isVisibleAt630(star.lat) then
  table.insert(out, '{| class="wikitable" style="width:100%; text-align:left;"')
            yesNo = "Yes"
   table.insert(out, "! Star !! Class !! Star-Lat !! Visible? !! Approx. Altitude")
            alt   = math.random(20,80)
        end


        table.insert(out, "|-\n")
  for _, star in ipairs(starChartData) do
        table.insert(out, string.format(
    local yesNo, alt = "No", 0
            "| %s || %s || %.1f°N || %s || %d°\n",
    if isVisibleAt630(star.lat) then
            star.name, star.starClass, star.lat, yesNo, alt
      yesNo = "Yes"
        ))
      alt = randInt(nextInt, 20, 80)
     end
     end
    table.insert(out, "|-")
    table.insert(out, string.format("| %s || %s || %.1f°N || %s || %d°", star.name, star.starClass, star.lat, yesNo, alt))
  end


    table.insert(out, "|}\n\n")
  table.insert(out, "|}")
 
  return table.concat(out, "\n")
    return table.concat(out)
end
end


---------------------------------------------------------
-- Return the module table
---------------------------------------------------------
return p
return p

Revision as of 21:09, 1 January 2026

Documentation for this module may be created at Module:BassaridiaForecast/doc

---------------------------------------------------------
-- Module:BassaridiaForecast  (Rewritten w/ Ops Alert)
--
-- Outputs:
--   * weatherForecast(frame)  -> full daily forecast table for all cities
--   * weatherForCity(frame)   -> single-city forecast table
--   * starsForTonight(frame)  -> deterministic host-star visibility table
--   * getOpsData(city, year, dayOfYear) -> structured ops alert signal for other modules
--   * opsAlert(frame)         -> quick badge + impacts + hazard for a single city
--
-- Key features:
--   - Deterministic per (city, year, day), so Hatch/Missionary/WL modules match exactly
--   - Adds Condition, Feels Like, Gusts, Cloud %, Visibility, Ops Impact, Ops Alert
--   - Wind ranges scale with conditions (storms/gales stronger than fair weather)
--   - Optional args: |year= |day= for testing/overrides
---------------------------------------------------------

local p = {}

---------------------------------------------------------
-- 0) Calendar integration (Module:BassaridianCalendar)
---------------------------------------------------------
local calOk, cal = pcall(require, "Module:BassaridianCalendar")

local DAYS_IN_YEAR = 183

local function getCurrentDateInfo_fallback()
  local startDate    = os.time({year=1999, month=8, day=6})
  local secondsInDay = 86400
  local currentTime  = os.time()
  local totalDays    = math.floor((currentTime - startDate) / secondsInDay)

  local psscYear  = math.floor(totalDays / DAYS_IN_YEAR)
  local dayOfYear = (totalDays % DAYS_IN_YEAR) + 1
  return { psscYear = psscYear, dayOfYear = dayOfYear }
end

local function parseCalendarString(s)
  s = tostring(s or "")
  -- Common patterns:
  -- "8, Opsitheiel (...), 52 PSSC"
  -- "8, Opsitheiel, 52 PSSC"
  local day, year = s:match("^(%d+)%s*,.-,%s*(%d+)%s*PSSC")
  if day and year then return tonumber(year), tonumber(day) end

  -- More forgiving: first number is day, last is year
  year = s:match("(%d+)%s*PSSC")
  day  = s:match("^(%d+)")
  if year and day then return tonumber(year), tonumber(day) end

  return nil, nil
end

local function getDateInfo(args)
  args = args or {}
  local yy = tonumber(args.year)
  local dd = tonumber(args.day)

  if yy and dd and dd >= 1 and dd <= DAYS_IN_YEAR then
    return { year = yy, dayOfYear = dd, source = "args" }
  end

  if calOk and cal and type(cal.getCurrentDate) == "function" then
    local raw = cal.getCurrentDate()
    local y, d = parseCalendarString(raw)
    if y and d and d >= 1 and d <= DAYS_IN_YEAR then
      return { year = y, dayOfYear = d, source = "calendar", raw = raw }
    end
  end

  local fb = getCurrentDateInfo_fallback()
  return { year = fb.psscYear, dayOfYear = fb.dayOfYear, source = "fallback" }
end

---------------------------------------------------------
-- 1) Season helper
---------------------------------------------------------
local function getSeasonName(dayOfYear)
  if dayOfYear <= 61 then
    return "Atosiel"
  elseif dayOfYear <= 122 then
    return "Thalassiel"
  else
    return "Opsitheiel"
  end
end

---------------------------------------------------------
-- 2) Deterministic RNG helpers
---------------------------------------------------------
local function hash31(str)
  local h = 0
  str = tostring(str or "")
  for i = 1, #str do
    h = (h * 131 + str:byte(i)) % 2147483647
  end
  return h
end

local function lcg(seed)
  local s = seed % 2147483647
  if s <= 0 then s = s + 2147483646 end
  return function()
    s = (1103515245 * s + 12345) % 2147483647
    return s
  end
end

local function rand01(nextInt) return (nextInt() % 1000000) / 1000000 end

local function randInt(nextInt, a, b)
  a, b = tonumber(a) or 0, tonumber(b) or 0
  if b < a then a, b = b, a end
  return a + (nextInt() % (b - a + 1))
end

local function pickFromList(nextInt, list)
  if not list or #list == 0 then return nil end
  return list[(nextInt() % #list) + 1]
end

---------------------------------------------------------
-- 3) Conversions + derived weather
---------------------------------------------------------
local function cToF(c)
  if type(c) == "number" then
    return math.floor(c * 9/5 + 32 + 0.5)
  end
  return c
end

local function conditionFromText(t)
  t = (t or ""):lower()
  if t:find("blizzard") then return "Blizzard" end
  if t:find("snow") or t:find("sleet") or t:find("flurr") or t:find("ice") then return "Snow" end
  if t:find("thunder") or t:find("severe") or t:find("downpour") or t:find("storm") then return "Storm" end
  if t:find("rain") or t:find("drizzle") or t:find("shower") then return "Rain" end
  if t:find("fog") or t:find("mist") or t:find("low visibility") then return "Fog" end
  if t:find("dust") or t:find("sandstorm") or t:find("blowing sand") then return "Dust" end
  if t:find("clear") or t:find("spotless") or t:find("sunny") then return "Clear" end
  if t:find("overcast") or t:find("cloud") or t:find("gloom") or t:find("haze") then return "Cloudy" end
  return "Fair"
end

-- Rothfusz regression (approx; OK for narrative/ops signal)
local function heatIndexF(T, RH)
  return -42.379 + 2.04901523*T + 10.14333127*RH - 0.22475541*T*RH
         - 0.00683783*T*T - 0.05481717*RH*RH + 0.00122874*T*T*RH
         + 0.00085282*T*RH*RH - 0.00000199*T*T*RH*RH
end

local function windChillF(T, windMph)
  return 35.74 + 0.6215*T - 35.75*(windMph^0.16) + 0.4275*T*(windMph^0.16)
end

local function computeFeelsLikeF(hiF, hum, windKmh)
  local windMph = (tonumber(windKmh) or 0) * 0.621371
  hiF = tonumber(hiF) or 0
  hum = tonumber(hum) or 0
  if hiF >= 80 and hum >= 40 then
    return math.floor(heatIndexF(hiF, hum) + 0.5)
  end
  if hiF <= 50 and windMph >= 3 then
    return math.floor(windChillF(hiF, windMph) + 0.5)
  end
  return hiF
end

local function computeCloudAndVis(nextInt, cond)
  if cond == "Clear" then
    return randInt(nextInt, 0, 20), randInt(nextInt, 15, 30)
  elseif cond == "Cloudy" then
    return randInt(nextInt, 35, 95), randInt(nextInt, 10, 25)
  elseif cond == "Fog" then
    return randInt(nextInt, 90, 100), randInt(nextInt, 1, 6)
  elseif cond == "Rain" then
    return randInt(nextInt, 70, 100), randInt(nextInt, 4, 15)
  elseif cond == "Storm" then
    return randInt(nextInt, 85, 100), randInt(nextInt, 2, 10)
  elseif cond == "Snow" then
    return randInt(nextInt, 80, 100), randInt(nextInt, 2, 10)
  elseif cond == "Blizzard" then
    return randInt(nextInt, 90, 100), randInt(nextInt, 1, 5)
  elseif cond == "Dust" then
    return randInt(nextInt, 20, 80), randInt(nextInt, 2, 10)
  end
  return randInt(nextInt, 10, 60), randInt(nextInt, 10, 25)
end

local function computeImpacts(cond, precipProb, windKmh, visKm, hiF, loF)
  local impacts = { ground="OK", sea="OK", air="OK" }
  precipProb = tonumber(precipProb) or 0
  windKmh = tonumber(windKmh) or 0
  visKm = tonumber(visKm) or 99
  hiF = tonumber(hiF) or 0
  loF = tonumber(loF) or 0

  -- Air
  if visKm <= 1 or windKmh >= 55 or cond == "Blizzard" then
    impacts.air = "No-Go"
  elseif visKm <= 5 or windKmh >= 40 or cond == "Storm" then
    impacts.air = "Limited"
  end

  -- Sea
  if windKmh >= 50 or (cond == "Storm" and precipProb >= 85) or cond == "Blizzard" then
    impacts.sea = "Closed"
  elseif windKmh >= 28 or cond == "Storm" or (cond == "Rain" and precipProb >= 80) then
    impacts.sea = "Restricted"
  end

  -- Ground
  if (cond == "Snow" or cond == "Blizzard") and (loF <= 32 or visKm <= 3) then
    impacts.ground = "Hazard"
  elseif cond == "Storm" or (cond == "Rain" and precipProb >= 80) or visKm <= 5 then
    impacts.ground = "Slow"
  end

  -- Heat/Cold stress (ground modifier)
  if hiF >= 100 and impacts.ground == "OK" then impacts.ground = "Slow" end
  if loF <= 10 and impacts.ground == "OK" then impacts.ground = "Slow" end

  return impacts
end

---------------------------------------------------------
-- 4) Ops Alert badge helpers
---------------------------------------------------------
local function alertLabel(level)
  if level == 3 then return "Red" end
  if level == 2 then return "Orange" end
  if level == 1 then return "Yellow" end
  return "Green"
end

local function alertColor(level)
  if level == 3 then return "#f8d7da" end
  if level == 2 then return "#ffe5cc" end
  if level == 1 then return "#fff3cd" end
  return "#d4edda"
end

local function spanBadge(level, text)
  return string.format(
    "<span style='background:%s;padding:2px 6px;border:1px solid #ccc;border-radius:10px;white-space:nowrap;'>%s</span>",
    alertColor(level), text
  )
end

local function formatImpact(imp)
  imp = imp or {}
  return string.format("Gnd: %s; Sea: %s; Air: %s", imp.ground or "OK", imp.sea or "OK", imp.air or "OK")
end

---------------------------------------------------------
-- 5) Temperature ranges per climate (°C base; displayed °F)
---------------------------------------------------------
local climateTemperature = {
  ["Humid Subtropical"] = {
    Atosiel     = { hiMin=18, hiMax=26, loMin=10, loMax=16, humMin=60, humMax=80 },
    Thalassiel  = { hiMin=25, hiMax=34, loMin=19, loMax=24, humMin=65, humMax=90 },
    Opsitheiel  = { hiMin=20, hiMax=28, loMin=12, loMax=18, humMin=50, humMax=75 }
  },
  ["Oceanic"] = {
    Atosiel     = { hiMin=10, hiMax=17, loMin=5,  loMax=12, humMin=70, humMax=90 },
    Thalassiel  = { hiMin=14, hiMax=21, loMin=9,  loMax=14, humMin=65, humMax=85 },
    Opsitheiel  = { hiMin=5,  hiMax=12, loMin=1,  loMax=6,  humMin=70, humMax=90 }
  },
  ["Subpolar Oceanic"] = {
    Atosiel     = { hiMin=3,  hiMax=9,  loMin=-2, loMax=3,  humMin=70, humMax=95 },
    Thalassiel  = { hiMin=6,  hiMax=12, loMin=1,  loMax=6,  humMin=70, humMax=90 },
    Opsitheiel  = { hiMin=-1, hiMax=4,  loMin=-6, loMax=-1, humMin=75, humMax=95 }
  },
  ["Mediterranean (Hot Summer)"] = {
    Atosiel     = { hiMin=15, hiMax=21, loMin=7,  loMax=12, humMin=40, humMax=60 },
    Thalassiel  = { hiMin=25, hiMax=35, loMin=15, loMax=20, humMin=30, humMax=50 },
    Opsitheiel  = { hiMin=12, hiMax=18, loMin=5,  loMax=10, humMin=45, humMax=65 }
  },
  ["Hot Desert"] = {
    Atosiel     = { hiMin=25, hiMax=35, loMin=12, loMax=18, humMin=10, humMax=30 },
    Thalassiel  = { hiMin=35, hiMax=45, loMin=25, loMax=30, humMin=5,  humMax=20 },
    Opsitheiel  = { hiMin=22, hiMax=30, loMin=10, loMax=16, humMin=5,  humMax=25 }
  },
  ["Cold Steppe"] = {
    Atosiel     = { hiMin=10, hiMax=18, loMin=2,  loMax=8,  humMin=30, humMax=50 },
    Thalassiel  = { hiMin=20, hiMax=28, loMin=10, loMax=15, humMin=25, humMax=45 },
    Opsitheiel  = { hiMin=0,  hiMax=7,  loMin=-6, loMax=0,  humMin=35, humMax=55 }
  },
  ["Hot Steppe"] = {
    Atosiel     = { hiMin=23, hiMax=30, loMin=12, loMax=18, humMin=20, humMax=40 },
    Thalassiel  = { hiMin=30, hiMax=40, loMin=20, loMax=25, humMin=15, humMax=35 },
    Opsitheiel  = { hiMin=25, hiMax=33, loMin=15, loMax=20, humMin=15, humMax=40 }
  },
  ["Subarctic"] = {
    Atosiel     = { hiMin=0,  hiMax=8,  loMin=-10,loMax=-1, humMin=50, humMax=80 },
    Thalassiel  = { hiMin=5,  hiMax=15, loMin=-1, loMax=5,  humMin=50, humMax=85 },
    Opsitheiel  = { hiMin=-5, hiMax=0,  loMin=-15,loMax=-5, humMin=60, humMax=90 }
  }
}

---------------------------------------------------------
-- 6) Weather flavor strings by climate & season
-- (Keep/expand these freely—these strings drive condition + advisory keywords.)
---------------------------------------------------------
local climateEvents = {
  ["Humid Subtropical"] = {
    Atosiel = {
      "Morning drizzle and warm afternoon sunshine",
      "Mild thunderstorm building by midday",
      "Patchy fog at dawn, clearing toward lunch",
      "Light rain with sunny breaks after noon",
      "Overcast for part of the day, mild temperatures",
      "Scattered clouds with a brief shower by dusk",
      "Warm breezes carrying faint floral scents",
      "Early morning seasonal storms and lake-driven floods may develop"
    },
    Thalassiel = {
      "Hot, steamy day with intense midday heat",
      "Tropical-like humidity, afternoon thunder possible",
      "Intermittent heavy downpours, muggy evenings",
      "High humidity and patchy thunderstorms late",
      "Heat advisory with only brief cooling at night",
      "Nighttime storms lingering into early morning",
      "Afternoon heavy rains could trigger river floods"
    },
    Opsitheiel = {
      "Warm daytime, gentle evening breezes",
      "Occasional rain, otherwise mild temperatures",
      "Fog at dawn, warm midday, pleasant night",
      "Evening drizzle with mild breezes",
      "Short-lived shower, then clearing skies",
      "Evening cooling may lead to localized mudslides on steep slopes"
    }
  },

  ["Oceanic"] = {
    Atosiel = {
      "Frequent light rain, cool breezes all day",
      "Foggy dawn, mild midmorning sunshine",
      "Short sunbreaks among passing showers",
      "Consistent drizzle, fresh winds from the sea",
      "Morning fog with coastal storms and marsh flooding possible"
    },
    Thalassiel = {
      "Light rain off and on, mild temperatures",
      "Overcast with comfortable breezes throughout",
      "Moist air with lingering fog near streams",
      "Intermittent showers may escalate into flash floods and coastal storms"
    },
    Opsitheiel = {
      "Overcast skies, cool drizzle through the day",
      "Low visibility in morning fog, slow clearing",
      "Heavier showers arriving late afternoon",
      "Evening drizzle may develop into coastal gales and shoreline flooding"
    }
  },

  ["Subpolar Oceanic"] = {
    Atosiel = {
      "Near-freezing dawn, cold drizzle by midday",
      "Overcast skies, mix of rain and sleet",
      "Snowmelt water raising local streams",
      "Cold drizzle combined with snowstorms and blizzards in higher areas"
    },
    Thalassiel = {
      "Cool, short days with periodic drizzle",
      "Short bursts of rain followed by fog",
      "Misty conditions could prompt snowstorms and river floods"
    },
    Opsitheiel = {
      "Steady cold rain mixing with wet snow",
      "Freezing drizzle at dawn, raw gusty winds",
      "Cloudy, damp day with potential sleet storms",
      "Freezing drizzle may trigger avalanches in higher passes"
    }
  },

  ["Mediterranean (Hot Summer)"] = {
    Atosiel = {
      "Mild temperatures, bright sunshine, dry air",
      "Sunny midday with a crisp, light wind",
      "Short drizzly spell, then mostly clear",
      "Bright skies overshadowed by potential heat advisory and droughts"
    },
    Thalassiel = {
      "Very hot midday sun, minimal clouds",
      "Heatwaves persisting, zero rain expected",
      "Intense midday sun might lead to severe heat waves and dust storms"
    },
    Opsitheiel = {
      "Cooler spells, brief showery intervals",
      "Sporadic light rainfall, refreshing breezes",
      "Cooling breezes could reduce temperatures, but localized brushfires and summer wildfires remain possible"
    }
  },

  ["Hot Desert"] = {
    Atosiel = {
      "Dry air, bright sunshine, no clouds in sight",
      "Gentle breezes raising light sand by midday",
      "Minor dust devil scouring the dunes",
      "Scorching afternoons may be accompanied by dust storms and occasional mirage-like shimmers"
    },
    Thalassiel = {
      "Blistering midday sun, risk of heat stroke",
      "Occasional dust storm cutting visibility briefly",
      "Extreme heat could combine with blowing sand to trigger potential sandstorms"
    },
    Opsitheiel = {
      "Warm, mostly sunny with cooler nights",
      "Occasional gust picking up desert grit",
      "Evening cooling might still see intense heat with possible dust devils on dry slopes"
    }
  },

  ["Cold Steppe"] = {
    Atosiel = {
      "Cool mornings, moderate midday warmth, breezy",
      "Light rain passing through grasslands midday",
      "Variable cloud cover, mild temperature swings",
      "Mild conditions could turn volatile with sudden flash floods and minor landslides"
    },
    Thalassiel = {
      "Warm day, potential for gusty thunderstorms",
      "Cloud buildup, short but intense showers possible",
      "Rolling thunder near dusk, rain squalls possible",
      "Afternoon heat may intensify, leading to thunderstorms, river floods, and gusty rain squalls"
    },
    Opsitheiel = {
      "Wind-driven chill under gray skies",
      "Possible light snowfall, especially overnight",
      "Sparse precipitation, dryness persists, cold air",
      "Chilly nights might bring frost and the risk of localized landslides on steep slopes"
    }
  },

  ["Hot Steppe"] = {
    Atosiel = {
      "Hot daytime sun, slight breeze, no rain",
      "Gusty wind with dust swirling near midday",
      "Scorching sun may lead to persistent dryness with a risk of brushfires and dust devils on dry slopes"
    },
    Thalassiel = {
      "Brutally hot midday sun, patchy dust storms",
      "Occasional swirling wind gusts, drifting dust",
      "Intense heat may trigger severe dust storms and a potential heat wave"
    },
    Opsitheiel = {
      "Hot days, moderate evenings, stable dryness",
      "Dust devils possible over parched plains",
      "Evening cooling might reduce temperatures, yet localized sandstorms and minor landslides remain a concern"
    }
  },

  ["Subarctic"] = {
    Atosiel = {
      "Snow melting, slushy paths, daytime sunshine",
      "Mixed precipitation, cold rain at lower altitudes",
      "Rapid thawing could spark river floods and snowstorms with heavy snowfall"
    },
    Thalassiel = {
      "Short, cool days, moderate sunshine at times",
      "Drizzle commonly, heavier rainfall occasionally",
      "Cool days may be interrupted by sudden blizzard-like conditions and heavy rain squalls"
    },
    Opsitheiel = {
      "Early snowfall returning, freezing ground rapidly",
      "Blizzard-like conditions with large snow accumulations",
      "Deep winter chill may result in persistent blizzards and avalanches in higher passes"
    }
  }
}

---------------------------------------------------------
-- 7) Triggered disaster keywords (extra advisory layer)
---------------------------------------------------------
local triggeredDisasters = {
  { keywords={"seasonal storms","lake%-driven floods"}, hazard="Seasonal storms and lake-driven floods" },
  { keywords={"river floods","heavy rains"}, hazard="River floods during heavy rains" },
  { keywords={"snowstorms","heavy snowfall"}, hazard="Snowstorms / heavy snowfall" },
  { keywords={"avalanch"}, hazard="Avalanche risk" },
  { keywords={"landslides","mudslides"}, hazard="Landslides / mudslides" },
  { keywords={"drought","persistent dryness","dry conditions"}, hazard="Drought / elevated fire risk" },
  { keywords={"brushfires","wildfires"}, hazard="Brushfire / wildfire risk" },
  { keywords={"dust storm","sandstorm","blowing sand"}, hazard="Dust / sandstorm risk" },
  { keywords={"fog","low visibility"}, hazard="Fog-related hazards" },
  { keywords={"severe storm","thunder"}, hazard="Severe storm / lightning risk" },
  { keywords={"shoreline flooding","storm surges","coastal gales"}, hazard="Coastal flooding / surge risk" }
}

---------------------------------------------------------
-- 8) Wind directions
---------------------------------------------------------
local windDirections = { "N","NE","E","SE","S","SW","W","NW" }

---------------------------------------------------------
-- 9) City list
---------------------------------------------------------
local cityData = {
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Vaeringheim Vaeringheim]",       climate = "Humid Subtropical"},
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Luminaria Luminaria]",         climate = "Oceanic"},
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Serena Serena]",               climate = "Subpolar Oceanic"},
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Pyralis Pyralis]",             climate = "Oceanic"},
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Symphonara Symphonara]",       climate = "Oceanic"},
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Aurelia Aurelia]",             climate = "Mediterranean (Hot Summer)"},
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Somniumpolis Somniumpolis]",   climate = "Humid Subtropical"},
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Nexa Nexa]",                   climate = "Oceanic"},
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Saluria Saluria]",             climate = "Oceanic"},
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Aetherium Aetherium]",         climate = "Subarctic"},
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Ferrum_Citadel Ferrum Citadel]", climate = "Hot Desert"},
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Acheron Acheron]",             climate = "Cold Steppe"},
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Tonar Tonar]",                 climate = "Cold Steppe"}, -- ADDED
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Ephyra Ephyra]",               climate = "Subpolar Oceanic"},
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Keybir-Aviv Keybir-Aviv]",     climate = "Humid Subtropical"},
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Tel-Amin Tel-Amin]",           climate = "Mediterranean (Hot Summer)"},
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Diamandis Diamandis]",         climate = "Mediterranean (Hot Summer)"},
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Thermosalem Thermosalem]",     climate = "Oceanic"},
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Sufriya Sufriya]",             climate = "Humid Subtropical"},
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Akróstadium Akróstadium]",     climate = "Cold Steppe"},
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Lykopolis Lykopolis]",         climate = "Oceanic"},
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Ourid Ourid]",                 climate = "Cold Steppe"},
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Bashkim Bashkim]",             climate = "Cold Steppe"},
  {city = "Slevik",       climate = "Subpolar Oceanic"},
  {city = "Sårensby",     climate = "Subpolar Oceanic"},
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Sjøsborg Sjøsborg]",           climate = "Subpolar Oceanic"},
  {city = "[https://micras.org/mwiki/List_of_cities_in_Bassaridia_Vaeringheim#Storesund Storesund]",         climate = "Subpolar Oceanic"},
  {city = "Fanghorn",     climate = "Subarctic"},
  {city = "Eikbu",        climate = "Subarctic"}
}

---------------------------------------------------------
-- 10) City name parsing (for deterministic seeds + ops API)
---------------------------------------------------------
local function extractPlainCity(cityField)
  cityField = tostring(cityField or "")
  -- [https://... Label]
  local label = cityField:match("^%[https?://[^%s]+%s+([^%]]+)%]$")
  if label and label ~= "" then return label end
  -- [[...|Label]]
  label = cityField:match("^%[%[[^%]|]+|([^%]]+)%]%]$")
  if label and label ~= "" then return label end
  -- [[Label]]
  label = cityField:match("^%[%[([^%]]+)%]%]$")
  if label and label ~= "" then return label end
  return cityField
end

local function getCityEntryByName(cityName)
  local wanted = tostring(cityName or "")
  for _, entry in ipairs(cityData) do
    local plain = extractPlainCity(entry.city)
    if plain == wanted then return entry, plain end
  end
  return nil, wanted
end

---------------------------------------------------------
-- 11) Ops alert computation
---------------------------------------------------------
local function computeAlert(nextInt, cond, impacts, precipProb, windKmh, visKm, hiF, loF, forecastText)
  local level = 0
  local hazard = "No reports"

  local function bump() level = math.min(3, level + 1) end

  precipProb = tonumber(precipProb) or 0
  windKmh = tonumber(windKmh) or 0
  visKm = tonumber(visKm) or 99
  hiF = tonumber(hiF) or 0
  loF = tonumber(loF) or 0

  -- Base bump from impacts
  if impacts and (impacts.air ~= "OK" or impacts.sea ~= "OK" or impacts.ground ~= "OK") then bump() end
  if impacts and (impacts.air == "No-Go" or impacts.sea == "Closed" or impacts.ground == "Hazard") then bump(); bump() end

  -- Additional bumps
  if windKmh >= 30 or visKm <= 5 or precipProb >= 85 then bump() end
  if hiF >= 105 or loF <= 5 then bump() end

  -- Hazard summary
  if cond == "Storm" then hazard = "Severe storm / lightning risk"
  elseif cond == "Rain" then hazard = "Heavy rain / localized flooding"
  elseif cond == "Fog" then hazard = "Low visibility / navigation hazard"
  elseif cond == "Blizzard" then hazard = "Blizzard / whiteout conditions"
  elseif cond == "Snow" then hazard = "Snow/ice / hazardous travel"
  elseif cond == "Dust" then hazard = "Dust/sand reduction in visibility"
  else
    if hiF >= 100 then hazard = "Heat stress conditions"
    elseif loF <= 10 then hazard = "Extreme cold exposure risk"
    else hazard = "No reports"
    end
  end

  -- Deterministic “extra advisory” based on keyword hits
  local extras = {}
  local t = (forecastText or ""):lower()
  for _, item in ipairs(triggeredDisasters) do
    for _, kw in ipairs(item.keywords or {}) do
      if t:find(kw) then
        local pHit = (level >= 2) and 0.35 or 0.10
        if rand01(nextInt) <= pHit then extras[#extras+1] = item.hazard end
        break
      end
    end
  end

  local recommend = { hatch=false, missionary=false }
  if impacts and (impacts.sea ~= "OK" or impacts.air ~= "OK") then recommend.hatch = true end
  if impacts and (impacts.ground ~= "OK") then recommend.missionary = true end
  if hiF >= 100 or loF <= 10 then recommend.missionary = true end

  return {
    level = level,
    label = alertLabel(level),
    hazard = hazard,
    impacts = impacts or {ground="OK", sea="OK", air="OK"},
    extras = extras,
    recommend = recommend,
    advisory = (#extras > 0) and table.concat(extras, "; ") or "No reports"
  }
end

---------------------------------------------------------
-- 12) Core forecast generator (deterministic)
---------------------------------------------------------
local function makeForecastForCity(cityName, year, dayOfYear)
  local entry, plain = getCityEntryByName(cityName)
  if not entry then return nil end

  local season = getSeasonName(dayOfYear)
  local climateName = entry.climate

  local nextInt = lcg(hash31(string.format("wx:%d:%03d:%s", year, dayOfYear, plain)))

  local seasonTbl = climateEvents[climateName] and climateEvents[climateName][season]
  local forecastStr = (seasonTbl and #seasonTbl > 0) and pickFromList(nextInt, seasonTbl) or "No data"

  local cond = conditionFromText(forecastStr)

  local tdata = climateTemperature[climateName] and climateTemperature[climateName][season]
  if not tdata then return nil end

  local hiC = randInt(nextInt, tdata.hiMin, tdata.hiMax)
  local loC = randInt(nextInt, tdata.loMin, tdata.loMax)
  local hum = randInt(nextInt, tdata.humMin, tdata.humMax)

  -- precip probability (boost if forecast text implies precip)
  local precipProb = randInt(nextInt, 0, 100)
  local fLower = (forecastStr or ""):lower()
  if fLower:find("rain") or fLower:find("drizzle") or fLower:find("downpour") or fLower:find("shower")
     or fLower:find("snow") or fLower:find("storm") or fLower:find("thunder") or fLower:find("sleet")
  then
    precipProb = randInt(nextInt, 70, 99)
  end

  local precipType = "None"
  if cond == "Rain" or cond == "Storm" then precipType = "Rain"
  elseif cond == "Snow" or cond == "Blizzard" then precipType = "Snow"
  end

  -- wind / gusts scale by condition
  local windDir = windDirections[(nextInt() % #windDirections) + 1]
  local windKmh
  if cond == "Storm" then windKmh = randInt(nextInt, 22, 55)
  elseif cond == "Blizzard" then windKmh = randInt(nextInt, 20, 50)
  elseif cond == "Snow" then windKmh = randInt(nextInt, 10, 40)
  elseif cond == "Dust" then windKmh = randInt(nextInt, 15, 45)
  else windKmh = randInt(nextInt, 0, 28)
  end
  local gustKmh = math.min(90, windKmh + randInt(nextInt, 5, 30))

  local cloud, visKm = computeCloudAndVis(nextInt, cond)

  local hiF, loF = cToF(hiC), cToF(loC)
  local feelsF = computeFeelsLikeF(hiF, hum, windKmh)

  local impacts = computeImpacts(cond, precipProb, windKmh, visKm, hiF, loF)
  local alert = computeAlert(nextInt, cond, impacts, precipProb, windKmh, visKm, hiF, loF, forecastStr)

  return {
    entry = entry,
    cityPlain = plain,
    cityDisplay = entry.city,
    climate = climateName,
    season = season,
    condition = cond,
    hiF = hiF, loF = loF, feelsF = feelsF,
    humidity = hum,
    precipType = precipType,
    precipProb = precipProb,
    windDir = windDir,
    windKmh = windKmh,
    gustKmh = gustKmh,
    cloud = cloud,
    visKm = visKm,
    forecast = forecastStr,
    ops = alert
  }
end

---------------------------------------------------------
-- 13) Exported Ops API for other modules
---------------------------------------------------------
function p.getOpsData(cityName, year, dayOfYear)
  local y = tonumber(year)
  local d = tonumber(dayOfYear)
  if not y or not d then
    local di = getDateInfo(nil)
    y, d = di.year, di.dayOfYear
  end
  local fc = makeForecastForCity(cityName, y, d)
  if not fc then return nil end
  return {
    city = fc.cityPlain,
    climate = fc.climate,
    season = fc.season,
    condition = fc.condition,
    hiF = fc.hiF, loF = fc.loF, feelsF = fc.feelsF,
    humidity = fc.humidity,
    precipType = fc.precipType,
    precipProb = fc.precipProb,
    windDir = fc.windDir,
    windKmh = fc.windKmh,
    gustKmh = fc.gustKmh,
    cloud = fc.cloud,
    visKm = fc.visKm,
    impacts = fc.ops.impacts,
    alert = {
      level = fc.ops.level,
      label = fc.ops.label,
      hazard = fc.ops.hazard,
      recommend = fc.ops.recommend,
      advisory = fc.ops.advisory
    }
  }
end

function p.opsAlert(frame)
  local args = (frame and frame.args) or {}
  local di = getDateInfo(args)
  local city = tostring(args.city or "")
  if city == "" then
    return "Error: city parameter required. Example: {{#invoke:BassaridiaForecast|opsAlert|city=Vaeringheim}}"
  end
  local op = p.getOpsData(city, di.year, di.dayOfYear)
  if not op then return "Error: unknown city '" .. city .. "'." end
  local badge = spanBadge(op.alert.level, op.alert.label .. " – " .. (op.condition or ""))
  return string.format("%s<br/>%s<br/>%s", badge, formatImpact(op.impacts), op.alert.hazard or "No reports")
end

---------------------------------------------------------
-- 14) Forecast tables
---------------------------------------------------------
function p.weatherForecast(frame)
  local args = (frame and frame.args) or {}
  local di = getDateInfo(args)
  local dayOfYear, yearNumber = di.dayOfYear, di.year
  local seasonName = getSeasonName(dayOfYear)

  local out = {}
  table.insert(out, "== Daily Weather Forecast ==")
  table.insert(out, string.format("''(Day %d of Year %d PSSC, %s)''", dayOfYear, yearNumber, seasonName))
  table.insert(out, "")

  table.insert(out, '<div style="overflow-x:auto;">')
  table.insert(out, '{| class="wikitable sortable" style="width:100%; text-align:left;"')
  table.insert(out, table.concat({
    "! City",
    "! Climate",
    "! Season",
    "! Condition",
    "! High °F",
    "! Low °F",
    "! Feels Like °F",
    "! Humidity (%)",
    "! Precip",
    "! Wind Dir",
    "! Wind (km/h)",
    "! Gust (km/h)",
    "! Cloud (%)",
    "! Vis (km)",
    "! Ops Impact",
    "! Ops Alert",
    "! Today's Weather",
    "! Natural Disaster Advisory"
  }, "\n"))

  for _, entry in ipairs(cityData) do
    local cityPlain = extractPlainCity(entry.city)
    local fc = makeForecastForCity(cityPlain, yearNumber, dayOfYear)
    if fc then
      local opsBadge = spanBadge(fc.ops.level, fc.ops.label)
      local opsImpact = formatImpact(fc.ops.impacts)
      local precipCell = string.format("%s %d%%", fc.precipType, fc.precipProb)

      table.insert(out, "|-")
      table.insert(out, string.format(
        "| %s\n| %s\n| %s\n| %s\n| %d\n| %d\n| %d\n| %d\n| %s\n| %s\n| %d\n| %d\n| %d\n| %d\n| %s\n| %s<br/>%s\n| %s\n| %s",
        fc.cityDisplay, fc.climate, fc.season, fc.condition,
        fc.hiF, fc.loF, fc.feelsF, fc.humidity,
        precipCell, fc.windDir, fc.windKmh, fc.gustKmh,
        fc.cloud, fc.visKm,
        opsImpact,
        opsBadge, fc.ops.hazard,
        fc.forecast,
        fc.ops.advisory
      ))
    end
  end

  table.insert(out, "|}")
  table.insert(out, "</div>")
  return table.concat(out, "\n")
end

function p.weatherForCity(frame)
  local args = (frame and frame.args) or {}
  local cityRequested = tostring(args.city or "")
  if cityRequested == "" then
    return "Error: Please specify a city. E.g. {{#invoke:BassaridiaForecast|weatherForCity|city=Vaeringheim}}"
  end

  local di = getDateInfo(args)
  local fc = makeForecastForCity(cityRequested, di.year, di.dayOfYear)
  if not fc then
    return "Error: City '" .. cityRequested .. "' not found in cityData."
  end

  local opsBadge = spanBadge(fc.ops.level, fc.ops.label .. " – " .. fc.condition)
  local precipCell = string.format("%s %d%%", fc.precipType, fc.precipProb)

  local out = {}
  table.insert(out, '{| class="wikitable" style="width:100%; text-align:left;"')
  table.insert(out, table.concat({
    "! Climate",
    "! Season",
    "! Condition",
    "! High °F",
    "! Low °F",
    "! Feels Like °F",
    "! Humidity (%)",
    "! Precip",
    "! Wind (dir / km/h / gust)",
    "! Cloud (%)",
    "! Vis (km)",
    "! Ops Impact",
    "! Ops Alert",
    "! Today's Weather",
    "! Natural Disaster Advisory"
  }, "\n"))

  table.insert(out, "|-")
  table.insert(out, string.format(
    "| %s\n| %s\n| %s\n| %d\n| %d\n| %d\n| %d\n| %s\n| %s / %d / %d\n| %d\n| %d\n| %s\n| %s<br/>%s\n| %s\n| %s",
    fc.climate, fc.season, fc.condition,
    fc.hiF, fc.loF, fc.feelsF, fc.humidity,
    precipCell,
    fc.windDir, fc.windKmh, fc.gustKmh,
    fc.cloud, fc.visKm,
    formatImpact(fc.ops.impacts),
    opsBadge, fc.ops.hazard,
    fc.forecast,
    fc.ops.advisory
  ))

  table.insert(out, "|}")
  return table.concat(out, "\n")
end

---------------------------------------------------------
-- 15) Stars for tonight (deterministic)
---------------------------------------------------------
local observerLat = 38.0  -- rough “Bassaridia mid-latitude”; adjust if desired

local starChartData = {
  { name="Azos",     starClass="Host Star", lat=62.0 },
  { name="Danaß",    starClass="Host Star", lat=54.0 },
  { name="Aprobelle",starClass="Host Star", lat=46.0 },
  { name="Eos",      starClass="Host Star", lat=40.0 },
  { name="Thalassa", starClass="Host Star", lat=34.0 },
  { name="Cato",     starClass="Host Star", lat=28.0 },
  { name="Noctis",   starClass="Host Star", lat=22.0 }
}

function p.starsForTonight(frame)
  local args = (frame and frame.args) or {}
  local di = getDateInfo(args)
  local nextInt = lcg(hash31(string.format("stars:%d:%03d", di.year, di.dayOfYear)))

  local function isVisibleAt630(lat)
    -- add a deterministic “seasonal/atmospheric” wobble
    local offset = randInt(nextInt, 5, 55)
    return (lat < (observerLat + offset))
  end

  local out = {}
  table.insert(out, "=== Northern Host Stars Visible at ~6:30 PM ===")
  table.insert(out, "")
  table.insert(out, '{| class="wikitable" style="width:100%; text-align:left;"')
  table.insert(out, "! Star !! Class !! Star-Lat !! Visible? !! Approx. Altitude")

  for _, star in ipairs(starChartData) do
    local yesNo, alt = "No", 0
    if isVisibleAt630(star.lat) then
      yesNo = "Yes"
      alt = randInt(nextInt, 20, 80)
    end
    table.insert(out, "|-")
    table.insert(out, string.format("| %s || %s || %.1f°N || %s || %d°", star.name, star.starClass, star.lat, yesNo, alt))
  end

  table.insert(out, "|}")
  return table.concat(out, "\n")
end

return p